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,418 @@
package org.operaton.fitpub.integration;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.operaton.fitpub.model.entity.Follow;
import org.operaton.fitpub.model.entity.RemoteActor;
import org.operaton.fitpub.model.entity.User;
import org.operaton.fitpub.repository.FollowRepository;
import org.operaton.fitpub.repository.RemoteActorRepository;
import org.operaton.fitpub.repository.UserRepository;
import org.operaton.fitpub.security.JwtTokenProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.annotation.Transactional;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* Integration tests for the complete federation follow flow.
* Tests the entire workflow from following a remote user to receiving accept notifications.
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Transactional
class FederationFollowFlowIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private UserRepository userRepository;
@Autowired
private FollowRepository followRepository;
@Autowired
private RemoteActorRepository remoteActorRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Value("${fitpub.base-url}")
private String baseUrl;
private User testUser;
private String authToken;
@BeforeEach
void setUp() throws NoSuchAlgorithmException {
// Generate RSA key pair for ActivityPub
KeyPair keyPair = generateRsaKeyPair();
String publicKey = encodePublicKey(keyPair.getPublic().getEncoded());
String privateKey = encodePrivateKey(keyPair.getPrivate().getEncoded());
// Create test user
testUser = User.builder()
.username("testuser")
.email("test@example.com")
.passwordHash(passwordEncoder.encode("password123"))
.displayName("Test User")
.publicKey(publicKey)
.privateKey(privateKey)
.enabled(true)
.build();
testUser = userRepository.save(testUser);
// Generate JWT token
authToken = jwtTokenProvider.createToken(testUser.getUsername());
}
private KeyPair generateRsaKeyPair() throws NoSuchAlgorithmException {
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048);
return keyGen.generateKeyPair();
}
private String encodePublicKey(byte[] keyBytes) {
String base64 = Base64.getEncoder().encodeToString(keyBytes);
return "-----BEGIN PUBLIC KEY-----\n" + base64 + "\n-----END PUBLIC KEY-----";
}
private String encodePrivateKey(byte[] keyBytes) {
String base64 = Base64.getEncoder().encodeToString(keyBytes);
return "-----BEGIN PRIVATE KEY-----\n" + base64 + "\n-----END PRIVATE KEY-----";
}
@Test
@Disabled("Requires mocking external HTTP calls to WebFinger and remote ActivityPub servers")
@DisplayName("Should follow a remote user via handle format @username@domain")
void testFollowRemoteUserWithHandle() throws Exception {
String remoteHandle = "@alice@fitpub.example";
// Perform follow request
MvcResult result = mockMvc.perform(post("/api/users/" + remoteHandle + "/follow")
.header("Authorization", "Bearer " + authToken)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PENDING"))
.andReturn();
// Verify follow record was created with PENDING status
String actorUri = baseUrl + "/users/alice"; // Would be resolved via WebFinger in real scenario
Follow follow = followRepository.findByFollowerIdAndFollowingActorUri(testUser.getId(), actorUri)
.orElse(null);
// Note: In a real scenario, this would require mocking WebFinger discovery
// For now, we verify the endpoint accepts the format
assertThat(result.getResponse().getContentAsString()).contains("PENDING");
}
@Test
@DisplayName("Should process incoming Follow activity and create follow relationship")
void testProcessIncomingFollowActivity() throws Exception {
// Create a remote actor
RemoteActor remoteActor = RemoteActor.builder()
.actorUri("https://remote.example/users/bob")
.username("bob")
.domain("remote.example")
.displayName("Bob Remote")
.inboxUrl("https://remote.example/users/bob/inbox")
.outboxUrl("https://remote.example/users/bob/outbox")
.publicKey("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----")
.lastFetchedAt(Instant.now())
.build();
remoteActor = remoteActorRepository.save(remoteActor);
// Create Follow activity
String followId = "https://remote.example/activities/follow/" + UUID.randomUUID();
Map<String, Object> followActivity = Map.of(
"@context", "https://www.w3.org/ns/activitystreams",
"type", "Follow",
"id", followId,
"actor", remoteActor.getActorUri(),
"object", baseUrl + "/users/" + testUser.getUsername(),
"published", Instant.now().toString()
);
// Post to inbox (without signature validation for test)
mockMvc.perform(post("/users/" + testUser.getUsername() + "/inbox")
.contentType("application/activity+json")
.content(objectMapper.writeValueAsString(followActivity)))
.andExpect(status().isAccepted());
// Verify follow relationship was created
Follow follow = followRepository.findByRemoteActorUriAndFollowingActorUri(
remoteActor.getActorUri(),
baseUrl + "/users/" + testUser.getUsername()
).orElse(null);
assertThat(follow).isNotNull();
assertThat(follow.getStatus()).isEqualTo(Follow.FollowStatus.ACCEPTED);
}
@Test
@DisplayName("Should process Accept activity and update follow status to ACCEPTED")
void testProcessAcceptActivity() throws Exception {
// Create a remote actor
RemoteActor remoteActor = RemoteActor.builder()
.actorUri("https://remote.example/users/carol")
.username("carol")
.domain("remote.example")
.displayName("Carol Remote")
.inboxUrl("https://remote.example/users/carol/inbox")
.outboxUrl("https://remote.example/users/carol/outbox")
.publicKey("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----")
.lastFetchedAt(Instant.now())
.build();
remoteActor = remoteActorRepository.save(remoteActor);
// Create pending follow
String followActivityId = baseUrl + "/activities/follow/" + UUID.randomUUID();
Follow pendingFollow = Follow.builder()
.followerId(testUser.getId())
.followingActorUri(remoteActor.getActorUri())
.status(Follow.FollowStatus.PENDING)
.activityId(followActivityId)
.build();
pendingFollow = followRepository.save(pendingFollow);
// Create Accept activity
Map<String, Object> acceptActivity = Map.of(
"@context", "https://www.w3.org/ns/activitystreams",
"type", "Accept",
"id", "https://remote.example/activities/accept/" + UUID.randomUUID(),
"actor", remoteActor.getActorUri(),
"object", followActivityId
);
// Post Accept to inbox
mockMvc.perform(post("/users/" + testUser.getUsername() + "/inbox")
.contentType("application/activity+json")
.content(objectMapper.writeValueAsString(acceptActivity)))
.andExpect(status().isAccepted());
// Verify follow status was updated to ACCEPTED
Follow updatedFollow = followRepository.findById(pendingFollow.getId()).orElseThrow();
assertThat(updatedFollow.getStatus()).isEqualTo(Follow.FollowStatus.ACCEPTED);
}
@Test
@DisplayName("Should process Undo Follow activity and remove follow relationship")
void testProcessUndoFollowActivity() throws Exception {
// Create a remote actor
RemoteActor remoteActor = RemoteActor.builder()
.actorUri("https://remote.example/users/dave")
.username("dave")
.domain("remote.example")
.displayName("Dave Remote")
.inboxUrl("https://remote.example/users/dave/inbox")
.outboxUrl("https://remote.example/users/dave/outbox")
.publicKey("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----")
.lastFetchedAt(Instant.now())
.build();
remoteActor = remoteActorRepository.save(remoteActor);
// Create accepted follow
Follow acceptedFollow = Follow.builder()
.remoteActorUri(remoteActor.getActorUri())
.followingActorUri(baseUrl + "/users/" + testUser.getUsername())
.status(Follow.FollowStatus.ACCEPTED)
.build();
acceptedFollow = followRepository.save(acceptedFollow);
// Create Undo Follow activity
Map<String, Object> undoActivity = Map.of(
"@context", "https://www.w3.org/ns/activitystreams",
"type", "Undo",
"id", "https://remote.example/activities/undo/" + UUID.randomUUID(),
"actor", remoteActor.getActorUri(),
"object", Map.of(
"type", "Follow",
"actor", remoteActor.getActorUri(),
"object", baseUrl + "/users/" + testUser.getUsername()
)
);
// Post Undo to inbox
mockMvc.perform(post("/users/" + testUser.getUsername() + "/inbox")
.contentType("application/activity+json")
.content(objectMapper.writeValueAsString(undoActivity)))
.andExpect(status().isAccepted());
// Verify follow was deleted
boolean followExists = followRepository.existsById(acceptedFollow.getId());
assertThat(followExists).isFalse();
}
@Test
@DisplayName("Should return followers list including both local and remote followers")
void testGetFollowersList() throws Exception {
// Generate keypair for local follower
KeyPair keyPair = generateRsaKeyPair();
// Create a local follower
User localFollower = User.builder()
.username("localfollower")
.email("local@example.com")
.passwordHash(passwordEncoder.encode("password"))
.displayName("Local Follower")
.publicKey(encodePublicKey(keyPair.getPublic().getEncoded()))
.privateKey(encodePrivateKey(keyPair.getPrivate().getEncoded()))
.enabled(true)
.build();
localFollower = userRepository.save(localFollower);
Follow localFollow = Follow.builder()
.followerId(localFollower.getId())
.followingActorUri(baseUrl + "/users/" + testUser.getUsername())
.status(Follow.FollowStatus.ACCEPTED)
.build();
followRepository.save(localFollow);
// Create a remote follower
RemoteActor remoteFollower = RemoteActor.builder()
.actorUri("https://remote.example/users/eve")
.username("eve")
.domain("remote.example")
.displayName("Eve Remote")
.inboxUrl("https://remote.example/users/eve/inbox")
.outboxUrl("https://remote.example/users/eve/outbox")
.publicKey("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----")
.lastFetchedAt(Instant.now())
.build();
remoteFollower = remoteActorRepository.save(remoteFollower);
Follow remoteFollow = Follow.builder()
.remoteActorUri(remoteFollower.getActorUri())
.followingActorUri(baseUrl + "/users/" + testUser.getUsername())
.status(Follow.FollowStatus.ACCEPTED)
.build();
followRepository.save(remoteFollow);
// Get followers list
mockMvc.perform(get("/api/users/" + testUser.getUsername() + "/followers"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[?(@.username == 'localfollower')]").exists())
.andExpect(jsonPath("$[?(@.username == 'eve')]").exists())
.andExpect(jsonPath("$[?(@.local == true)]").exists())
.andExpect(jsonPath("$[?(@.local == false)]").exists());
}
@Test
@DisplayName("Should return following list including both local and remote users")
void testGetFollowingList() throws Exception {
// Generate keypair for local followed user
KeyPair keyPair = generateRsaKeyPair();
// Create a local user being followed
User localFollowed = User.builder()
.username("localfollowed")
.email("followed@example.com")
.passwordHash(passwordEncoder.encode("password"))
.displayName("Local Followed")
.publicKey(encodePublicKey(keyPair.getPublic().getEncoded()))
.privateKey(encodePrivateKey(keyPair.getPrivate().getEncoded()))
.enabled(true)
.build();
localFollowed = userRepository.save(localFollowed);
Follow localFollow = Follow.builder()
.followerId(testUser.getId())
.followingActorUri(baseUrl + "/users/" + localFollowed.getUsername())
.status(Follow.FollowStatus.ACCEPTED)
.build();
followRepository.save(localFollow);
// Create a remote user being followed
RemoteActor remoteFollowed = RemoteActor.builder()
.actorUri("https://remote.example/users/frank")
.username("frank")
.domain("remote.example")
.displayName("Frank Remote")
.inboxUrl("https://remote.example/users/frank/inbox")
.outboxUrl("https://remote.example/users/frank/outbox")
.publicKey("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----")
.lastFetchedAt(Instant.now())
.build();
remoteFollowed = remoteActorRepository.save(remoteFollowed);
Follow remoteFollow = Follow.builder()
.followerId(testUser.getId())
.followingActorUri(remoteFollowed.getActorUri())
.status(Follow.FollowStatus.ACCEPTED)
.build();
followRepository.save(remoteFollow);
// Get following list
mockMvc.perform(get("/api/users/" + testUser.getUsername() + "/following"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[?(@.username == 'localfollowed')]").exists())
.andExpect(jsonPath("$[?(@.username == 'frank')]").exists())
.andExpect(jsonPath("$[?(@.local == true)]").exists())
.andExpect(jsonPath("$[?(@.local == false)]").exists());
}
@Test
@Disabled("Requires mocking external HTTP calls to WebFinger and remote ActivityPub servers")
@DisplayName("Should prevent duplicate follow relationships")
void testPreventDuplicateFollows() throws Exception {
// Create a remote actor
RemoteActor remoteActor = RemoteActor.builder()
.actorUri("https://remote.example/users/grace")
.username("grace")
.domain("remote.example")
.displayName("Grace Remote")
.inboxUrl("https://remote.example/users/grace/inbox")
.outboxUrl("https://remote.example/users/grace/outbox")
.publicKey("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----")
.lastFetchedAt(Instant.now())
.build();
remoteActor = remoteActorRepository.save(remoteActor);
// Create existing follow
Follow existingFollow = Follow.builder()
.followerId(testUser.getId())
.followingActorUri(remoteActor.getActorUri())
.status(Follow.FollowStatus.ACCEPTED)
.build();
followRepository.save(existingFollow);
// Try to follow again - should get appropriate response
String remoteHandle = "@grace@remote.example";
mockMvc.perform(post("/api/users/" + remoteHandle + "/follow")
.header("Authorization", "Bearer " + authToken)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().is4xxClientError()); // Should return error for duplicate follow
}
}

View file

@ -280,19 +280,27 @@ class TrainingLoadServiceTest {
// Given
int days = 30;
LocalDate startDate = LocalDate.now().minusDays(days - 1);
List<TrainingLoad> expectedLoad = List.of(
List<TrainingLoad> existingLoad = List.of(
createTrainingLoad(userId, testDate, BigDecimal.valueOf(100.0))
);
when(trainingLoadRepository.findByUserIdSinceDate(userId, startDate))
.thenReturn(expectedLoad);
.thenReturn(existingLoad);
// When
List<TrainingLoad> result = trainingLoadService.getRecentTrainingLoad(userId, days);
// Then
assertEquals(expectedLoad, result);
// Should return 30 days of data (fills in missing days with rest day entries)
assertEquals(30, result.size());
verify(trainingLoadRepository).findByUserIdSinceDate(userId, startDate);
// Verify that the existing load is included
assertTrue(result.stream().anyMatch(tl ->
tl.getDate().equals(testDate) &&
tl.getTrainingStressScore() != null &&
tl.getTrainingStressScore().compareTo(BigDecimal.valueOf(100.0)) == 0
));
}
@Test

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");
}
}