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
|
||||
.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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
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.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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</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>
|
||||
|
|
|
|||
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