feat(account): hide deleted accounts and clean up their rows
Build, Test & Push Core Service / quality-check (push) Successful in 1m2s
Build, Test & Push Core Service / docker (push) Successful in 1m20s

Auth marks a deleted user in deleted_user (no sync, not in landing stats or pile snapshots) and later purges the rows through /internal/accounts with a service token. Prune removes rows of user ids Auth does not know. Dry run by default.
This commit is contained in:
lobo committed 2026-09-27 11:46:22 +02:00
1 parent 88aae0101f
commit 351cda411d
28 files changed
+941 -16

No files matched your search

@@ -136,4 +136,34 @@ class JwtServiceTest extends AbstractUnitTest {
void parseLoginToken_ProfileTicketIsNoLogin() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(signTicket("public-profile", "42", 60_000)));
}
private String signServiceToken(PrivateKey key, String type, Long expiresInMs) {
io.jsonwebtoken.JwtBuilder builder = Jwts.builder().subject("tbp-auth").claim("typ", type).issuedAt(new Date());
if (expiresInMs != null) {
builder.expiration(new Date(System.currentTimeMillis() + expiresInMs));
}
return builder.signWith(key, Jwts.SIG.EdDSA).compact();
}
@Test
void isServiceToken_AcceptsAServiceTokenOfAuth() {
assertTrue(jwtService.isServiceToken(signServiceToken(signingKey, "service", 60_000L)));
}
@Test
void isServiceToken_RejectsEverythingElse() throws Exception {
PrivateKey otherKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPrivate();
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, "service", null)), "no exp");
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, "service", -1_000L)), "expired");
assertFalse(jwtService.isServiceToken(signServiceToken(otherKey, "service", 60_000L)), "foreign key");
assertFalse(jwtService.isServiceToken(signToken("42", "a@b.de", null)), "login token");
assertFalse(jwtService.isServiceToken(signTicket("public-profile", "42", 60_000)), "profile ticket");
assertFalse(jwtService.isServiceToken("garbage"));
}
@Test
void parseLoginToken_ServiceTokenIsNoLogin() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(signServiceToken(signingKey, "service", 60_000L)));
}
}
@@ -0,0 +1,53 @@
package core_service.configuration.security;
import core_service.shared.test.AbstractUnitTest;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.lenient;
class ServiceTokenFilterTest extends AbstractUnitTest {
@Mock
private JwtService jwtService;
@AfterEach
void clearContext() {
SecurityContextHolder.clearContext();
}
private Authentication authenticationAfter(MockHttpServletRequest request) throws Exception {
lenient().when(jwtService.isServiceToken("good")).thenReturn(true);
new ServiceTokenFilter(jwtService).doFilter(request, new MockHttpServletResponse(), new MockFilterChain());
return SecurityContextHolder.getContext().getAuthentication();
}
@Test
void bearerServiceTokenGivesTheServiceAuthority() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer good");
assertThat(authenticationAfter(request).getAuthorities()).extracting(GrantedAuthority::getAuthority)
.containsExactly(ServiceTokenFilter.SERVICE_AUTHORITY);
}
@Test
void cookieAndInvalidTokensAuthenticateNothing() throws Exception {
MockHttpServletRequest cookie = new MockHttpServletRequest();
cookie.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "good"));
assertThat(authenticationAfter(cookie)).isNull();
MockHttpServletRequest invalid = new MockHttpServletRequest();
invalid.addHeader("Authorization", "Bearer bad");
assertThat(authenticationAfter(invalid)).isNull();
}
}
@@ -0,0 +1,112 @@
package core_service.modules.account.api;
import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.AfterEach;
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.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.util.Date;
import static org.assertj.core.api.Assertions.assertThat;
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.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The real (non-dev) filter chain, like ExpiredAccessTokenTest, so both share one Spring context.
*/
@AutoConfigureMockMvc
@ActiveProfiles(value = "prod", inheritProfiles = false)
class InternalAccountControllerTest extends AbstractIntegrationTest {
private static final long USER_ID = 910_001L;
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@AfterEach
void cleanUp() {
jdbc.update("delete from deleted_user where user_id = ?", USER_ID);
}
private static String token(JwtBuilder builder) {
return builder.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
}
private static String serviceToken() {
return token(Jwts.builder().subject("tbp-auth").claim("typ", "service"));
}
private static String loginToken() {
return token(Jwts.builder().subject("a@b.de").claim("userId", String.valueOf(USER_ID)));
}
private ResultActions send(MockHttpServletRequestBuilder request, String body, String token) throws Exception {
request.contentType(MediaType.APPLICATION_JSON).content(body);
if (token != null) {
request.header("Authorization", "Bearer " + token);
}
return mockMvc.perform(request);
}
@Test
void withoutAServiceToken_Is401() throws Exception {
String body = "{\"userIds\":[" + USER_ID + "]}";
send(post("/internal/accounts/deleted"), body, null).andExpect(status().isUnauthorized());
send(post("/internal/accounts/deleted"), body, loginToken()).andExpect(status().isUnauthorized());
assertThat(jdbc.queryForList("select user_id from deleted_user where user_id = ?", Long.class, USER_ID)).isEmpty();
}
@Test
void serviceToken_IsNoLoginForTheApi() throws Exception {
mockMvc.perform(get("/api/library/summary").header("Authorization", "Bearer " + serviceToken()))
.andExpect(status().isUnauthorized());
}
@Test
void markThenPurge_InDryRunCountsAndKeepsTheTombstone() throws Exception {
String body = "{\"userIds\":[" + USER_ID + "]}";
send(post("/internal/accounts/deleted"), body, serviceToken()).andExpect(status().isNoContent());
send(post("/internal/accounts/purge"), "{\"userIds\":[" + USER_ID + "],\"dryRun\":false}", serviceToken())
.andExpect(status().isOk())
.andExpect(jsonPath("$.dryRun").value(true))
.andExpect(jsonPath("$.users[0]").value(USER_ID))
.andExpect(jsonPath("$.rows.user_game").value(0))
.andExpect(jsonPath("$.refused").value(false));
assertThat(jdbc.queryForList("select user_id from deleted_user where user_id = ?", Long.class, USER_ID))
.containsExactly(USER_ID);
}
@Test
void replaceDeleted_RemovesTombstonesAuthNoLongerFlags() throws Exception {
send(post("/internal/accounts/deleted"), "{\"userIds\":[" + USER_ID + "]}", serviceToken())
.andExpect(status().isNoContent());
send(put("/internal/accounts/deleted"), "{\"userIds\":[]}", serviceToken()).andExpect(status().isNoContent());
assertThat(jdbc.queryForList("select user_id from deleted_user where user_id = ?", Long.class, USER_ID)).isEmpty();
}
@Test
void prune_WithAnEmptyListIs400() throws Exception {
send(post("/internal/accounts/prune"), "{\"knownUserIds\":[],\"dryRun\":false}", serviceToken()).andExpect(status().isBadRequest());
}
}
@@ -0,0 +1,27 @@
package core_service.modules.account.api;
import core_service.shared.test.AbstractIntegrationTest;
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.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The dev profile authenticates every API request as user 1; /internal must still need a service token.
*/
@AutoConfigureMockMvc
class InternalAccountDevProfileTest extends AbstractIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void withoutAServiceToken_Is401() throws Exception {
mockMvc.perform(post("/internal/accounts/deleted").contentType(MediaType.APPLICATION_JSON).content("{\"userIds\":[1]}"))
.andExpect(status().isUnauthorized());
}
}
@@ -0,0 +1,116 @@
package core_service.modules.account.repo;
import core_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.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Import(AccountCleanupRepository.class)
class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"user_steam_sync", "user_game");
@Autowired
private AccountCleanupRepository repository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("delete from backlog_snapshot");
jdbc.update("insert into steam_game (appid, name) values (220, 'Half-Life 2') on conflict do nothing");
for (long userId : List.of(1L, 2L)) {
// The V14 trigger adds the user_game_status_change row.
jdbc.update("insert into user_game (user_id, appid, status) values (?, 220, 'BACKLOG')", userId);
jdbc.update("insert into user_game_achievement (user_id, appid, api_name) values (?, 220, 'ACH_1')", userId);
jdbc.update("insert into user_game_achievement_meta (user_id, appid) values (?, 220)", userId);
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, 220, 30)", userId);
jdbc.update("insert into wishlist_game (user_id, appid) values (?, 620)", userId);
jdbc.update("""
insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, listed_games)
values (?, current_date, 1, 1, 0, 1)""", userId);
jdbc.update("""
insert into user_steam_sync (user_id, steam_id, user_seen_at) values (?, '76561198000000001', now())""",
userId);
}
}
private int rows(String table, long userId) {
Integer count = jdbc.queryForObject("select count(*) from " + table + " where user_id = ?", Integer.class, userId);
return count != null ? count : 0;
}
private List<Long> deleted() {
return jdbc.queryForList("select user_id from deleted_user order by user_id", Long.class);
}
@Test
void addDeleted_IsIdempotentAndStopsTheNightlySync() {
repository.addDeleted(List.of(1L));
repository.addDeleted(List.of(1L));
assertThat(deleted()).containsExactly(1L);
assertThat(jdbc.queryForObject("select user_seen_at from user_steam_sync where user_id = 1", Object.class)).isNull();
assertThat(jdbc.queryForObject("select user_seen_at from user_steam_sync where user_id = 2", Object.class)).isNotNull();
}
@Test
void replaceDeleted_KeepsOnlyTheGivenUsers() {
repository.addDeleted(List.of(1L, 2L));
repository.replaceDeleted(List.of(2L, 3L));
assertThat(deleted()).containsExactly(2L, 3L);
repository.replaceDeleted(List.of());
assertThat(deleted()).isEmpty();
}
@Test
void findDeleted_ReturnsOnlyTombstonedUsers() {
repository.addDeleted(List.of(2L));
assertThat(repository.findDeleted(List.of(1L, 2L))).containsExactly(2L);
assertThat(repository.findDeleted(List.of())).isEmpty();
}
@Test
void countRows_CountsPerTableAndDeletesNothing() {
Map<String, Integer> counts = repository.countRows(List.of(1L));
assertThat(counts.keySet()).containsAll(USER_TABLES);
USER_TABLES.forEach(table -> assertThat(counts.get(table)).as(table).isEqualTo(1));
USER_TABLES.forEach(table -> assertThat(rows(table, 1L)).as(table).isEqualTo(1));
}
@Test
void deleteRows_RemovesEveryRowOfTheUserAndKeepsTheOthers() {
repository.addDeleted(List.of(1L));
Map<String, Integer> deletedRows = repository.deleteRows(List.of(1L));
USER_TABLES.forEach(table -> assertThat(deletedRows.get(table)).as(table).isEqualTo(1));
assertThat(deletedRows.get("deleted_user")).isEqualTo(1);
USER_TABLES.forEach(table -> assertThat(rows(table, 1L)).as(table).isZero());
USER_TABLES.forEach(table -> assertThat(rows(table, 2L)).as(table).isEqualTo(1));
assertThat(deleted()).isEmpty();
assertThat(jdbc.queryForObject("select count(*) from steam_game where appid = 220", Integer.class)).isEqualTo(1);
}
@Test
void findOrphans_ReturnsUsersNotInTheList() {
repository.addDeleted(List.of(7L));
assertThat(repository.findOrphans(List.of(2L))).containsExactly(1L, 7L);
assertThat(repository.findOrphans(List.of(1L, 2L, 7L))).isEmpty();
}
}
@@ -0,0 +1,110 @@
package core_service.modules.account.service;
import core_service.modules.account.domain.CleanupResult;
import core_service.modules.account.repo.AccountCleanupRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
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.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AccountCleanupServiceTest extends AbstractUnitTest {
private static final Map<String, Integer> ROWS = Map.of("user_game", 3);
@Mock
private AccountCleanupRepository repository;
private AccountCleanupService service(boolean dryRun) {
return new AccountCleanupService(repository, dryRun, 2);
}
@Test
void purge_OnlyTouchesTombstonedUsers() {
when(repository.findDeleted(List.of(1L, 2L))).thenReturn(List.of(2L));
when(repository.deleteRows(List.of(2L))).thenReturn(ROWS);
CleanupResult result = service(false).purge(List.of(1L, 2L), false);
assertThat(result).isEqualTo(new CleanupResult(false, List.of(2L), ROWS, false));
}
@Test
void purge_InDryRunOnlyCounts() {
when(repository.findDeleted(List.of(2L))).thenReturn(List.of(2L));
when(repository.countRows(List.of(2L))).thenReturn(ROWS);
CleanupResult result = service(true).purge(List.of(2L), false);
assertThat(result).isEqualTo(new CleanupResult(true, List.of(2L), ROWS, false));
verify(repository, never()).deleteRows(any());
}
@Test
void prune_RefusesAnEmptyList() {
assertThatThrownBy(() -> service(false).prune(List.of(), false)).isInstanceOf(IllegalArgumentException.class);
verify(repository, never()).findOrphans(any());
}
@Test
void prune_RefusesMoreOrphansThanAllowed() {
when(repository.findOrphans(List.of(9L))).thenReturn(List.of(1L, 2L, 3L));
CleanupResult result = service(false).prune(List.of(9L), false);
assertThat(result).isEqualTo(new CleanupResult(false, List.of(1L, 2L, 3L), Map.of(), true));
verify(repository, never()).deleteRows(any());
verify(repository, never()).countRows(any());
}
@Test
void prune_DeletesTheOrphans() {
when(repository.findOrphans(List.of(9L))).thenReturn(List.of(1L, 2L));
when(repository.deleteRows(List.of(1L, 2L))).thenReturn(ROWS);
assertThat(service(false).prune(List.of(9L), false)).isEqualTo(new CleanupResult(false, List.of(1L, 2L), ROWS, false));
}
@Test
void prune_InDryRunOnlyCounts() {
when(repository.findOrphans(List.of(9L))).thenReturn(List.of(1L));
when(repository.countRows(List.of(1L))).thenReturn(ROWS);
assertThat(service(true).prune(List.of(9L), false)).isEqualTo(new CleanupResult(true, List.of(1L), ROWS, false));
verify(repository, never()).deleteRows(any());
}
@Test
void prune_WithoutOrphansDoesNothing() {
when(repository.findOrphans(List.of(9L))).thenReturn(List.of());
assertThat(service(false).prune(List.of(9L), false)).isEqualTo(new CleanupResult(false, List.of(), Map.of(), false));
verify(repository, never()).deleteRows(any());
}
@Test
void purge_AuthsDryRunOnlyCounts() {
when(repository.findDeleted(List.of(2L))).thenReturn(List.of(2L));
when(repository.countRows(List.of(2L))).thenReturn(ROWS);
assertThat(service(false).purge(List.of(2L), true)).isEqualTo(new CleanupResult(true, List.of(2L), ROWS, false));
verify(repository, never()).deleteRows(any());
}
@Test
void prune_AuthsDryRunOnlyCounts() {
when(repository.findOrphans(List.of(9L))).thenReturn(List.of(1L));
when(repository.countRows(List.of(1L))).thenReturn(ROWS);
assertThat(service(false).prune(List.of(9L), true)).isEqualTo(new CleanupResult(true, List.of(1L), ROWS, false));
verify(repository, never()).deleteRows(any());
}
}
@@ -103,4 +103,15 @@ class BacklogSnapshotRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.live(1L, DAY)).isEqualTo(new UserStats.PileDay(DAY, 1, 1, 600));
assertThat(repository.live(3L, DAY)).isEqualTo(new UserStats.PileDay(DAY, 0, 0, 0));
}
@Test
void writeAll_SkipsDeletedUsers() {
userGameRepository.saveAllAndFlush(List.of(game(1L, 220L, 0, GameStatus.BACKLOG),
game(2L, 220L, 0, GameStatus.BACKLOG)));
jdbc.update("insert into deleted_user (user_id) values (2)");
assertThat(repository.writeAll(DAY)).isEqualTo(1);
assertThat(repository.findSince(2L, DAY)).isEmpty();
}
}
@@ -0,0 +1,47 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.UserSteamSync;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
class UserSteamSyncRepositoryTest extends AbstractPersistenceTest {
private static final String STEAM_ID = "76561198000000001";
@Autowired
private UserSteamSyncRepository repository;
@Autowired
private JdbcTemplate jdbc;
private int claim(long userId) {
return repository.tryClaimRun(userId, STEAM_ID, Instant.now().minusSeconds(600));
}
@Test
void tryClaimRun_ClaimsForALiveUser() {
assertThat(claim(1L)).isEqualTo(1);
}
@Test
void tryClaimRun_RefusesADeletedUserWithoutASyncRow() {
jdbc.update("insert into deleted_user (user_id) values (2)");
assertThat(claim(2L)).isZero();
assertThat(repository.findById(2L)).isEmpty();
}
@Test
void tryClaimRun_RefusesADeletedUserWithAnIdleSyncRow() {
repository.saveAndFlush(new UserSteamSync(3L, STEAM_ID));
jdbc.update("insert into deleted_user (user_id) values (3)");
assertThat(claim(3L)).isZero();
}
}
@@ -8,6 +8,7 @@ import core_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.jdbc.core.JdbcTemplate;
import org.springframework.context.annotation.Import;
import java.time.Instant;
@@ -28,6 +29,9 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
@@ -61,4 +65,15 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
private static List<Long> appids(List<Map<String, Object>> rows) {
return rows.stream().map(row -> ((Number) row.get("appid")).longValue()).toList();
}
@Test
void deletedUsers_LeaveEveryNumberAndTheTopLists() {
steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now()));
userGameRepository.saveAllAndFlush(List.of(new UserGame(3L, 620L, 900, Instant.now(), Instant.now()),
new UserGame(3L, 220L, 0, Instant.now(), Instant.now())));
jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)");
hiddenGames_LeaveEveryNumber();
hiddenGames_LeaveTheTopLists();
}
}