Better Federation Support

This commit is contained in:
Tim Zöller 2025-12-15 21:55:17 +01:00
parent 15b420b87a
commit 5b687883b0
22 changed files with 2931 additions and 49 deletions

View file

@ -0,0 +1,217 @@
package org.operaton.fitpub.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for WebFingerClient.
*/
@ExtendWith(MockitoExtension.class)
class WebFingerClientTest {
private WebFingerClient webFingerClient;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
webFingerClient = new WebFingerClient(objectMapper);
ReflectionTestUtils.setField(webFingerClient, "localDomain", "fitpub.test");
}
// ==================== Handle Parsing Tests ====================
@Test
void parseHandle_withAtPrefix_shouldParseCorrectly() throws Exception {
// This test uses reflection to access the private parseHandle method
String handle = "@alice@example.com";
// We can't directly test private methods, but we can test through discoverActor
// which will validate the handle parsing logic
// For now, we'll test the validation through discoverActor's exceptions
// Testing valid format doesn't throw during parsing phase
assertThatThrownBy(() -> webFingerClient.discoverActor(handle))
.isInstanceOf(IOException.class) // Will fail at network call, but parsing succeeded
.hasMessageContaining("Failed to fetch WebFinger resource");
}
@Test
void parseHandle_withoutAtPrefix_shouldParseCorrectly() throws Exception {
String handle = "alice@example.com";
assertThatThrownBy(() -> webFingerClient.discoverActor(handle))
.isInstanceOf(IOException.class) // Will fail at network call, but parsing succeeded
.hasMessageContaining("Failed to fetch WebFinger resource");
}
@Test
void parseHandle_withNullHandle_shouldThrowException() {
assertThatThrownBy(() -> webFingerClient.discoverActor(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Handle cannot be null or empty");
}
@Test
void parseHandle_withEmptyHandle_shouldThrowException() {
assertThatThrownBy(() -> webFingerClient.discoverActor(""))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Handle cannot be null or empty");
}
@Test
void parseHandle_withBlankHandle_shouldThrowException() {
assertThatThrownBy(() -> webFingerClient.discoverActor(" "))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Handle cannot be null or empty");
}
@Test
void parseHandle_withoutAtSymbol_shouldThrowException() {
assertThatThrownBy(() -> webFingerClient.discoverActor("aliceexample.com"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid handle format");
}
@Test
void parseHandle_withMultipleAtSymbols_shouldThrowException() {
assertThatThrownBy(() -> webFingerClient.discoverActor("@alice@example@com"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid handle format");
}
@Test
void parseHandle_withEmptyUsername_shouldThrowException() {
// "@example.com" becomes "example.com" after removing @, then split gives only 1 part
assertThatThrownBy(() -> webFingerClient.discoverActor("@example.com"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid handle format");
}
@Test
void parseHandle_withEmptyDomain_shouldThrowException() {
// "alice@" splits into ["alice"] (trailing empty string is discarded)
// So this fails the parts.length != 2 check
assertThatThrownBy(() -> webFingerClient.discoverActor("alice@"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid handle format");
}
@Test
void parseHandle_withInvalidUsernameCharacters_shouldThrowException() {
assertThatThrownBy(() -> webFingerClient.discoverActor("alice!@example.com"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid username format");
}
@Test
void parseHandle_withInvalidDomainFormat_shouldThrowException() {
assertThatThrownBy(() -> webFingerClient.discoverActor("alice@invalid"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid domain format");
}
@Test
void parseHandle_withValidUsernameCharacters_shouldNotThrowParsingException() {
// Valid characters: a-z, A-Z, 0-9, _, -
assertThatThrownBy(() -> webFingerClient.discoverActor("alice_bob-123@example.com"))
.isInstanceOf(IOException.class) // Fails at network, not parsing
.hasMessageContaining("Failed to fetch WebFinger resource");
}
// ==================== SSRF Protection Tests ====================
@Test
void validateDomain_withLoopbackAddress_shouldThrowException() {
// "localhost" doesn't have a dot, so it fails domain format validation
assertThatThrownBy(() -> webFingerClient.discoverActor("alice@localhost"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid domain format");
}
@Test
void validateDomain_with127_0_0_1_shouldThrowException() {
assertThatThrownBy(() -> webFingerClient.discoverActor("alice@127.0.0.1"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Loopback addresses are not allowed");
}
@Test
void validateDomain_withPrivateIP_10_0_0_1_shouldThrowException() {
assertThatThrownBy(() -> webFingerClient.discoverActor("alice@10.0.0.1"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Private IP addresses are not allowed");
}
@Test
void validateDomain_withPrivateIP_192_168_1_1_shouldThrowException() {
assertThatThrownBy(() -> webFingerClient.discoverActor("alice@192.168.1.1"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Private IP addresses are not allowed");
}
@Test
void validateDomain_withPrivateIP_172_16_0_1_shouldThrowException() {
assertThatThrownBy(() -> webFingerClient.discoverActor("alice@172.16.0.1"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Private IP addresses are not allowed");
}
@Test
void validateDomain_withLinkLocalAddress_shouldThrowException() {
assertThatThrownBy(() -> webFingerClient.discoverActor("alice@169.254.0.1"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Link-local addresses are not allowed");
}
@Test
void validateDomain_withLocalDomain_shouldThrowException() {
assertThatThrownBy(() -> webFingerClient.discoverActor("alice@fitpub.test"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Cannot discover local users via WebFinger");
}
@Test
void validateDomain_withLocalDomainCaseInsensitive_shouldThrowException() {
assertThatThrownBy(() -> webFingerClient.discoverActor("alice@FITPUB.TEST"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Cannot discover local users via WebFinger");
}
@Test
void validateDomain_withNonexistentDomain_shouldThrowException() {
assertThatThrownBy(() -> webFingerClient.discoverActor("alice@this-domain-does-not-exist-12345.invalid"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Unable to resolve domain");
}
// ==================== Integration-like Tests ====================
// Note: These tests will attempt real network calls and will fail with IOException
// In a real scenario, we'd use WireMock or similar to mock HTTP responses
@Test
void discoverActor_withValidHandle_butNoNetwork_shouldThrowIOException() {
// This test validates that valid handles pass validation
// Use a domain that definitely won't have WebFinger endpoint
assertThatThrownBy(() -> webFingerClient.discoverActor("alice@example.com"))
.isInstanceOf(IOException.class)
.hasMessageContaining("Failed to fetch WebFinger resource");
}
@Test
void discoverActor_withPublicIP_shouldPassSSRFValidation() {
// Public IP (Google DNS) should pass SSRF validation but fail at WebFinger layer
assertThatThrownBy(() -> webFingerClient.discoverActor("alice@8.8.8.8"))
.isInstanceOf(IOException.class)
.hasMessageContaining("Failed to fetch WebFinger resource");
}
}