Public Access
feat(account): let users delete their account
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:
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 {
|
||||
|
||||
|
||||
Reference in new issue
Block a user