Account >D

eletion
This commit is contained in:
Tim Zöller 2026-01-04 08:18:21 +01:00
parent b5164c9617
commit 4fe283f246
13 changed files with 712 additions and 1 deletions

View file

@ -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

View file

@ -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<Map<String, String>> 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.
*

View file

@ -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;
}

View file

@ -52,4 +52,13 @@ public interface CommentRepository extends JpaRepository<Comment, UUID> {
* @return list of comments
*/
List<Comment> 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<Comment> findByRemoteActorUri(String remoteActorUri);
}

View file

@ -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<Follow, UUID> {
* @return the follow relationship if it exists
*/
Optional<Follow> 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);
}

View file

@ -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<Like, UUID> {
* @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);
}

View file

@ -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<RemoteActivity,
*
* @param remoteActorUri the remote actor URI
*/
@Modifying
@Transactional
void deleteByRemoteActorUri(String remoteActorUri);
}

View file

@ -435,6 +435,49 @@ public class FederationService {
}
}
/**
* Send a Delete activity for actor (account) deletion.
* Notifies all followers that this account has been permanently deleted.
* The actor URI is both the actor and the object being deleted.
*
* @param user the user account being deleted
*/
@Transactional
public void sendActorDeleteActivity(User user) {
try {
String deleteId = baseUrl + "/activities/delete/" + UUID.randomUUID();
String actorUri = baseUrl + "/users/" + user.getUsername();
Map<String, Object> 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<String> 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<String, Object> actorData) {

View file

@ -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<String, Object> 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<Comment> 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}

View file

@ -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());
}
}

View file

@ -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)';

View file

@ -72,6 +72,79 @@
<p class="mb-1 text-muted">Download your activities and data</p>
</div>
</div>
<!-- Danger Zone: Delete Account -->
<div class="mt-5">
<h5 class="text-danger">
<i class="bi bi-exclamation-triangle-fill"></i> Danger Zone
</h5>
<hr class="text-danger">
<div class="card border-danger">
<div class="card-body">
<h6 class="card-title">Delete Account</h6>
<p class="card-text text-muted">
Permanently delete your account and all data. This <strong>cannot be undone</strong>.
</p>
<ul class="text-muted small">
<li>All activities and fitness data permanently deleted</li>
<li>Followers notified of account deletion</li>
<li>Profile removed from federation servers</li>
<li>This action is immediate and irreversible</li>
</ul>
<button type="button" class="btn btn-danger" id="deleteAccountBtn">
<i class="bi bi-trash"></i> Delete My Account
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Account Modal -->
<div class="modal fade" id="deleteAccountModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-danger">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title">
<i class="bi bi-exclamation-triangle-fill"></i>
Confirm Account Deletion
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<strong>Warning:</strong> This is permanent and cannot be undone!
</div>
<p>Enter your password to confirm:</p>
<form id="deleteAccountForm">
<div class="mb-3">
<label for="deletePasswordInput" class="form-label">Password</label>
<input type="password"
class="form-control"
id="deletePasswordInput"
required
placeholder="Enter your password">
<div class="invalid-feedback">Invalid password</div>
</div>
</form>
<div id="deleteErrorAlert" class="alert alert-danger d-none">
<span id="deleteErrorMessage"></span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
<span id="deleteButtonText">
<i class="bi bi-trash"></i> Delete My Account
</span>
<span id="deleteButtonSpinner" class="d-none">
<span class="spinner-border spinner-border-sm"></span> Deleting...
</span>
</button>
</div>
</div>
</div>
</div>
</div>
@ -80,13 +153,85 @@
<!-- Custom Scripts -->
<th:block layout:fragment="scripts">
<script th:inline="javascript">
<script>
document.addEventListener('DOMContentLoaded', function() {
// Redirect to login if not authenticated
if (!FitPubAuth.isAuthenticated()) {
window.location.href = '/login';
return;
}
const modal = new bootstrap.Modal(document.getElementById('deleteAccountModal'));
const deletePasswordInput = document.getElementById('deletePasswordInput');
const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
// Show modal
document.getElementById('deleteAccountBtn').addEventListener('click', () => {
deletePasswordInput.value = '';
deletePasswordInput.classList.remove('is-invalid');
document.getElementById('deleteErrorAlert').classList.add('d-none');
modal.show();
});
// Handle Enter key
deletePasswordInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
confirmDeleteBtn.click();
}
});
// Confirm deletion
confirmDeleteBtn.addEventListener('click', async () => {
const password = deletePasswordInput.value.trim();
if (!password) {
deletePasswordInput.classList.add('is-invalid');
return;
}
// Show loading
confirmDeleteBtn.disabled = true;
document.getElementById('deleteButtonText').classList.add('d-none');
document.getElementById('deleteButtonSpinner').classList.remove('d-none');
try {
const response = await FitPubAuth.authenticatedFetch('/api/users/me', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (response.ok) {
// Success - logout and redirect
FitPubAuth.logout();
FitPub.showAlert('Account deleted successfully', 'success');
setTimeout(() => window.location.href = '/', 2000);
} else if (response.status === 401) {
// Invalid password
deletePasswordInput.classList.add('is-invalid');
confirmDeleteBtn.disabled = false;
document.getElementById('deleteButtonText').classList.remove('d-none');
document.getElementById('deleteButtonSpinner').classList.add('d-none');
} else {
// Other error
const data = await response.json();
document.getElementById('deleteErrorMessage').textContent =
data.error || 'Failed to delete account';
document.getElementById('deleteErrorAlert').classList.remove('d-none');
confirmDeleteBtn.disabled = false;
document.getElementById('deleteButtonText').classList.remove('d-none');
document.getElementById('deleteButtonSpinner').classList.add('d-none');
}
} catch (error) {
console.error('Delete error:', error);
document.getElementById('deleteErrorMessage').textContent =
'Network error. Please try again.';
document.getElementById('deleteErrorAlert').classList.remove('d-none');
confirmDeleteBtn.disabled = false;
document.getElementById('deleteButtonText').classList.remove('d-none');
document.getElementById('deleteButtonSpinner').classList.add('d-none');
}
});
});
</script>
</th:block>

View file

@ -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());
}
}