From 4fe283f2462f57930c1dcc4c770b286d74f9cf0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Sun, 4 Jan 2026 08:18:21 +0100 Subject: [PATCH] Account >D eletion --- .../fitpub/config/SecurityConfig.java | 1 + .../fitpub/controller/UserController.java | 45 ++++ .../model/dto/AccountDeletionRequest.java | 25 ++ .../fitpub/repository/CommentRepository.java | 9 + .../fitpub/repository/FollowRepository.java | 26 +++ .../fitpub/repository/LikeRepository.java | 11 + .../repository/RemoteActivityRepository.java | 4 + .../fitpub/service/FederationService.java | 43 ++++ .../fitpub/service/InboxProcessor.java | 114 +++++++++ .../operaton/fitpub/service/UserService.java | 55 +++++ .../V17__optimize_follow_cleanup.sql | 13 ++ src/main/resources/templates/settings.html | 147 +++++++++++- .../fitpub/service/UserServiceTest.java | 220 ++++++++++++++++++ 13 files changed, 712 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/operaton/fitpub/model/dto/AccountDeletionRequest.java create mode 100644 src/main/resources/db/migration/V17__optimize_follow_cleanup.sql create mode 100644 src/test/java/org/operaton/fitpub/service/UserServiceTest.java diff --git a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java index 5d13a29..ac2c3fb 100644 --- a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java +++ b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java @@ -137,6 +137,7 @@ public class SecurityConfig { // User API endpoints .requestMatchers(HttpMethod.GET, "/api/users/me").authenticated() .requestMatchers(HttpMethod.PUT, "/api/users/me").authenticated() + .requestMatchers(HttpMethod.DELETE, "/api/users/me").authenticated() .requestMatchers(HttpMethod.GET, "/api/users/{username}").permitAll() .requestMatchers(HttpMethod.GET, "/api/users/id/*").permitAll() .requestMatchers(HttpMethod.GET, "/api/users/search").permitAll() // User search diff --git a/src/main/java/org/operaton/fitpub/controller/UserController.java b/src/main/java/org/operaton/fitpub/controller/UserController.java index 71907f6..ab95e60 100644 --- a/src/main/java/org/operaton/fitpub/controller/UserController.java +++ b/src/main/java/org/operaton/fitpub/controller/UserController.java @@ -3,6 +3,7 @@ package org.operaton.fitpub.controller; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.operaton.fitpub.model.dto.AccountDeletionRequest; import org.operaton.fitpub.model.dto.ActorDTO; import org.operaton.fitpub.model.dto.UserDTO; import org.operaton.fitpub.model.dto.UserUpdateRequest; @@ -12,10 +13,12 @@ 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.service.UserService; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -41,6 +44,7 @@ public class UserController { private final RemoteActorRepository remoteActorRepository; private final org.operaton.fitpub.service.WebFingerClient webFingerClient; private final org.operaton.fitpub.service.FederationService federationService; + private final UserService userService; @Value("${fitpub.base-url}") private String baseUrl; @@ -116,6 +120,47 @@ public class UserController { return ResponseEntity.ok(dto); } + /** + * Delete current user's account. + * Requires password confirmation for security. + * Sends ActivityPub Delete activity to notify followers. + * Permanently deletes all user data. + * + * @param request the deletion request with password + * @param userDetails the authenticated user + * @return success or error response + */ + @DeleteMapping("/me") + public ResponseEntity> deleteCurrentUser( + @Valid @RequestBody AccountDeletionRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + log.info("User {} requesting account deletion", userDetails.getUsername()); + + User user = userRepository.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + try { + userService.deleteUserAccount(user.getId(), request.getPassword()); + + log.info("Account deletion successful: {}", user.getUsername()); + return ResponseEntity.ok(Map.of( + "message", "Account deleted successfully", + "username", user.getUsername() + )); + + } catch (BadCredentialsException e) { + log.warn("Invalid password for account deletion: {}", user.getUsername()); + return ResponseEntity.status(401).body(Map.of("error", "Invalid password")); + + } catch (Exception e) { + log.error("Account deletion failed for {}", user.getUsername(), e); + return ResponseEntity.status(500).body(Map.of( + "error", "Failed to delete account: " + e.getMessage() + )); + } + } + /** * Get user profile by username. * diff --git a/src/main/java/org/operaton/fitpub/model/dto/AccountDeletionRequest.java b/src/main/java/org/operaton/fitpub/model/dto/AccountDeletionRequest.java new file mode 100644 index 0000000..d90df74 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/dto/AccountDeletionRequest.java @@ -0,0 +1,25 @@ +package org.operaton.fitpub.model.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Request DTO for account deletion requiring password confirmation. + * Used to securely verify the user's identity before permanently deleting their account. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AccountDeletionRequest { + + /** + * The user's current password for verification. + * Required to prevent accidental or unauthorized account deletion. + */ + @NotBlank(message = "Password is required to confirm account deletion") + private String password; +} diff --git a/src/main/java/org/operaton/fitpub/repository/CommentRepository.java b/src/main/java/org/operaton/fitpub/repository/CommentRepository.java index 1e7ecf0..b7d91b9 100644 --- a/src/main/java/org/operaton/fitpub/repository/CommentRepository.java +++ b/src/main/java/org/operaton/fitpub/repository/CommentRepository.java @@ -52,4 +52,13 @@ public interface CommentRepository extends JpaRepository { * @return list of comments */ List findByUserIdOrderByCreatedAtDesc(UUID userId); + + /** + * Find all comments by a remote actor. + * Used when processing remote actor deletion. + * + * @param remoteActorUri the remote actor's URI + * @return list of comments + */ + List findByRemoteActorUri(String remoteActorUri); } diff --git a/src/main/java/org/operaton/fitpub/repository/FollowRepository.java b/src/main/java/org/operaton/fitpub/repository/FollowRepository.java index 4fa9bcc..fcf5f7c 100644 --- a/src/main/java/org/operaton/fitpub/repository/FollowRepository.java +++ b/src/main/java/org/operaton/fitpub/repository/FollowRepository.java @@ -2,9 +2,11 @@ package org.operaton.fitpub.repository; import org.operaton.fitpub.model.entity.Follow; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; @@ -86,4 +88,28 @@ public interface FollowRepository extends JpaRepository { * @return the follow relationship if it exists */ Optional findByRemoteActorUriAndFollowingActorUri(String remoteActorUri, String followingActorUri); + + /** + * Delete all follow records where the given actor URI is being followed. + * This is used when a user account is deleted to clean up records where + * the user was being followed (since following_actor_uri is a string, not FK). + * + * @param followingActorUri the actor URI being followed + * @return number of deleted records + */ + @Modifying + @Transactional + @Query("DELETE FROM Follow f WHERE f.followingActorUri = :followingActorUri") + int deleteByFollowingActorUri(@Param("followingActorUri") String followingActorUri); + + /** + * Delete all follow records for a remote actor (when remote account is deleted). + * This cleans up follows where the remote actor was the follower. + * + * @param remoteActorUri the remote actor's URI + */ + @Modifying + @Transactional + @Query("DELETE FROM Follow f WHERE f.remoteActorUri = :remoteActorUri") + void deleteByRemoteActorUri(@Param("remoteActorUri") String remoteActorUri); } diff --git a/src/main/java/org/operaton/fitpub/repository/LikeRepository.java b/src/main/java/org/operaton/fitpub/repository/LikeRepository.java index fb862f1..9a5d3e0 100644 --- a/src/main/java/org/operaton/fitpub/repository/LikeRepository.java +++ b/src/main/java/org/operaton/fitpub/repository/LikeRepository.java @@ -2,9 +2,11 @@ package org.operaton.fitpub.repository; import org.operaton.fitpub.model.entity.Like; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; @@ -91,4 +93,13 @@ public interface LikeRepository extends JpaRepository { * @param remoteActorUri the remote actor URI */ void deleteByActivityIdAndRemoteActorUri(UUID activityId, String remoteActorUri); + + /** + * Delete all likes from a remote actor (when remote account is deleted). + * + * @param remoteActorUri the remote actor's URI + */ + @Modifying + @Transactional + void deleteByRemoteActorUri(String remoteActorUri); } diff --git a/src/main/java/org/operaton/fitpub/repository/RemoteActivityRepository.java b/src/main/java/org/operaton/fitpub/repository/RemoteActivityRepository.java index b9656a7..7d0a505 100644 --- a/src/main/java/org/operaton/fitpub/repository/RemoteActivityRepository.java +++ b/src/main/java/org/operaton/fitpub/repository/RemoteActivityRepository.java @@ -4,9 +4,11 @@ import org.operaton.fitpub.model.entity.RemoteActivity; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; @@ -107,5 +109,7 @@ public interface RemoteActivityRepository extends JpaRepository deleteActivity = new HashMap<>(); + deleteActivity.put("@context", "https://www.w3.org/ns/activitystreams"); + deleteActivity.put("type", "Delete"); + deleteActivity.put("id", deleteId); + deleteActivity.put("actor", actorUri); + deleteActivity.put("object", actorUri); // Actor is the object being deleted + deleteActivity.put("published", Instant.now().toString()); + deleteActivity.put("to", List.of("https://www.w3.org/ns/activitystreams#Public")); + deleteActivity.put("cc", List.of(actorUri + "/followers")); + + // Send to all follower inboxes + List inboxes = getFollowerInboxes(user.getId()); + for (String inbox : inboxes) { + try { + sendActivity(inbox, deleteActivity, user); + } catch (Exception e) { + log.error("Failed to send Delete(Actor) to inbox: {}", inbox, e); + // Continue with other inboxes even if one fails + } + } + + log.info("Sent Delete(Actor) for: {} to {} inboxes", actorUri, inboxes.size()); + + } catch (Exception e) { + log.error("Failed to send Delete(Actor) for user: {}", user.getUsername(), e); + // Re-throw to allow caller to handle + throw new RuntimeException("Failed to send Delete(Actor) activity", e); + } + } + // Helper methods private String extractUsername(String actorUri, Map actorData) { diff --git a/src/main/java/org/operaton/fitpub/service/InboxProcessor.java b/src/main/java/org/operaton/fitpub/service/InboxProcessor.java index 3dae22d..0084449 100644 --- a/src/main/java/org/operaton/fitpub/service/InboxProcessor.java +++ b/src/main/java/org/operaton/fitpub/service/InboxProcessor.java @@ -69,6 +69,9 @@ public class InboxProcessor { case "Like": processLike(username, activity); break; + case "Delete": + processDelete(username, activity); + break; default: log.warn("Unhandled activity type: {}", type); } @@ -433,6 +436,117 @@ public class InboxProcessor { } } + /** + * Process a Delete activity. + * Handles both actor deletions (account removal) and object deletions (activity/comment removal). + */ + private void processDelete(String username, Map activity) { + try { + String actor = (String) activity.get("actor"); + Object object = activity.get("object"); + + // Determine object URI (can be a string or an embedded object) + String objectUri; + if (object instanceof Map) { + objectUri = (String) ((Map) object).get("id"); + } else { + objectUri = (String) object; + } + + if (objectUri == null) { + log.warn("Delete activity has no object URI"); + return; + } + + log.info("Processing Delete from {} for object {}", actor, objectUri); + + // Check if this is an actor deletion (object URI equals actor URI) + if (objectUri.equals(actor)) { + processActorDelete(actor); + } else { + processObjectDelete(objectUri); + } + + } catch (Exception e) { + log.error("Error processing Delete activity", e); + } + } + + /** + * Process actor (account) deletion. + * Removes all data associated with the deleted remote actor. + */ + private void processActorDelete(String actorUri) { + try { + log.info("Processing actor deletion: {}", actorUri); + + // Delete follow relationships where this actor is the follower + followRepository.deleteByRemoteActorUri(actorUri); + log.debug("Deleted follows where actor {} was the follower", actorUri); + + // Delete follow relationships where this actor is being followed + followRepository.deleteByFollowingActorUri(actorUri); + log.debug("Deleted follows where actor {} was being followed", actorUri); + + // Delete all likes from this actor + likeRepository.deleteByRemoteActorUri(actorUri); + log.debug("Deleted likes from actor {}", actorUri); + + // Soft-delete comments from this actor (preserve for context) + java.util.List comments = commentRepository.findByRemoteActorUri(actorUri); + for (Comment comment : comments) { + comment.setDeleted(true); + comment.setContent("[deleted]"); + } + if (!comments.isEmpty()) { + commentRepository.saveAll(comments); + log.debug("Soft-deleted {} comments from actor {}", comments.size(), actorUri); + } + + // Delete all remote activities from this actor + remoteActivityRepository.deleteByRemoteActorUri(actorUri); + log.debug("Deleted remote activities from actor {}", actorUri); + + // Delete the remote actor record itself + remoteActorRepository.findByActorUri(actorUri).ifPresent(remoteActor -> { + remoteActorRepository.delete(remoteActor); + log.debug("Deleted remote actor record for {}", actorUri); + }); + + log.info("Completed actor deletion for: {}", actorUri); + + } catch (Exception e) { + log.error("Error processing actor deletion for: {}", actorUri, e); + } + } + + /** + * Process object deletion (activity or comment). + * Removes the specific object that was deleted. + */ + private void processObjectDelete(String objectUri) { + try { + log.info("Processing object deletion: {}", objectUri); + + // Try to delete as a remote activity + remoteActivityRepository.findByActivityUri(objectUri).ifPresent(remoteActivity -> { + remoteActivityRepository.delete(remoteActivity); + log.info("Deleted remote activity: {}", objectUri); + }); + + // Try to soft-delete as a comment + commentRepository.findByActivityPubId(objectUri).ifPresent(comment -> { + comment.setDeleted(true); + comment.setContent("[deleted]"); + commentRepository.save(comment); + log.info("Soft-deleted comment: {}", objectUri); + }); + + } catch (Exception e) { + log.error("Error processing object deletion for: {}", objectUri, e); + } + } + /** * Extract activity UUID from URI. * Expects format: https://fitpub.example/activities/{uuid} diff --git a/src/main/java/org/operaton/fitpub/service/UserService.java b/src/main/java/org/operaton/fitpub/service/UserService.java index ee3e652..f8475bd 100644 --- a/src/main/java/org/operaton/fitpub/service/UserService.java +++ b/src/main/java/org/operaton/fitpub/service/UserService.java @@ -6,8 +6,10 @@ import org.operaton.fitpub.model.dto.AuthResponse; import org.operaton.fitpub.model.dto.LoginRequest; import org.operaton.fitpub.model.dto.RegisterRequest; import org.operaton.fitpub.model.entity.User; +import org.operaton.fitpub.repository.FollowRepository; import org.operaton.fitpub.repository.UserRepository; import org.operaton.fitpub.security.JwtTokenProvider; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -17,6 +19,7 @@ import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.util.Base64; +import java.util.UUID; /** * Service for user management operations including registration and authentication. @@ -29,6 +32,11 @@ public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; + private final FollowRepository followRepository; + private final FederationService federationService; + + @Value("${fitpub.base-url}") + private String baseUrl; /** * Register a new user account with RSA key pair for ActivityPub. @@ -165,4 +173,51 @@ public class UserService { String base64 = Base64.getEncoder().encodeToString(keyBytes); return "-----BEGIN PRIVATE KEY-----\n" + base64 + "\n-----END PRIVATE KEY-----"; } + + /** + * Delete a user account permanently. + * Requires password verification for security. + * Sends ActivityPub Delete activity to notify followers (best effort). + * Database cascades handle most deletions automatically. + * + * @param userId User ID to delete + * @param password Password for verification + * @throws IllegalArgumentException if user not found + * @throws BadCredentialsException if password is invalid + */ + @Transactional + public void deleteUserAccount(UUID userId, String password) { + log.info("Attempting to delete user account: {}", userId); + + // 1. Fetch user + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + // 2. Verify password + if (!passwordEncoder.matches(password, user.getPasswordHash())) { + log.warn("Invalid password provided for account deletion: {}", user.getUsername()); + throw new BadCredentialsException("Invalid password"); + } + + log.info("Password verified for account deletion: {}", user.getUsername()); + + // 3. Send Delete activity to followers (best effort) + try { + federationService.sendActorDeleteActivity(user); + log.info("Sent Delete activity to followers for: {}", user.getUsername()); + } catch (Exception e) { + log.error("Failed to send Delete activity for {}, continuing with deletion", user.getUsername(), e); + } + + // 4. Manual cleanup: Delete follows where user is being followed + // (Database cascades don't cover this since followingActorUri is a string, not FK) + String actorUri = baseUrl + "/users/" + user.getUsername(); + int deletedFollows = followRepository.deleteByFollowingActorUri(actorUri); + log.info("Deleted {} follow records where user was being followed: {}", deletedFollows, user.getUsername()); + + // 5. Delete user (triggers all ON DELETE CASCADE for related entities) + userRepository.delete(user); + + log.info("User account deleted successfully: {}", user.getUsername()); + } } diff --git a/src/main/resources/db/migration/V17__optimize_follow_cleanup.sql b/src/main/resources/db/migration/V17__optimize_follow_cleanup.sql new file mode 100644 index 0000000..e5723d8 --- /dev/null +++ b/src/main/resources/db/migration/V17__optimize_follow_cleanup.sql @@ -0,0 +1,13 @@ +-- Optimization for account deletion: add index for follow cleanup operations +-- This index improves performance when deleting follows where user is being followed +-- (followingActorUri string lookups during account deletion) + +-- Create index on following_actor_uri for faster cleanup during account deletion +-- This only indexes rows where follower_id IS NULL (remote actors following local users) +CREATE INDEX IF NOT EXISTS idx_follows_following_actor_uri_cleanup +ON follows(following_actor_uri) +WHERE follower_id IS NULL; + +-- Add comment explaining the index purpose +COMMENT ON INDEX idx_follows_following_actor_uri_cleanup IS +'Optimizes account deletion by speeding up cleanup of remote followers (followingActorUri lookups)'; diff --git a/src/main/resources/templates/settings.html b/src/main/resources/templates/settings.html index 2dac9ef..07046f5 100644 --- a/src/main/resources/templates/settings.html +++ b/src/main/resources/templates/settings.html @@ -72,6 +72,79 @@

Download your activities and data

+ + +
+
+ Danger Zone +
+
+
+
+
Delete Account
+

+ Permanently delete your account and all data. This cannot be undone. +

+
    +
  • All activities and fitness data permanently deleted
  • +
  • Followers notified of account deletion
  • +
  • Profile removed from federation servers
  • +
  • This action is immediate and irreversible
  • +
+ +
+
+
+ + + + + @@ -80,13 +153,85 @@ - diff --git a/src/test/java/org/operaton/fitpub/service/UserServiceTest.java b/src/test/java/org/operaton/fitpub/service/UserServiceTest.java new file mode 100644 index 0000000..b6acde8 --- /dev/null +++ b/src/test/java/org/operaton/fitpub/service/UserServiceTest.java @@ -0,0 +1,220 @@ +package org.operaton.fitpub.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.operaton.fitpub.model.entity.User; +import org.operaton.fitpub.repository.FollowRepository; +import org.operaton.fitpub.repository.UserRepository; +import org.operaton.fitpub.security.JwtTokenProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for UserService, focusing on account deletion functionality. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("UserService Tests") +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @Mock + private FollowRepository followRepository; + + @Mock + private FederationService federationService; + + @InjectMocks + private UserService userService; + + private User testUser; + private UUID testUserId; + private String testPassword = "password123"; + private String encodedPassword = "$2a$10$encodedPasswordHash"; + + @BeforeEach + void setUp() { + testUserId = UUID.randomUUID(); + testUser = User.builder() + .id(testUserId) + .username("testuser") + .email("test@example.com") + .passwordHash(encodedPassword) + .displayName("Test User") + .enabled(true) + .locked(false) + .build(); + + // Set the base URL for the service + ReflectionTestUtils.setField(userService, "baseUrl", "https://fitpub.example"); + } + + @Test + @DisplayName("Should successfully delete account with valid password") + void deleteUserAccount_WithValidPassword_ShouldSucceed() { + // Arrange + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(passwordEncoder.matches(testPassword, encodedPassword)).thenReturn(true); + when(followRepository.deleteByFollowingActorUri(anyString())).thenReturn(2); + doNothing().when(federationService).sendActorDeleteActivity(any(User.class)); + doNothing().when(userRepository).delete(any(User.class)); + + // Act & Assert + assertDoesNotThrow(() -> userService.deleteUserAccount(testUserId, testPassword)); + + // Verify interactions + verify(userRepository).findById(testUserId); + verify(passwordEncoder).matches(testPassword, encodedPassword); + verify(federationService).sendActorDeleteActivity(testUser); + verify(followRepository).deleteByFollowingActorUri("https://fitpub.example/users/testuser"); + verify(userRepository).delete(testUser); + } + + @Test + @DisplayName("Should throw BadCredentialsException with invalid password") + void deleteUserAccount_WithInvalidPassword_ShouldThrowBadCredentialsException() { + // Arrange + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(passwordEncoder.matches(testPassword, encodedPassword)).thenReturn(false); + + // Act & Assert + assertThatThrownBy(() -> userService.deleteUserAccount(testUserId, testPassword)) + .isInstanceOf(BadCredentialsException.class) + .hasMessage("Invalid password"); + + // Verify that deletion was not attempted + verify(userRepository).findById(testUserId); + verify(passwordEncoder).matches(testPassword, encodedPassword); + verify(federationService, never()).sendActorDeleteActivity(any()); + verify(followRepository, never()).deleteByFollowingActorUri(anyString()); + verify(userRepository, never()).delete(any()); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when user not found") + void deleteUserAccount_WithNonExistentUser_ShouldThrowIllegalArgumentException() { + // Arrange + UUID nonExistentUserId = UUID.randomUUID(); + when(userRepository.findById(nonExistentUserId)).thenReturn(Optional.empty()); + + // Act & Assert + assertThatThrownBy(() -> userService.deleteUserAccount(nonExistentUserId, testPassword)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("User not found"); + + // Verify that no further processing occurred + verify(userRepository).findById(nonExistentUserId); + verify(passwordEncoder, never()).matches(anyString(), anyString()); + verify(federationService, never()).sendActorDeleteActivity(any()); + verify(userRepository, never()).delete(any()); + } + + @Test + @DisplayName("Should continue deletion even if federation fails") + void deleteUserAccount_WhenFederationFails_ShouldContinueWithDeletion() { + // Arrange + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(passwordEncoder.matches(testPassword, encodedPassword)).thenReturn(true); + when(followRepository.deleteByFollowingActorUri(anyString())).thenReturn(1); + + // Simulate federation failure + doThrow(new RuntimeException("Federation service unavailable")) + .when(federationService).sendActorDeleteActivity(any(User.class)); + + doNothing().when(userRepository).delete(any(User.class)); + + // Act & Assert - should not throw exception + assertDoesNotThrow(() -> userService.deleteUserAccount(testUserId, testPassword)); + + // Verify deletion still occurred + verify(federationService).sendActorDeleteActivity(testUser); + verify(followRepository).deleteByFollowingActorUri("https://fitpub.example/users/testuser"); + verify(userRepository).delete(testUser); + } + + @Test + @DisplayName("Should delete follow relationships where user is being followed") + void deleteUserAccount_ShouldDeleteFollowRelationships() { + // Arrange + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(passwordEncoder.matches(testPassword, encodedPassword)).thenReturn(true); + when(followRepository.deleteByFollowingActorUri("https://fitpub.example/users/testuser")).thenReturn(5); + doNothing().when(federationService).sendActorDeleteActivity(any(User.class)); + doNothing().when(userRepository).delete(any(User.class)); + + // Act + userService.deleteUserAccount(testUserId, testPassword); + + // Assert + verify(followRepository).deleteByFollowingActorUri("https://fitpub.example/users/testuser"); + } + + @Test + @DisplayName("Should handle null password gracefully") + void deleteUserAccount_WithNullPassword_ShouldThrowBadCredentialsException() { + // Arrange + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(passwordEncoder.matches(null, encodedPassword)).thenReturn(false); + + // Act & Assert + assertThatThrownBy(() -> userService.deleteUserAccount(testUserId, null)) + .isInstanceOf(BadCredentialsException.class) + .hasMessage("Invalid password"); + + verify(userRepository, never()).delete(any()); + } + + @Test + @DisplayName("Should handle empty password gracefully") + void deleteUserAccount_WithEmptyPassword_ShouldThrowBadCredentialsException() { + // Arrange + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(passwordEncoder.matches("", encodedPassword)).thenReturn(false); + + // Act & Assert + assertThatThrownBy(() -> userService.deleteUserAccount(testUserId, "")) + .isInstanceOf(BadCredentialsException.class) + .hasMessage("Invalid password"); + + verify(userRepository, never()).delete(any()); + } + + @Test + @DisplayName("Should verify password before attempting any deletions") + void deleteUserAccount_ShouldVerifyPasswordBeforeDeletion() { + // Arrange + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(passwordEncoder.matches("wrongpassword", encodedPassword)).thenReturn(false); + + // Act & Assert + assertThatThrownBy(() -> userService.deleteUserAccount(testUserId, "wrongpassword")) + .isInstanceOf(BadCredentialsException.class); + + // Verify order: password check happens before any deletion attempts + verify(passwordEncoder).matches("wrongpassword", encodedPassword); + verify(federationService, never()).sendActorDeleteActivity(any()); + verify(followRepository, never()).deleteByFollowingActorUri(anyString()); + verify(userRepository, never()).delete(any()); + } +}