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

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