Better Federation Support
This commit is contained in:
parent
15b420b87a
commit
5b687883b0
22 changed files with 2931 additions and 49 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue