Account >D
eletion
This commit is contained in:
parent
b5164c9617
commit
4fe283f246
13 changed files with 712 additions and 1 deletions
|
|
@ -137,6 +137,7 @@ public class SecurityConfig {
|
||||||
// User API endpoints
|
// User API endpoints
|
||||||
.requestMatchers(HttpMethod.GET, "/api/users/me").authenticated()
|
.requestMatchers(HttpMethod.GET, "/api/users/me").authenticated()
|
||||||
.requestMatchers(HttpMethod.PUT, "/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/{username}").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/users/id/*").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/users/id/*").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/users/search").permitAll() // User search
|
.requestMatchers(HttpMethod.GET, "/api/users/search").permitAll() // User search
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package org.operaton.fitpub.controller;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.ActorDTO;
|
||||||
import org.operaton.fitpub.model.dto.UserDTO;
|
import org.operaton.fitpub.model.dto.UserDTO;
|
||||||
import org.operaton.fitpub.model.dto.UserUpdateRequest;
|
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.FollowRepository;
|
||||||
import org.operaton.fitpub.repository.RemoteActorRepository;
|
import org.operaton.fitpub.repository.RemoteActorRepository;
|
||||||
import org.operaton.fitpub.repository.UserRepository;
|
import org.operaton.fitpub.repository.UserRepository;
|
||||||
|
import org.operaton.fitpub.service.UserService;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.authentication.BadCredentialsException;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
|
@ -41,6 +44,7 @@ public class UserController {
|
||||||
private final RemoteActorRepository remoteActorRepository;
|
private final RemoteActorRepository remoteActorRepository;
|
||||||
private final org.operaton.fitpub.service.WebFingerClient webFingerClient;
|
private final org.operaton.fitpub.service.WebFingerClient webFingerClient;
|
||||||
private final org.operaton.fitpub.service.FederationService federationService;
|
private final org.operaton.fitpub.service.FederationService federationService;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
@ -116,6 +120,47 @@ public class UserController {
|
||||||
return ResponseEntity.ok(dto);
|
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.
|
* Get user profile by username.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -52,4 +52,13 @@ public interface CommentRepository extends JpaRepository<Comment, UUID> {
|
||||||
* @return list of comments
|
* @return list of comments
|
||||||
*/
|
*/
|
||||||
List<Comment> findByUserIdOrderByCreatedAtDesc(UUID userId);
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ package org.operaton.fitpub.repository;
|
||||||
|
|
||||||
import org.operaton.fitpub.model.entity.Follow;
|
import org.operaton.fitpub.model.entity.Follow;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
@ -86,4 +88,28 @@ public interface FollowRepository extends JpaRepository<Follow, UUID> {
|
||||||
* @return the follow relationship if it exists
|
* @return the follow relationship if it exists
|
||||||
*/
|
*/
|
||||||
Optional<Follow> findByRemoteActorUriAndFollowingActorUri(String remoteActorUri, String followingActorUri);
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ package org.operaton.fitpub.repository;
|
||||||
|
|
||||||
import org.operaton.fitpub.model.entity.Like;
|
import org.operaton.fitpub.model.entity.Like;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
@ -91,4 +93,13 @@ public interface LikeRepository extends JpaRepository<Like, UUID> {
|
||||||
* @param remoteActorUri the remote actor URI
|
* @param remoteActorUri the remote actor URI
|
||||||
*/
|
*/
|
||||||
void deleteByActivityIdAndRemoteActorUri(UUID activityId, String remoteActorUri);
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import org.operaton.fitpub.model.entity.RemoteActivity;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
@ -107,5 +109,7 @@ public interface RemoteActivityRepository extends JpaRepository<RemoteActivity,
|
||||||
*
|
*
|
||||||
* @param remoteActorUri the remote actor URI
|
* @param remoteActorUri the remote actor URI
|
||||||
*/
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Transactional
|
||||||
void deleteByRemoteActorUri(String remoteActorUri);
|
void deleteByRemoteActorUri(String remoteActorUri);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Helper methods
|
||||||
|
|
||||||
private String extractUsername(String actorUri, Map<String, Object> actorData) {
|
private String extractUsername(String actorUri, Map<String, Object> actorData) {
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,9 @@ public class InboxProcessor {
|
||||||
case "Like":
|
case "Like":
|
||||||
processLike(username, activity);
|
processLike(username, activity);
|
||||||
break;
|
break;
|
||||||
|
case "Delete":
|
||||||
|
processDelete(username, activity);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
log.warn("Unhandled activity type: {}", type);
|
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.
|
* Extract activity UUID from URI.
|
||||||
* Expects format: https://fitpub.example/activities/{uuid}
|
* Expects format: https://fitpub.example/activities/{uuid}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ import org.operaton.fitpub.model.dto.AuthResponse;
|
||||||
import org.operaton.fitpub.model.dto.LoginRequest;
|
import org.operaton.fitpub.model.dto.LoginRequest;
|
||||||
import org.operaton.fitpub.model.dto.RegisterRequest;
|
import org.operaton.fitpub.model.dto.RegisterRequest;
|
||||||
import org.operaton.fitpub.model.entity.User;
|
import org.operaton.fitpub.model.entity.User;
|
||||||
|
import org.operaton.fitpub.repository.FollowRepository;
|
||||||
import org.operaton.fitpub.repository.UserRepository;
|
import org.operaton.fitpub.repository.UserRepository;
|
||||||
import org.operaton.fitpub.security.JwtTokenProvider;
|
import org.operaton.fitpub.security.JwtTokenProvider;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.authentication.BadCredentialsException;
|
import org.springframework.security.authentication.BadCredentialsException;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
@ -17,6 +19,7 @@ import java.security.KeyPair;
|
||||||
import java.security.KeyPairGenerator;
|
import java.security.KeyPairGenerator;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for user management operations including registration and authentication.
|
* Service for user management operations including registration and authentication.
|
||||||
|
|
@ -29,6 +32,11 @@ public class UserService {
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final JwtTokenProvider jwtTokenProvider;
|
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.
|
* Register a new user account with RSA key pair for ActivityPub.
|
||||||
|
|
@ -165,4 +173,51 @@ public class UserService {
|
||||||
String base64 = Base64.getEncoder().encodeToString(keyBytes);
|
String base64 = Base64.getEncoder().encodeToString(keyBytes);
|
||||||
return "-----BEGIN PRIVATE KEY-----\n" + base64 + "\n-----END PRIVATE KEY-----";
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)';
|
||||||
|
|
@ -72,6 +72,79 @@
|
||||||
<p class="mb-1 text-muted">Download your activities and data</p>
|
<p class="mb-1 text-muted">Download your activities and data</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -80,13 +153,85 @@
|
||||||
|
|
||||||
<!-- Custom Scripts -->
|
<!-- Custom Scripts -->
|
||||||
<th:block layout:fragment="scripts">
|
<th:block layout:fragment="scripts">
|
||||||
<script th:inline="javascript">
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Redirect to login if not authenticated
|
// Redirect to login if not authenticated
|
||||||
if (!FitPubAuth.isAuthenticated()) {
|
if (!FitPubAuth.isAuthenticated()) {
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
return;
|
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>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
|
||||||
220
src/test/java/org/operaton/fitpub/service/UserServiceTest.java
Normal file
220
src/test/java/org/operaton/fitpub/service/UserServiceTest.java
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue