feat(account): let users delete their account
Build, Test & Push Auth Service / quality-check (push) Successful in 32s
Build, Test & Push Auth Service / docker (push) Successful in 1m23s

DELETE /api/users/me with the password sets users.deleted_at, ends all logins and frees the Steam ID. A nightly job removes users flagged 30 days ago, Core first, and prunes Core orphans. Dry run by default.
This commit is contained in:
lobo committed 2026-09-27 11:46:22 +02:00
1 parent a1763addce
commit 7dc2fb613e
33 files changed
+1115 -11

No files matched your search

@@ -2,6 +2,7 @@ package auth_service.configuration.security;
import auth_service.shared.security.ProfileTicketIssuer;
import auth_service.shared.security.PublicKeyPublisher;
import auth_service.shared.security.ServiceTokenIssuer;
import auth_service.shared.security.SessionProperties;
import auth_service.shared.security.TokenGenerator;
import auth_service.shared.security.UserPrincipal;
@@ -28,12 +29,14 @@ import java.util.Optional;
* Signing: EdDSA (Ed25519). Auth holds the private key and signs; Core holds only the public key and verifies.
*/
@Service
public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKeyPublisher {
public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceTokenIssuer, PublicKeyPublisher {
private static final long PROFILE_TICKET_EXPIRATION_MS = 10 * 60 * 1000;
// Only non-login tokens carry this claim; isTokenValid rejects them.
private static final String TYPE_CLAIM = "typ";
private static final String PROFILE_TICKET_TYPE = "public-profile";
private static final long SERVICE_TOKEN_EXPIRATION_MS = 60 * 1000;
private static final String SERVICE_TOKEN_TYPE = "service";
private static final String ED25519 = "Ed25519";
private final JwtConfig jwtConfig;
@@ -90,6 +93,18 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKe
.compact();
}
@Override
public String generateServiceToken() {
return Jwts.builder()
.header().keyId(jwtConfig.getKid()).and()
.subject("tbp-auth")
.claim(TYPE_CLAIM, SERVICE_TOKEN_TYPE)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + SERVICE_TOKEN_EXPIRATION_MS))
.signWith(privateKey, Jwts.SIG.EdDSA)
.compact();
}
@Override
public List<Map<String, ?>> publicJwks() {
return List.of(Jwks.builder()
@@ -0,0 +1,31 @@
package auth_service.modules.account.api;
import auth_service.modules.account.api.dto.request.DeleteAccountRequest;
import auth_service.modules.account.service.AccountService;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.security.UserPrincipal;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class AccountController {
private final AccountService accountService;
private final AuthCookies authCookies;
@DeleteMapping(value = "/api/users/me", version = "1.0")
public ResponseEntity<Void> deleteAccount(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody DeleteAccountRequest request) {
accountService.deleteAccount(principal.getIdAsLong(), request.password());
HttpHeaders headers = new HttpHeaders();
authCookies.cleared().forEach(cookie -> headers.add(HttpHeaders.SET_COOKIE, cookie.toString()));
return ResponseEntity.noContent().headers(headers).build();
}
}
@@ -0,0 +1,32 @@
package auth_service.modules.account.api;
import auth_service.modules.account.exception.InvalidPasswordException;
import auth_service.modules.account.exception.TooManyDeleteAttemptsException;
import auth_service.shared.api.dto.response.ErrorResponse;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(assignableTypes = AccountController.class)
@Order(value = 1)
public class AccountExceptionController {
// 403, not 401: the frontend answers a 401 with a refresh and sends the request again.
@ExceptionHandler(InvalidPasswordException.class)
public ResponseEntity<ErrorResponse> clientError(InvalidPasswordException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).contentType(MediaType.APPLICATION_JSON)
.body(new ErrorResponse("INVALID_PASSWORD", ex.getMessage()));
}
@ExceptionHandler(TooManyDeleteAttemptsException.class)
public ResponseEntity<ErrorResponse> clientError(TooManyDeleteAttemptsException ex) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(ex.getRetryAfterSeconds()))
.contentType(MediaType.APPLICATION_JSON)
.body(new ErrorResponse("TOO_MANY_ATTEMPTS", ex.getMessage()));
}
}
@@ -0,0 +1,6 @@
package auth_service.modules.account.api.dto.request;
import jakarta.validation.constraints.NotBlank;
public record DeleteAccountRequest(@NotBlank String password) {
}
@@ -0,0 +1,7 @@
package auth_service.modules.account.exception;
public class InvalidPasswordException extends RuntimeException {
public InvalidPasswordException() {
super("The password is wrong");
}
}
@@ -0,0 +1,13 @@
package auth_service.modules.account.exception;
import lombok.Getter;
@Getter
public class TooManyDeleteAttemptsException extends RuntimeException {
private final long retryAfterSeconds;
public TooManyDeleteAttemptsException(long retryAfterSeconds) {
super("Too many wrong passwords. Try again later.");
this.retryAfterSeconds = retryAfterSeconds;
}
}
@@ -0,0 +1,14 @@
package auth_service.modules.account.model;
import java.util.List;
import java.util.Map;
/**
* Core's answer to a purge or prune.
*
* @param users the users Core touched, or would touch in a dry run
* @param rows rows per table Core deleted, or would delete
* @param refused true when Core found more orphans than it allows and did nothing
*/
public record CoreCleanupResult(boolean dryRun, List<Long> users, Map<String, Integer> rows, boolean refused) {
}
@@ -0,0 +1,75 @@
package auth_service.modules.account.repo;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
/**
* The deleted flag on users. Plain SQL, because the user and login modules own the entities.
*/
@Repository
public class AccountRepository {
private final JdbcTemplate jdbc;
private final NamedParameterJdbcTemplate namedJdbc;
public AccountRepository(JdbcTemplate jdbc, NamedParameterJdbcTemplate namedJdbc) {
this.jdbc = jdbc;
this.namedJdbc = namedJdbc;
}
public Optional<String> findPasswordHashOfActive(long userId) {
return jdbc.queryForList("SELECT password FROM users WHERE id = ? AND deleted_at IS NULL", String.class, userId)
.stream().findFirst();
}
/**
* Sets the flag, frees the Steam ID for a new account and ends every login of the user. A user flagged before
* keeps the first time.
*
* @return 1 when the user was flagged now
*/
@Transactional
public int flagDeleted(long userId, Instant now) {
Timestamp at = Timestamp.from(now);
int flagged = jdbc.update("""
UPDATE users SET deleted_at = ?, steam_id = NULL, steam_id_verified = FALSE
WHERE id = ? AND deleted_at IS NULL""", at, userId);
jdbc.update("UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL", at, userId);
return flagged;
}
public List<Long> findFlaggedIds() {
return jdbc.queryForList("SELECT id FROM users WHERE deleted_at IS NOT NULL ORDER BY id", Long.class);
}
public List<Long> findFlaggedBefore(Instant cutoff) {
return jdbc.queryForList("SELECT id FROM users WHERE deleted_at < ? ORDER BY id", Long.class,
Timestamp.from(cutoff));
}
/**
* Also the flagged users: their rows in Core are no orphans yet.
*/
public List<Long> findAllIds() {
return jdbc.queryForList("SELECT id FROM users ORDER BY id", Long.class);
}
/**
* Settings and refresh tokens go by their ON DELETE CASCADE.
*/
public int deleteUsers(Collection<Long> userIds) {
if (userIds.isEmpty()) {
return 0;
}
return namedJdbc.update("DELETE FROM users WHERE id IN (:ids)", new MapSqlParameterSource("ids", userIds));
}
}
@@ -0,0 +1,83 @@
package auth_service.modules.account.service;
import auth_service.modules.account.model.CoreCleanupResult;
import auth_service.modules.account.repo.AccountRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
/**
* Nightly, after Core's sync: tells Core who is flagged, removes users flagged longer than the retention (Core
* first, then Auth), and lets Core remove rows of users Auth does not know.
*/
@Service
@Slf4j
public class AccountCleanupService {
private final AccountRepository accountRepository;
private final CoreAccountService coreAccountService;
private final boolean dryRun;
private final Duration retention;
public AccountCleanupService(AccountRepository accountRepository, CoreAccountService coreAccountService,
@Value("${app.account-cleanup.dry-run}") boolean dryRun,
@Value("${app.account-cleanup.retention}") Duration retention) {
this.accountRepository = accountRepository;
this.coreAccountService = coreAccountService;
this.dryRun = dryRun;
this.retention = retention;
}
@Scheduled(cron = "${app.account-cleanup.cron}", zone = "Europe/Berlin")
public void runNightly() {
try {
coreAccountService.replaceDeleted(accountRepository.findFlaggedIds());
} catch (RuntimeException e) {
log.warn("Account cleanup stopped: Core did not take the flagged users: {}", e.getMessage());
return;
}
purgeDue();
pruneOrphans();
}
private void purgeDue() {
List<Long> due = accountRepository.findFlaggedBefore(Instant.now().minus(retention));
if (due.isEmpty()) {
return;
}
try {
CoreCleanupResult result = coreAccountService.purge(due, dryRun);
log.info("Account cleanup: Core {} users {}, rows {}", result.dryRun() ? "would delete" : "deleted",
result.users(), result.rows());
if (dryRun || result.dryRun()) {
log.info("Account cleanup (dry run): would delete users {} in Auth", due);
return;
}
// Core skips a user without a tombstone; that user stays flagged and goes next night.
List<Long> purged = due.stream().filter(result.users()::contains).toList();
accountRepository.deleteUsers(purged);
log.info("Account cleanup: deleted users {} in Auth", purged);
} catch (RuntimeException e) {
log.warn("Account cleanup: purge of users {} failed, tried again next night: {}", due, e.getMessage());
}
}
private void pruneOrphans() {
try {
CoreCleanupResult result = coreAccountService.prune(accountRepository.findAllIds(), dryRun);
if (result.refused()) {
log.warn("Account cleanup: Core refused the prune, too many orphans: {}", result.users());
} else if (!result.users().isEmpty()) {
log.info("Account cleanup: Core {} orphans {}, rows {}", result.dryRun() ? "would delete" : "deleted",
result.users(), result.rows());
}
} catch (RuntimeException e) {
log.warn("Account cleanup: prune failed: {}", e.getMessage());
}
}
}
@@ -0,0 +1,65 @@
package auth_service.modules.account.service;
import auth_service.modules.account.exception.InvalidPasswordException;
import auth_service.modules.account.exception.TooManyDeleteAttemptsException;
import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.security.AttemptLimiter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@Service
@Slf4j
public class AccountService {
private static final Duration LOCK = Duration.ofMinutes(15);
private static final int MAX_WRONG_PASSWORDS = 5;
private final AccountRepository accountRepository;
private final PasswordEncoder passwordEncoder;
private final CoreAccountService coreAccountService;
private final AttemptLimiter attemptLimiter = new AttemptLimiter(LOCK);
public AccountService(AccountRepository accountRepository, PasswordEncoder passwordEncoder,
CoreAccountService coreAccountService) {
this.accountRepository = accountRepository;
this.passwordEncoder = passwordEncoder;
this.coreAccountService = coreAccountService;
}
/**
* Flags the user as deleted and ends all logins; the nightly cleanup removes the user 30 days later. Core
* hides the user at once; when Core is down, the nightly cleanup tells it.
*/
@CacheEvict(value = {"usersByEmail", "publicStats"}, allEntries = true)
public void deleteAccount(long userId, String password) {
String key = String.valueOf(userId);
if (!attemptLimiter.tryAcquire(key, MAX_WRONG_PASSWORDS)) {
throw new TooManyDeleteAttemptsException(LOCK.toSeconds());
}
Optional<String> hash = accountRepository.findPasswordHashOfActive(userId);
if (hash.isEmpty()) {
// Already flagged, e.g. a second click: the answer is the same.
attemptLimiter.release(key);
return;
}
if (!passwordEncoder.matches(password, hash.get())) {
throw new InvalidPasswordException();
}
attemptLimiter.reset(key);
accountRepository.flagDeleted(userId, Instant.now());
log.info("Account of user {} flagged as deleted", userId);
try {
coreAccountService.markDeleted(List.of(userId));
} catch (RuntimeException e) {
log.warn("Core did not take the delete of user {}, the nightly cleanup sends it again: {}", userId,
e.getMessage());
}
}
}
@@ -0,0 +1,75 @@
package auth_service.modules.account.service;
import auth_service.modules.account.model.CoreCleanupResult;
import auth_service.shared.security.ServiceTokenIssuer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.time.Duration;
import java.util.List;
import java.util.Map;
/**
* Core's /internal/accounts endpoints, called with a service token. Every error throws.
*/
@Service
public class CoreAccountService {
private final RestClient restClient;
private final ServiceTokenIssuer serviceTokenIssuer;
@Autowired
public CoreAccountService(@Value("${app.core.internal-url}") String coreUrl, ServiceTokenIssuer serviceTokenIssuer) {
this(RestClient.builder().baseUrl(coreUrl).requestFactory(requestFactory()).build(), serviceTokenIssuer);
}
CoreAccountService(RestClient restClient, ServiceTokenIssuer serviceTokenIssuer) {
this.restClient = restClient;
this.serviceTokenIssuer = serviceTokenIssuer;
}
private static SimpleClientHttpRequestFactory requestFactory() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(3));
factory.setReadTimeout(Duration.ofSeconds(10));
return factory;
}
public void markDeleted(List<Long> userIds) {
restClient.post().uri("/internal/accounts/deleted").headers(this::authorize)
.body(Map.of("userIds", userIds)).retrieve().toBodilessEntity();
}
/**
* After this, Core hides exactly these users.
*/
public void replaceDeleted(List<Long> userIds) {
restClient.put().uri("/internal/accounts/deleted").headers(this::authorize)
.body(Map.of("userIds", userIds)).retrieve().toBodilessEntity();
}
/**
* @param dryRun Core only counts; Core also counts when its own dry run is on
*/
public CoreCleanupResult purge(List<Long> userIds, boolean dryRun) {
return restClient.post().uri("/internal/accounts/purge").headers(this::authorize)
.body(Map.of("userIds", userIds, "dryRun", dryRun)).retrieve().body(CoreCleanupResult.class);
}
/**
* @param knownUserIds every user of Auth, also the flagged ones
* @param dryRun see purge
*/
public CoreCleanupResult prune(List<Long> knownUserIds, boolean dryRun) {
return restClient.post().uri("/internal/accounts/prune").headers(this::authorize)
.body(Map.of("knownUserIds", knownUserIds, "dryRun", dryRun)).retrieve().body(CoreCleanupResult.class);
}
private void authorize(HttpHeaders headers) {
headers.setBearerAuth(serviceTokenIssuer.generateServiceToken());
}
}
@@ -11,4 +11,9 @@ public class User {
private String password;
private Long steamId;
private boolean enabled;
private boolean deleted;
public boolean canLogin() {
return enabled && !deleted;
}
}
@@ -3,6 +3,7 @@ package auth_service.modules.login.persistence.entities;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
import java.time.LocalDateTime;
/**
@@ -35,4 +36,7 @@ public class MinimalUserEntity {
@Column(name = "steam_id")
private Long steamId;
@Column(name = "deleted_at", insertable = false, updatable = false)
private Instant deletedAt;
}
@@ -19,6 +19,7 @@ public abstract class EntityMapper {
model.setPassword(entity.getPassword());
model.setSteamId(entity.getSteamId());
model.setEnabled(entity.isEnabled());
model.setDeleted(entity.getDeletedAt() != null);
}
return model;
@@ -50,7 +50,7 @@ public class LoginService {
String hash = foundUser.map(User::getPassword).orElse(dummyHash);
boolean passwordMatches = passwordEncoder.matches(userToLogin.getPassword(), hash);
if (foundUser.isEmpty() || !passwordMatches || !foundUser.get().isEnabled()) {
if (foundUser.isEmpty() || !passwordMatches || !foundUser.get().canLogin()) {
throw new InvalidCredentialsException("Invalid email or password");
}
@@ -46,8 +46,8 @@ public class RefreshTokenService {
}
/**
* Empty when the token cannot renew the session: missing, unknown, expired, revoked, or its user is gone or
* disabled.
* Empty when the token cannot renew the session: missing, unknown, expired, revoked, or its user is gone,
* disabled or deleted.
* A token that was rotated before means a second copy exists, so the whole family is revoked. This method
* returns instead of throwing, so that revoke is committed.
*/
@@ -70,7 +70,7 @@ public class RefreshTokenService {
refreshTokenRepository.revokeFamily(token.familyId(), now);
return Optional.empty();
}
Optional<User> user = userRepository.findById(token.userId()).filter(User::isEnabled);
Optional<User> user = userRepository.findById(token.userId()).filter(User::canLogin);
if (user.isEmpty()) {
refreshTokenRepository.revokeFamily(token.familyId(), now);
return Optional.empty();
@@ -17,13 +17,13 @@ public class PublicProfileRepository {
}
/**
* Only enabled users who switched the public profile on. The name ignores case, like its unique index.
* Only enabled, not deleted users who switched the public profile on. The name ignores case, like its unique index.
*/
public Optional<PublicProfile> findPublicByDisplayName(String displayName) {
return jdbc.query("""
SELECT u.id, u.display_name, u.registration_date, s.profile_stats
FROM users u JOIN user_settings s ON s.user_id = u.id
WHERE LOWER(u.display_name) = LOWER(?) AND u.enabled AND s.public_profile
WHERE LOWER(u.display_name) = LOWER(?) AND u.enabled AND u.deleted_at IS NULL AND s.public_profile
""",
(rs, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"),
rs.getTimestamp("registration_date") != null
@@ -15,13 +15,13 @@ public class StatsRepository {
}
public long countTotalUsers() {
Long v = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE enabled = TRUE", Long.class);
Long v = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE enabled = TRUE AND deleted_at IS NULL", Long.class);
return v != null ? v : 0L;
}
public long countUsersWithSteamLinked() {
Long v = jdbc.queryForObject(
"SELECT COUNT(*) FROM users WHERE enabled = TRUE AND steam_id IS NOT NULL", Long.class);
"SELECT COUNT(*) FROM users WHERE enabled = TRUE AND deleted_at IS NULL AND steam_id IS NOT NULL", Long.class);
return v != null ? v : 0L;
}
}
@@ -148,7 +148,8 @@ public class UserRepositoryImpl implements UserRepository {
@Override
public void setVerifiedSteamId(long userId, long steamId) {
int updated = entityManager.createQuery(
"UPDATE UserEntity u SET u.steamId = :steamId, u.steamIdVerified = TRUE WHERE u.id = :userId")
"UPDATE UserEntity u SET u.steamId = :steamId, u.steamIdVerified = TRUE "
+ "WHERE u.id = :userId AND u.deletedAt IS NULL")
.setParameter("steamId", steamId)
.setParameter("userId", userId)
.executeUpdate();
@@ -3,6 +3,7 @@ package auth_service.modules.user.persistence.entities;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
import java.time.LocalDateTime;
/**
@@ -36,6 +37,10 @@ public class UserEntity {
@Column(name = "steam_id_verified", nullable = false)
private boolean steamIdVerified;
// Read only: a settings save that loaded the user before a delete must not write the old NULL back.
@Column(name = "deleted_at", insertable = false, updatable = false)
private Instant deletedAt;
@Column(name = "display_name", length = 24)
private String displayName;
@@ -0,0 +1,9 @@
package auth_service.shared.security;
/**
* Port for the short-lived token Auth sends with its calls to Core's /internal endpoints. Signed like a login token
* but no login: the login filters of Auth and Core reject it.
*/
public interface ServiceTokenIssuer {
String generateServiceToken();
}
+9
View File
@@ -59,6 +59,15 @@ app:
public-url: ${APP_PUBLIC_URL:http://localhost:8080}
# Where the user lands after the Steam link.
frontend-url: ${APP_FRONTEND_URL:http://localhost:5173}
core:
# Core inside the Docker network, for the account cleanup calls.
internal-url: ${APP_CORE_INTERNAL_URL:http://localhost:8090}
account-cleanup:
cron: "0 15 4 * * *"
# How long a deleted account waits before the cleanup removes it for good.
retention: 30d
# Log only, delete nothing. See the Deployment README before flipping it.
dry-run: ${APP_ACCOUNT_CLEANUP_DRY_RUN:true}
# Registration settings
auth:
@@ -0,0 +1,5 @@
-- "Delete my account" only sets this; the nightly cleanup removes the user 30 days later. Undo: set it back to NULL.
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMPTZ;
-- The delete revokes all refresh tokens of the user.
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens (user_id);
@@ -159,4 +159,17 @@ class JwtServiceTest extends AbstractUnitTest {
String token = jwtService.generateToken("7", "a@b.de", null);
assertEquals("7", Jwts.parser().verifyWith(fromJwk).build().parseSignedClaims(token).getPayload().get("userId"));
}
@Test
void generateServiceToken_IsShortLivedAndNoLogin() {
String token = jwtService.generateServiceToken();
var parsed = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(token);
assertEquals(KID, parsed.getHeader().getKeyId());
assertEquals("service", parsed.getPayload().get("typ", String.class));
assertNull(parsed.getPayload().get("userId"));
long secondsLeft = (parsed.getPayload().getExpiration().getTime() - System.currentTimeMillis()) / 1000;
assertTrue(secondsLeft > 0 && secondsLeft <= 60, "expires in " + secondsLeft + "s");
assertEquals(Optional.empty(), jwtService.parseLoginToken(token));
}
}
@@ -0,0 +1,167 @@
package auth_service.modules.account.api;
import auth_service.configuration.security.ClientHeaderFilter;
import auth_service.modules.user.model.SteamLinkOutcome;
import auth_service.modules.user.service.UserService;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.test.AbstractIntegrationTest;
import com.jayway.jsonpath.JsonPath;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Delete my account against a real database. Core is not reachable in tests, so this also proves that a failed
* mark in Core does not fail the delete.
*/
@AutoConfigureMockMvc
class DeletedAccountFlowTest extends AbstractIntegrationTest {
private static final String EMAIL = "delete-flow@example.com";
private static final String PASSWORD = "correct horse battery";
private static final String NAME = "DeleteFlow";
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private UserService userService;
@Test
void steamIdOfAFlaggedUser_CanBeLinkedByANewAccount() throws Exception {
long steamId = 76561198099990001L;
String newEmail = "delete-flow-new@example.com";
jdbc.update("DELETE FROM users WHERE email = ?", newEmail);
jdbc.update("UPDATE users SET steam_id = ?, steam_id_verified = true WHERE email = ?", steamId, EMAIL);
String access = login().getCookie(AuthCookies.ACCESS_COOKIE).getValue();
deleteAccount(access, PASSWORD).andExpect(status().isNoContent());
Long newUser = jdbc.queryForObject("INSERT INTO users (email, password, enabled) VALUES (?, 'x', true) RETURNING id",
Long.class, newEmail);
try {
assertThat(userService.linkVerifiedSteamId(newUser, steamId)).isEqualTo(SteamLinkOutcome.LINKED);
} finally {
jdbc.update("DELETE FROM users WHERE email IN (?, ?)", newEmail, EMAIL);
}
}
@BeforeEach
void seedUser() {
jdbc.update("DELETE FROM users WHERE email = ?", EMAIL);
Long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, display_name) VALUES (?, ?, true, ?) "
+ "RETURNING id", Long.class, EMAIL, passwordEncoder.encode(PASSWORD), NAME);
jdbc.update("INSERT INTO user_settings (user_id, language, theme, public_profile) VALUES (?, 'de', 'dark', true)", id);
}
private MockHttpServletResponse login() throws Exception {
return mockMvc.perform(post("/api/auth/login")
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + "\"}"))
.andReturn().getResponse();
}
private ResultActions deleteAccount(String accessToken, String password) throws Exception {
return mockMvc.perform(delete("/api/users/me")
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, accessToken))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"" + password + "\"}"));
}
private long totalUsers() throws Exception {
String body = mockMvc.perform(get("/api/stats/public")).andReturn().getResponse().getContentAsString();
return JsonPath.<Number>read(body, "$.totalUsers").longValue();
}
@Test
void wrongPassword_Is403AndKeepsTheAccount() throws Exception {
String access = login().getCookie(AuthCookies.ACCESS_COOKIE).getValue();
deleteAccount(access, "wrong").andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("INVALID_PASSWORD"));
assertThat(jdbc.queryForObject("SELECT deleted_at FROM users WHERE email = ?", Object.class, EMAIL)).isNull();
}
@Test
void sixthWrongPassword_Is429WithRetryAfter() throws Exception {
String access = login().getCookie(AuthCookies.ACCESS_COOKIE).getValue();
for (int i = 0; i < 5; i++) {
deleteAccount(access, "wrong").andExpect(status().isForbidden());
}
deleteAccount(access, PASSWORD).andExpect(status().isTooManyRequests())
.andExpect(header().string("Retry-After", "900"))
.andExpect(jsonPath("$.code").value("TOO_MANY_ATTEMPTS"));
}
@Test
void withoutLogin_Is401() throws Exception {
mockMvc.perform(delete("/api/users/me").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.contentType(MediaType.APPLICATION_JSON).content("{\"password\":\"" + PASSWORD + "\"}"))
.andExpect(status().isUnauthorized());
}
@Test
void blankPassword_Is400() throws Exception {
String access = login().getCookie(AuthCookies.ACCESS_COOKIE).getValue();
deleteAccount(access, " ").andExpect(status().isBadRequest());
}
@Test
void delete_FlagsTheUserAndHidesItEverywhere() throws Exception {
MockHttpServletResponse session = login();
String access = session.getCookie(AuthCookies.ACCESS_COOKIE).getValue();
String refresh = session.getCookie(AuthCookies.REFRESH_COOKIE).getValue();
long usersBefore = totalUsers();
MockHttpServletResponse deleted = deleteAccount(access, PASSWORD).andExpect(status().isNoContent())
.andReturn().getResponse();
assertThat(deleted.getCookie(AuthCookies.ACCESS_COOKIE).getMaxAge()).isZero();
assertThat(deleted.getCookie(AuthCookies.REFRESH_COOKIE).getMaxAge()).isZero();
assertThat(jdbc.queryForObject("SELECT deleted_at FROM users WHERE email = ?", Object.class, EMAIL)).isNotNull();
mockMvc.perform(post("/api/auth/refresh").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(new Cookie(AuthCookies.REFRESH_COOKIE, refresh)))
.andExpect(status().isUnauthorized());
assertThat(login().getStatus()).isEqualTo(401);
mockMvc.perform(get("/api/public/profiles/" + NAME)).andExpect(status().isNotFound());
assertThat(totalUsers()).isEqualTo(usersBefore - 1);
}
@Test
void registration_WithTheEmailOfAFlaggedUser_IsStillRefused() throws Exception {
jdbc.update("UPDATE users SET deleted_at = NOW() WHERE email = ?", EMAIL);
mockMvc.perform(post("/api/registration/new")
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD
+ "\",\"inviteCode\":\"test-invite-code\"}"))
.andExpect(jsonPath("$.code").value("EMAIL_ALREADY_IN_USE"));
}
}
@@ -0,0 +1,92 @@
package auth_service.modules.account.repo;
import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@Import(AccountRepository.class)
class AccountRepositoryTest extends AbstractPersistenceTest {
private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.SECONDS);
@Autowired
private AccountRepository repository;
@Autowired
private JdbcTemplate jdbc;
private long alice;
private long bob;
private long insertUser(String email) {
Long id = jdbc.queryForObject("insert into users (email, password, enabled) values (?, 'hash-' || ?, true) returning id",
Long.class, email, email);
jdbc.update("insert into user_settings (user_id, language, theme) values (?, 'de', 'dark')", id);
jdbc.update("""
insert into refresh_tokens (user_id, token_hash, family_id, expires_at, created_at)
values (?, ?, ?, now() + interval '30 days', now())""", id, UUID.randomUUID().toString(), UUID.randomUUID());
return id;
}
private Long count(String sql, Object... args) {
return jdbc.queryForObject(sql, Long.class, args);
}
@BeforeEach
void seed() {
alice = insertUser("alice@example.com");
bob = insertUser("bob@example.com");
}
@Test
void flagDeleted_SetsTheFlagAndRevokesOnlyThatUsersTokens() {
assertThat(repository.flagDeleted(alice, NOW)).isEqualTo(1);
assertThat(jdbc.queryForObject("select deleted_at from users where id = ?", Timestamp.class, alice).toInstant())
.isEqualTo(NOW);
assertThat(count("select count(*) from refresh_tokens where user_id = ? and revoked_at is null", alice)).isZero();
assertThat(count("select count(*) from refresh_tokens where user_id = ? and revoked_at is null", bob)).isEqualTo(1);
assertThat(repository.flagDeleted(alice, NOW.plusSeconds(60))).as("second flag keeps the first time").isZero();
}
@Test
void findPasswordHashOfActive_IsEmptyForAFlaggedUser() {
assertThat(repository.findPasswordHashOfActive(alice)).contains("hash-alice@example.com");
repository.flagDeleted(alice, NOW);
assertThat(repository.findPasswordHashOfActive(alice)).isEmpty();
}
@Test
void findFlagged_ListsFlaggedUsersAndThoseDue() {
repository.flagDeleted(alice, NOW.minus(31, ChronoUnit.DAYS));
repository.flagDeleted(bob, NOW.minus(29, ChronoUnit.DAYS));
assertThat(repository.findFlaggedIds()).containsExactlyInAnyOrder(alice, bob);
assertThat(repository.findFlaggedBefore(NOW.minus(30, ChronoUnit.DAYS))).containsExactly(alice);
assertThat(repository.findAllIds()).contains(alice, bob);
}
@Test
void deleteUsers_RemovesTheUserWithSettingsAndTokens() {
assertThat(repository.deleteUsers(List.of(alice))).isEqualTo(1);
assertThat(count("select count(*) from users where id = ?", alice)).isZero();
assertThat(count("select count(*) from user_settings where user_id = ?", alice)).isZero();
assertThat(count("select count(*) from refresh_tokens where user_id = ?", alice)).isZero();
assertThat(count("select count(*) from users where id = ?", bob)).isEqualTo(1);
assertThat(repository.deleteUsers(List.of())).isZero();
}
}
@@ -0,0 +1,126 @@
package auth_service.modules.account.service;
import auth_service.modules.account.model.CoreCleanupResult;
import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.web.client.RestClientException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
class AccountCleanupServiceTest extends AbstractUnitTest {
private static final Duration RETENTION = Duration.ofDays(30);
@Mock
private AccountRepository accountRepository;
@Mock
private CoreAccountService coreAccountService;
private AccountCleanupService service(boolean dryRun) {
return new AccountCleanupService(accountRepository, coreAccountService, dryRun, RETENTION);
}
private static CoreCleanupResult core(boolean dryRun) {
return new CoreCleanupResult(dryRun, List.of(7L), Map.of("user_game", 3), false);
}
@BeforeEach
void setUp() {
lenient().when(accountRepository.findFlaggedIds()).thenReturn(List.of(7L, 8L));
lenient().when(accountRepository.findFlaggedBefore(any())).thenReturn(List.of(7L));
lenient().when(accountRepository.findAllIds()).thenReturn(List.of(1L, 7L, 8L));
}
@Test
void runNightly_SendsTheFlaggedSetThenPurgesThenPrunes() {
when(coreAccountService.purge(eq(List.of(7L)), anyBoolean())).thenReturn(core(false));
when(coreAccountService.prune(eq(List.of(1L, 7L, 8L)), anyBoolean())).thenReturn(core(false));
service(false).runNightly();
verify(coreAccountService).replaceDeleted(List.of(7L, 8L));
verify(accountRepository).deleteUsers(List.of(7L));
}
@Test
void runNightly_InDryRunAsksCoreForADryRunAndDeletesNothingInAuth() {
when(coreAccountService.purge(eq(List.of(7L)), anyBoolean())).thenReturn(core(true));
when(coreAccountService.prune(any(), anyBoolean())).thenReturn(core(true));
service(true).runNightly();
verify(coreAccountService).purge(List.of(7L), true);
verify(coreAccountService).prune(List.of(1L, 7L, 8L), true);
verify(accountRepository, never()).deleteUsers(any());
}
@Test
void runNightly_ADryRunInCoreKeepsTheUserInAuth() {
when(coreAccountService.purge(eq(List.of(7L)), anyBoolean())).thenReturn(core(true));
when(coreAccountService.prune(any(), anyBoolean())).thenReturn(core(true));
service(false).runNightly();
verify(accountRepository, never()).deleteUsers(any());
}
@Test
void runNightly_AFailedPurgeKeepsTheUserInAuthButStillPrunes() {
when(coreAccountService.purge(eq(List.of(7L)), anyBoolean())).thenThrow(new RestClientException("Core down"));
when(coreAccountService.prune(any(), anyBoolean())).thenReturn(core(false));
service(false).runNightly();
verify(accountRepository, never()).deleteUsers(any());
verify(coreAccountService).prune(List.of(1L, 7L, 8L), false);
}
@Test
void runNightly_StopsWhenCoreCannotTakeTheFlaggedSet() {
doThrow(new RestClientException("Core down")).when(coreAccountService).replaceDeleted(any());
service(false).runNightly();
verify(coreAccountService).replaceDeleted(List.of(7L, 8L));
verifyNoMoreInteractions(coreAccountService);
verify(accountRepository, never()).deleteUsers(any());
}
@Test
void runNightly_NothingDueSkipsThePurge() {
when(accountRepository.findFlaggedBefore(any())).thenReturn(List.of());
when(coreAccountService.prune(any(), anyBoolean())).thenReturn(core(false));
service(false).runNightly();
verify(coreAccountService, never()).purge(any(), anyBoolean());
verify(accountRepository, never()).deleteUsers(any());
}
@Test
void runNightly_KeepsAUserCoreSkipped() {
when(accountRepository.findFlaggedBefore(any())).thenReturn(List.of(7L, 8L));
when(coreAccountService.purge(eq(List.of(7L, 8L)), anyBoolean())).thenReturn(core(false));
when(coreAccountService.prune(any(), anyBoolean())).thenReturn(core(false));
service(false).runNightly();
verify(accountRepository).deleteUsers(List.of(7L));
}
}
@@ -0,0 +1,103 @@
package auth_service.modules.account.service;
import auth_service.modules.account.exception.InvalidPasswordException;
import auth_service.modules.account.exception.TooManyDeleteAttemptsException;
import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class AccountServiceTest extends AbstractUnitTest {
private static final long USER_ID = 42L;
@Mock
private AccountRepository accountRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private CoreAccountService coreAccountService;
private AccountService service;
@BeforeEach
void setUp() {
service = new AccountService(accountRepository, passwordEncoder, coreAccountService);
lenient().when(accountRepository.findPasswordHashOfActive(USER_ID)).thenReturn(Optional.of("hash"));
lenient().when(passwordEncoder.matches("right", "hash")).thenReturn(true);
}
@Test
void deleteAccount_FlagsTheUserAndTellsCore() {
service.deleteAccount(USER_ID, "right");
verify(accountRepository).flagDeleted(eq(USER_ID), any());
verify(coreAccountService).markDeleted(List.of(USER_ID));
}
@Test
void deleteAccount_WrongPasswordFlagsNothing() {
assertThatThrownBy(() -> service.deleteAccount(USER_ID, "wrong")).isInstanceOf(InvalidPasswordException.class);
verify(accountRepository, never()).flagDeleted(anyLong(), any());
verifyNoInteractions(coreAccountService);
}
@Test
void deleteAccount_SixthWrongPasswordIsRateLimited() {
for (int i = 0; i < 5; i++) {
assertThatThrownBy(() -> service.deleteAccount(USER_ID, "wrong")).isInstanceOf(InvalidPasswordException.class);
}
assertThatThrownBy(() -> service.deleteAccount(USER_ID, "right")).isInstanceOf(TooManyDeleteAttemptsException.class);
verify(accountRepository, never()).flagDeleted(anyLong(), any());
}
@Test
void deleteAccount_SuccessResetsTheCounter() {
for (int i = 0; i < 4; i++) {
assertThatThrownBy(() -> service.deleteAccount(USER_ID, "wrong")).isInstanceOf(InvalidPasswordException.class);
}
service.deleteAccount(USER_ID, "right");
for (int i = 0; i < 5; i++) {
assertThatThrownBy(() -> service.deleteAccount(USER_ID, "wrong")).isInstanceOf(InvalidPasswordException.class);
}
}
@Test
void deleteAccount_ACoreErrorDoesNotFailTheDelete() {
doThrow(new IllegalStateException("Core down")).when(coreAccountService).markDeleted(any());
assertThatNoException().isThrownBy(() -> service.deleteAccount(USER_ID, "right"));
verify(accountRepository).flagDeleted(eq(USER_ID), any());
}
@Test
void deleteAccount_AlreadyFlaggedUserIsANoOp() {
when(accountRepository.findPasswordHashOfActive(USER_ID)).thenReturn(Optional.empty());
assertThatNoException().isThrownBy(() -> service.deleteAccount(USER_ID, "anything"));
verify(accountRepository, never()).flagDeleted(anyLong(), any());
verifyNoInteractions(coreAccountService);
}
}
@@ -0,0 +1,77 @@
package auth_service.modules.account.service;
import auth_service.modules.account.model.CoreCleanupResult;
import auth_service.shared.security.ServiceTokenIssuer;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withNoContent;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class CoreAccountServiceTest extends AbstractUnitTest {
private static final String CORE = "http://core:8090";
private static final ServiceTokenIssuer TOKENS = () -> "service-token";
private MockRestServiceServer core;
private CoreAccountService service;
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder().baseUrl(CORE);
core = MockRestServiceServer.bindTo(builder).build();
service = new CoreAccountService(builder.build(), TOKENS);
}
@Test
void markAndReplace_SendTheUserIdsWithTheServiceToken() {
core.expect(requestTo(CORE + "/internal/accounts/deleted")).andExpect(method(HttpMethod.POST))
.andExpect(header("Authorization", "Bearer service-token"))
.andExpect(content().json("{\"userIds\":[42]}"))
.andRespond(withNoContent());
core.expect(requestTo(CORE + "/internal/accounts/deleted")).andExpect(method(HttpMethod.PUT))
.andExpect(content().json("{\"userIds\":[]}"))
.andRespond(withNoContent());
service.markDeleted(List.of(42L));
service.replaceDeleted(List.of());
core.verify();
}
@Test
void purgeAndPrune_ReadTheResult() {
String result = "{\"dryRun\":true,\"users\":[42],\"rows\":{\"user_game\":3},\"refused\":false}";
core.expect(requestTo(CORE + "/internal/accounts/purge")).andExpect(content().json("{\"userIds\":[42],\"dryRun\":false}"))
.andRespond(withSuccess(result, MediaType.APPLICATION_JSON));
core.expect(requestTo(CORE + "/internal/accounts/prune")).andExpect(content().json("{\"knownUserIds\":[1,42],\"dryRun\":true}"))
.andRespond(withSuccess(result, MediaType.APPLICATION_JSON));
CoreCleanupResult expected = new CoreCleanupResult(true, List.of(42L), Map.of("user_game", 3), false);
assertThat(service.purge(List.of(42L), false)).isEqualTo(expected);
assertThat(service.prune(List.of(1L, 42L), true)).isEqualTo(expected);
}
@Test
void anErrorOfCoreThrows() {
core.expect(requestTo(CORE + "/internal/accounts/deleted")).andRespond(withServerError());
assertThatThrownBy(() -> service.markDeleted(List.of(42L))).isInstanceOf(RestClientException.class);
}
}
@@ -17,6 +17,7 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
@@ -165,4 +166,16 @@ class LoginServiceTest extends AbstractUnitTest {
verifyNoInteractions(minimalUserRepository);
verify(passwordEncoder, never()).matches(any(), eq("encodedPassword"));
}
@Test
void login_DeletedUser_IsRefusedLikeAWrongPassword() {
var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true)
.deleted(true).build();
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
verify(refreshTokenService, never()).startFamily(anyLong());
}
}
@@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.LocalDateTime;
import java.util.List;
@@ -26,6 +27,22 @@ class UserRepositoryTest extends AbstractPersistenceTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private JdbcTemplate jdbc;
@Test
void updateSettings_KeepsADeleteThatCommittedAfterTheLoad() {
var user = entityManager.persistFlushFind(UserEntity.builder().email("race@example.com").password("password")
.enabled(true).registrationDate(LocalDateTime.now()).build());
// The delete request commits after the settings request loaded the user.
jdbc.update("UPDATE users SET deleted_at = NOW() WHERE id = ?", user.getId());
userRepository.updateSettings(user.getId(), "RaceName", UserSettings.builder().language("de").theme("dark").build());
entityManager.flush();
assertNotNull(jdbc.queryForObject("SELECT deleted_at FROM users WHERE id = ?", Object.class, user.getId()));
}
@Test
void findByEmail_Success() {
// Arrange
@@ -141,6 +158,15 @@ class UserRepositoryTest extends AbstractPersistenceTest {
assertThrows(UserNotFoundException.class, () -> userRepository.setVerifiedSteamId(999_999L, 76561198000000001L));
}
@Test
void setVerifiedSteamId_DeletedUser_IsRefused() {
var deleted = entityManager.persistFlushFind(UserEntity.builder().email("deleted@example.com").password("password")
.enabled(true).registrationDate(LocalDateTime.now()).build());
jdbc.update("UPDATE users SET deleted_at = NOW() WHERE id = ?", deleted.getId());
assertThrows(UserNotFoundException.class, () -> userRepository.setVerifiedSteamId(deleted.getId(), 76561198000000001L));
}
@Test
void updateSettings_UnknownUser() {
var settings = UserSettings.builder().language("en").theme("light").build();
@@ -18,7 +18,9 @@ import java.util.Base64;
* Full Spring context with Testcontainers.
*/
@Tag("IntegrationTest")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// The account cleanup must not run by itself, and Core is never reachable in tests.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"app.account-cleanup.cron=-", "app.core.internal-url=http://127.0.0.1:1"})
@ActiveProfiles("dev")
public abstract class AbstractIntegrationTest {