From 351cda411d24e696910e895dd2a5877a57efbd91 Mon Sep 17 00:00:00 2001 From: lobo Date: Sun, 27 Sep 2026 11:46:22 +0200 Subject: [PATCH] feat(account): hide deleted accounts and clean up their rows 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. --- readme.md | 20 ++- .../configuration/security/AccessTokens.java | 2 +- .../configuration/security/JwtService.java | 13 ++ .../security/SecurityConfig.java | 24 +++- .../security/ServiceTokenFilter.java | 39 ++++++ .../api/InternalAccountController.java | 50 ++++++++ .../account/api/dto/CleanupResultDto.java | 7 ++ .../account/api/dto/KnownUsersRequest.java | 12 ++ .../modules/account/api/dto/PurgeRequest.java | 11 ++ .../account/api/dto/UserIdsRequest.java | 8 ++ .../modules/account/domain/CleanupResult.java | 12 ++ .../repo/AccountCleanupRepository.java | 102 +++++++++++++++ .../service/AccountCleanupService.java | 91 ++++++++++++++ .../repo/BacklogSnapshotRepository.java | 4 +- .../library/repo/UserSteamSyncRepository.java | 5 +- .../library/service/LibrarySyncWorker.java | 2 +- .../modules/stats/repo/StatsRepository.java | 23 ++-- src/main/resources/application.yml | 5 + .../db/migration/V19__deleted_user.sql | 6 + .../security/JwtServiceTest.java | 30 +++++ .../security/ServiceTokenFilterTest.java | 53 ++++++++ .../api/InternalAccountControllerTest.java | 112 +++++++++++++++++ .../api/InternalAccountDevProfileTest.java | 27 ++++ .../repo/AccountCleanupRepositoryTest.java | 116 ++++++++++++++++++ .../service/AccountCleanupServiceTest.java | 110 +++++++++++++++++ .../repo/BacklogSnapshotRepositoryTest.java | 11 ++ .../repo/UserSteamSyncRepositoryTest.java | 47 +++++++ .../stats/repo/StatsRepositoryTest.java | 15 +++ 28 files changed, 941 insertions(+), 16 deletions(-) create mode 100644 src/main/java/core_service/configuration/security/ServiceTokenFilter.java create mode 100644 src/main/java/core_service/modules/account/api/InternalAccountController.java create mode 100644 src/main/java/core_service/modules/account/api/dto/CleanupResultDto.java create mode 100644 src/main/java/core_service/modules/account/api/dto/KnownUsersRequest.java create mode 100644 src/main/java/core_service/modules/account/api/dto/PurgeRequest.java create mode 100644 src/main/java/core_service/modules/account/api/dto/UserIdsRequest.java create mode 100644 src/main/java/core_service/modules/account/domain/CleanupResult.java create mode 100644 src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java create mode 100644 src/main/java/core_service/modules/account/service/AccountCleanupService.java create mode 100644 src/main/resources/db/migration/V19__deleted_user.sql create mode 100644 src/test/java/core_service/configuration/security/ServiceTokenFilterTest.java create mode 100644 src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java create mode 100644 src/test/java/core_service/modules/account/api/InternalAccountDevProfileTest.java create mode 100644 src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java create mode 100644 src/test/java/core_service/modules/account/service/AccountCleanupServiceTest.java create mode 100644 src/test/java/core_service/modules/library/repo/UserSteamSyncRepositoryTest.java diff --git a/readme.md b/readme.md index 0a11d02..54117c7 100644 --- a/readme.md +++ b/readme.md @@ -75,6 +75,9 @@ until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load wor frontend origin. A token with a `typ` claim (for example a profile ticket) is rejected on normal endpoints; the profile ticket is accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` header. +`/internal/**` has its own filter chain: it accepts only a service token of Auth (`typ=service`, with `exp`, sent +as `Bearer`), never a login, also not in the dev profile. Traefik exposes the path, so the signed token is its +only guard. ## API @@ -93,6 +96,8 @@ accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` he | `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT | | `GET` | `/api/public/profiles/library` | profile ticket | | `GET` | `/api/stats/public` | public | +| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token | +| `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token | | `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public | ## Tech stack @@ -120,6 +125,7 @@ flowchart LR end Stats["stats"] Version["version"] + Account["account"] Steam["steam_adapter"] Igdb["igdb_adapter"] Speedrun["speedrun_adapter"] @@ -136,6 +142,8 @@ flowchart LR Speedrun --> SpeedrunApi["speedrun.com API"] LibRepo --> Db[("PostgreSQL tbp_core")] Stats --> Db + Auth -->|"service token
/internal/accounts"| Account + Account --> Db ``` | Module | Content | @@ -143,6 +151,7 @@ flowchart LR | `library` | Sync, backlog, manual games, playtime history, wishlist, achievements, public profile data | | `stats` | Public numbers for the landing page | | `version` | `GET /api/version`: commit and build time of the running image | +| `account` | Deleted accounts, driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows | | `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures | | `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search | | `speedrun_adapter` | speedrun.com client: game search by name, verified runs | @@ -169,7 +178,8 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` Afterwards it writes every user's pile size of the day to `backlog_snapshot`, also for users without Steam, and dates finished games by the unlock of their ending achievement (`FinishedEstimateService`, `user_game.finished_at_estimate`). -3. `LibrarySyncWorker` runs in the background. `user_steam_sync` lets only one run per user start. +3. `LibrarySyncWorker` runs in the background. `user_steam_sync` lets only one run per user start, and none for + a user in `deleted_user`. 4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer owns. The growth counts at most Steam's playtime of the last two weeks, so a game seen for the first time brings @@ -186,6 +196,14 @@ The frontend polls `GET /api/library/sync-status` until the run ends. At 01:30 `ImageRepairService` repairs the pictures reported through `POST /api/library/broken-images` (table `broken_image`, one row per game and kind). Reports only count for Steam games the user owns. +### Deleted accounts + +Auth drives it (see Auth's readme). `POST /internal/accounts/deleted` adds a tombstone in `deleted_user`: the user +gets no more syncs and leaves the landing stats and the pile snapshots at once. Auth's nightly job sends the whole +flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned ones), then `prune` with all its +user ids, which removes the rows of users Auth does not know (refused for more than +`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows. + ### Manual games next to Steam games A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games and manual games share diff --git a/src/main/java/core_service/configuration/security/AccessTokens.java b/src/main/java/core_service/configuration/security/AccessTokens.java index 1cc8b9a..16207da 100644 --- a/src/main/java/core_service/configuration/security/AccessTokens.java +++ b/src/main/java/core_service/configuration/security/AccessTokens.java @@ -34,7 +34,7 @@ final class AccessTokens { .findFirst(); } - private static Optional bearer(HttpServletRequest request) { + static Optional bearer(HttpServletRequest request) { String header = request.getHeader("Authorization"); return header != null && header.startsWith(BEARER) ? Optional.of(header.substring(BEARER.length())) : Optional.empty(); } diff --git a/src/main/java/core_service/configuration/security/JwtService.java b/src/main/java/core_service/configuration/security/JwtService.java index 2e35a7b..89d9875 100644 --- a/src/main/java/core_service/configuration/security/JwtService.java +++ b/src/main/java/core_service/configuration/security/JwtService.java @@ -22,6 +22,7 @@ public class JwtService implements ProfileTicketVerifier { // Only non-login tokens carry this claim; parseLoginToken rejects them. private static final String TYPE_CLAIM = "typ"; private static final String PROFILE_TICKET_TYPE = "public-profile"; + private static final String SERVICE_TOKEN_TYPE = "service"; private final JwtParser parser; @@ -50,6 +51,18 @@ public class JwtService implements ProfileTicketVerifier { } } + /** + * A token Auth signs for its calls to /internal. jjwt accepts a token without exp, so exp is required here. + */ + public boolean isServiceToken(String token) { + try { + Claims claims = claims(token); + return SERVICE_TOKEN_TYPE.equals(claims.get(TYPE_CLAIM, String.class)) && claims.getExpiration() != null; + } catch (Exception e) { + return false; + } + } + @Override public Optional verifyProfileTicket(String ticket) { try { diff --git a/src/main/java/core_service/configuration/security/SecurityConfig.java b/src/main/java/core_service/configuration/security/SecurityConfig.java index 95d8989..e8c5596 100644 --- a/src/main/java/core_service/configuration/security/SecurityConfig.java +++ b/src/main/java/core_service/configuration/security/SecurityConfig.java @@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -37,9 +38,11 @@ public class SecurityConfig { }; private final OncePerRequestFilter jwtAuthFilter; + private final JwtService jwtService; - public SecurityConfig(@Qualifier("jwtAuthenticationFilter") OncePerRequestFilter jwtAuthFilter) { + public SecurityConfig(@Qualifier("jwtAuthenticationFilter") OncePerRequestFilter jwtAuthFilter, JwtService jwtService) { this.jwtAuthFilter = jwtAuthFilter; + this.jwtService = jwtService; } @Value("${app.cors.allowed-origins}") @@ -61,7 +64,26 @@ public class SecurityConfig { return source; } + /** + * Calls of Auth only (service token), never a login. The path is public through Traefik, so the token is the + * only guard. + */ @Bean + @Order(1) + public SecurityFilterChain internalFilterChain(HttpSecurity http) { + http + .securityMatcher("/internal/**") + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) + .authorizeHttpRequests(auth -> auth.anyRequest().hasAuthority(ServiceTokenFilter.SERVICE_AUTHORITY)) + .addFilterBefore(new ServiceTokenFilter(jwtService), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + @Order(2) public SecurityFilterChain securityFilterChain(HttpSecurity http) { http .csrf(AbstractHttpConfigurer::disable) diff --git a/src/main/java/core_service/configuration/security/ServiceTokenFilter.java b/src/main/java/core_service/configuration/security/ServiceTokenFilter.java new file mode 100644 index 0000000..5382a5e --- /dev/null +++ b/src/main/java/core_service/configuration/security/ServiceTokenFilter.java @@ -0,0 +1,39 @@ +package core_service.configuration.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +/** + * Authenticates Auth's calls to /internal by a service token in the Bearer header. Not a bean on purpose: as a + * bean, Spring Boot would also run it for every other path. + */ +class ServiceTokenFilter extends OncePerRequestFilter { + + static final String SERVICE_AUTHORITY = "SERVICE"; + + private final JwtService jwtService; + + ServiceTokenFilter(JwtService jwtService) { + this.jwtService = jwtService; + } + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + if (AccessTokens.bearer(request).filter(jwtService::isServiceToken).isPresent()) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + "tbp-auth", null, List.of(new SimpleGrantedAuthority(SERVICE_AUTHORITY)))); + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/core_service/modules/account/api/InternalAccountController.java b/src/main/java/core_service/modules/account/api/InternalAccountController.java new file mode 100644 index 0000000..aca473a --- /dev/null +++ b/src/main/java/core_service/modules/account/api/InternalAccountController.java @@ -0,0 +1,50 @@ +package core_service.modules.account.api; + +import core_service.modules.account.api.dto.CleanupResultDto; +import core_service.modules.account.api.dto.KnownUsersRequest; +import core_service.modules.account.api.dto.PurgeRequest; +import core_service.modules.account.api.dto.UserIdsRequest; +import core_service.modules.account.domain.CleanupResult; +import core_service.modules.account.service.AccountCleanupService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/internal/accounts") +@RequiredArgsConstructor +public class InternalAccountController { + + private final AccountCleanupService accountCleanupService; + + @PostMapping("/deleted") + public ResponseEntity markDeleted(@Valid @RequestBody UserIdsRequest request) { + accountCleanupService.markDeleted(request.userIds()); + return ResponseEntity.noContent().build(); + } + + @PutMapping("/deleted") + public ResponseEntity replaceDeleted(@Valid @RequestBody UserIdsRequest request) { + accountCleanupService.replaceDeleted(request.userIds()); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/purge") + public ResponseEntity purge(@Valid @RequestBody PurgeRequest request) { + return ResponseEntity.ok(toDto(accountCleanupService.purge(request.userIds(), request.dryRun()))); + } + + @PostMapping("/prune") + public ResponseEntity prune(@Valid @RequestBody KnownUsersRequest request) { + return ResponseEntity.ok(toDto(accountCleanupService.prune(request.knownUserIds(), request.dryRun()))); + } + + private static CleanupResultDto toDto(CleanupResult result) { + return new CleanupResultDto(result.dryRun(), result.users(), result.rows(), result.refused()); + } +} diff --git a/src/main/java/core_service/modules/account/api/dto/CleanupResultDto.java b/src/main/java/core_service/modules/account/api/dto/CleanupResultDto.java new file mode 100644 index 0000000..ec164e8 --- /dev/null +++ b/src/main/java/core_service/modules/account/api/dto/CleanupResultDto.java @@ -0,0 +1,7 @@ +package core_service.modules.account.api.dto; + +import java.util.List; +import java.util.Map; + +public record CleanupResultDto(boolean dryRun, List users, Map rows, boolean refused) { +} diff --git a/src/main/java/core_service/modules/account/api/dto/KnownUsersRequest.java b/src/main/java/core_service/modules/account/api/dto/KnownUsersRequest.java new file mode 100644 index 0000000..0464ba7 --- /dev/null +++ b/src/main/java/core_service/modules/account/api/dto/KnownUsersRequest.java @@ -0,0 +1,12 @@ +package core_service.modules.account.api.dto; + +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + +/** + * @param knownUserIds every user Auth has, also the ones flagged as deleted + * @param dryRun see PurgeRequest + */ +public record KnownUsersRequest(@NotEmpty List knownUserIds, boolean dryRun) { +} diff --git a/src/main/java/core_service/modules/account/api/dto/PurgeRequest.java b/src/main/java/core_service/modules/account/api/dto/PurgeRequest.java new file mode 100644 index 0000000..147daee --- /dev/null +++ b/src/main/java/core_service/modules/account/api/dto/PurgeRequest.java @@ -0,0 +1,11 @@ +package core_service.modules.account.api.dto; + +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +/** + * @param dryRun Auth's dry run; Core also counts only when its own dry run is on + */ +public record PurgeRequest(@NotNull List userIds, boolean dryRun) { +} diff --git a/src/main/java/core_service/modules/account/api/dto/UserIdsRequest.java b/src/main/java/core_service/modules/account/api/dto/UserIdsRequest.java new file mode 100644 index 0000000..819d889 --- /dev/null +++ b/src/main/java/core_service/modules/account/api/dto/UserIdsRequest.java @@ -0,0 +1,8 @@ +package core_service.modules.account.api.dto; + +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record UserIdsRequest(@NotNull List userIds) { +} diff --git a/src/main/java/core_service/modules/account/domain/CleanupResult.java b/src/main/java/core_service/modules/account/domain/CleanupResult.java new file mode 100644 index 0000000..0db92cf --- /dev/null +++ b/src/main/java/core_service/modules/account/domain/CleanupResult.java @@ -0,0 +1,12 @@ +package core_service.modules.account.domain; + +import java.util.List; +import java.util.Map; + +/** + * @param users the users the run touched, or would touch in a dry run + * @param rows deleted rows per table, or the rows a dry run would delete + * @param refused true when a prune found more orphans than allowed and did nothing + */ +public record CleanupResult(boolean dryRun, List users, Map rows, boolean refused) { +} diff --git a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java new file mode 100644 index 0000000..fd2b1eb --- /dev/null +++ b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java @@ -0,0 +1,102 @@ +package core_service.modules.account.repo; + +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Tombstones in deleted_user and the per-user rows of the library tables. Plain SQL, because the library module + * owns the entities. + */ +@Repository +@RequiredArgsConstructor +public class AccountCleanupRepository { + + // Children before user_game (user_game_status_change would also go by its cascade), the tombstones last. + private static final List 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", "deleted_user"); + + // Every user with data has a row in one of these; deleteRows then removes the child rows too. + private static final List ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game", + "deleted_user"); + + private final NamedParameterJdbcTemplate jdbc; + + private static MapSqlParameterSource ids(Collection userIds) { + return new MapSqlParameterSource("ids", userIds); + } + + /** + * Also takes the users off the nightly sync list. + */ + public void addDeleted(Collection userIds) { + if (userIds.isEmpty()) { + return; + } + jdbc.batchUpdate("insert into deleted_user (user_id) values (:id) on conflict do nothing", + userIds.stream().map(id -> new MapSqlParameterSource("id", id)).toArray(MapSqlParameterSource[]::new)); + jdbc.update("update user_steam_sync set user_seen_at = null where user_id in (:ids)", ids(userIds)); + } + + /** + * After this, exactly the given users are tombstoned. + */ + public void replaceDeleted(Collection userIds) { + addDeleted(userIds); + if (userIds.isEmpty()) { + jdbc.update("delete from deleted_user", new MapSqlParameterSource()); + } else { + jdbc.update("delete from deleted_user where user_id not in (:ids)", ids(userIds)); + } + } + + public List findDeleted(Collection userIds) { + if (userIds.isEmpty()) { + return List.of(); + } + return jdbc.queryForList("select user_id from deleted_user where user_id in (:ids) order by user_id", + ids(userIds), Long.class); + } + + /** + * @param userIds not empty + */ + public Map countRows(Collection userIds) { + Map counts = new LinkedHashMap<>(); + for (String table : TABLES) { + Integer count = jdbc.queryForObject("select count(*) from " + table + " where user_id in (:ids)", + ids(userIds), Integer.class); + counts.put(table, count != null ? count : 0); + } + return counts; + } + + /** + * @param userIds not empty + */ + public Map deleteRows(Collection userIds) { + Map counts = new LinkedHashMap<>(); + for (String table : TABLES) { + counts.put(table, jdbc.update("delete from " + table + " where user_id in (:ids)", ids(userIds))); + } + return counts; + } + + /** + * Users that have rows in Core but are not in the list. + * + * @param knownUserIds not empty + */ + public List findOrphans(Collection knownUserIds) { + String union = String.join(" union ", ORPHAN_SOURCES.stream().map(table -> "select user_id from " + table).toList()); + return jdbc.queryForList("select user_id from (" + union + ") u where user_id not in (:ids) order by user_id", + ids(knownUserIds), Long.class); + } +} diff --git a/src/main/java/core_service/modules/account/service/AccountCleanupService.java b/src/main/java/core_service/modules/account/service/AccountCleanupService.java new file mode 100644 index 0000000..558c125 --- /dev/null +++ b/src/main/java/core_service/modules/account/service/AccountCleanupService.java @@ -0,0 +1,91 @@ +package core_service.modules.account.service; + +import core_service.modules.account.domain.CleanupResult; +import core_service.modules.account.repo.AccountCleanupRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; + +/** + * Deleted accounts, driven by Auth: a tombstone hides the user at once, the purge after 30 days removes the rows. + * Auth decides who is deleted and when; Core only checks that a purged user is tombstoned. + */ +@Service +@Slf4j +public class AccountCleanupService { + + private final AccountCleanupRepository repository; + private final boolean dryRun; + private final int maxOrphans; + + public AccountCleanupService(AccountCleanupRepository repository, + @Value("${app.account-cleanup.dry-run}") boolean dryRun, + @Value("${app.account-cleanup.max-orphans}") int maxOrphans) { + this.repository = repository; + this.dryRun = dryRun; + this.maxOrphans = maxOrphans; + } + + @Transactional + @CacheEvict(value = "publicStats", allEntries = true) + public void markDeleted(List userIds) { + repository.addDeleted(userIds); + log.info("Account cleanup: marked users {} as deleted", userIds); + } + + /** + * The whole flagged set of Auth. Also lifts the tombstone of a user whose delete was undone in Auth. + */ + @Transactional + @CacheEvict(value = "publicStats", allEntries = true) + public void replaceDeleted(List userIds) { + repository.replaceDeleted(userIds); + log.info("Account cleanup: {} users marked as deleted", userIds.size()); + } + + /** + * @param requestDryRun Auth's dry run; either side can ask for one + */ + @Transactional + @CacheEvict(value = "publicStats", allEntries = true) + public CleanupResult purge(List userIds, boolean requestDryRun) { + List tombstoned = repository.findDeleted(userIds); + if (tombstoned.size() < userIds.size()) { + log.warn("Account cleanup: purge skips users without a tombstone: {}", + userIds.stream().filter(id -> !tombstoned.contains(id)).toList()); + } + return removeRows("purge", tombstoned, dryRun || requestDryRun); + } + + /** + * Removes the rows of users Auth does not know. Refuses an empty list, and more orphans than allowed, so a + * broken call can never wipe the library tables. + */ + @Transactional + @CacheEvict(value = "publicStats", allEntries = true) + public CleanupResult prune(List knownUserIds, boolean requestDryRun) { + if (knownUserIds.isEmpty()) { + throw new IllegalArgumentException("knownUserIds must not be empty"); + } + List orphans = repository.findOrphans(knownUserIds); + if (orphans.size() > maxOrphans) { + log.warn("Account cleanup: prune refused, {} orphans (max {}): {}", orphans.size(), maxOrphans, orphans); + return new CleanupResult(dryRun || requestDryRun, orphans, Map.of(), true); + } + return removeRows("prune", orphans, dryRun || requestDryRun); + } + + private CleanupResult removeRows(String run, List userIds, boolean countOnly) { + if (userIds.isEmpty()) { + return new CleanupResult(countOnly, List.of(), Map.of(), false); + } + Map rows = countOnly ? repository.countRows(userIds) : repository.deleteRows(userIds); + log.info("Account cleanup: {} {} users {}, rows {}", run, countOnly ? "would delete" : "deleted", userIds, rows); + return new CleanupResult(countOnly, userIds, rows, false); + } +} diff --git a/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java b/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java index 51901d1..bcb9f5b 100644 --- a/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java +++ b/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java @@ -36,7 +36,9 @@ public class BacklogSnapshotRepository { public int writeAll(LocalDate day) { return jdbc.update(""" insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, listed_games) - select ug.user_id, :day, %s group by ug.user_id + select ug.user_id, :day, %s + and ug.user_id not in (select user_id from deleted_user) + group by ug.user_id on conflict (user_id, day) do update set backlog_games = excluded.backlog_games, never_played = excluded.never_played, backlog_minutes = excluded.backlog_minutes, listed_games = excluded.listed_games diff --git a/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java b/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java index d9524d0..ee1a600 100644 --- a/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java @@ -15,12 +15,13 @@ public interface UserSteamSyncRepository extends JpaRepository 0", Long.class); + "SELECT COUNT(*) FROM user_game WHERE " + LISTED + " AND playtime_minutes > 0", Long.class); return v != null ? v : 0L; } public long sumPlaytimeMinutes() { Long v = jdbc.queryForObject( - "SELECT COALESCE(SUM(playtime_minutes), 0) FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL", Long.class); + "SELECT COALESCE(SUM(playtime_minutes), 0) FROM user_game WHERE " + LISTED, Long.class); return v != null ? v : 0L; } public long countUsersWithLibrary() { Long v = jdbc.queryForObject( - "SELECT COUNT(DISTINCT user_id) FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL", Long.class); + "SELECT COUNT(DISTINCT user_id) FROM user_game WHERE " + LISTED, Long.class); return v != null ? v : 0L; } @@ -51,9 +54,9 @@ public class StatsRepository { Long v = jdbc.queryForObject(""" SELECT COALESCE(MAX(c), 0) FROM ( SELECT COUNT(*) AS c FROM user_game - WHERE removed_at IS NULL AND hidden_at IS NULL GROUP BY user_id + WHERE %s GROUP BY user_id ) t - """, Long.class); + """.formatted(LISTED), Long.class); return v != null ? v : 0L; } @@ -61,9 +64,9 @@ public class StatsRepository { Long v = jdbc.queryForObject(""" SELECT COALESCE(MAX(c), 0) FROM ( SELECT COUNT(*) AS c FROM user_game - WHERE removed_at IS NULL AND hidden_at IS NULL AND playtime_minutes = 0 GROUP BY user_id + WHERE %s AND playtime_minutes = 0 GROUP BY user_id ) t - """, Long.class); + """.formatted(LISTED), Long.class); return v != null ? v : 0L; } @@ -73,6 +76,7 @@ public class StatsRepository { FROM user_game ug LEFT JOIN steam_game sg ON sg.appid = ug.appid WHERE ug.removed_at IS NULL AND ug.hidden_at IS NULL + AND ug.user_id NOT IN (SELECT user_id FROM deleted_user) GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url ORDER BY value DESC LIMIT ? @@ -85,6 +89,7 @@ public class StatsRepository { FROM user_game ug LEFT JOIN steam_game sg ON sg.appid = ug.appid WHERE ug.removed_at IS NULL AND ug.hidden_at IS NULL + AND ug.user_id NOT IN (SELECT user_id FROM deleted_user) GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url ORDER BY value DESC LIMIT ? diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6bfcb48..f2fcddd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -70,6 +70,11 @@ app: image-repair-batch: 200 image-repair-max-attempts: 3 image-repair-retry-days: 30 + account-cleanup: + # Log only, delete nothing. Auth's nightly cleanup drives this; see the Deployment README before flipping it. + dry-run: ${APP_ACCOUNT_CLEANUP_DRY_RUN:true} + # A prune refuses more orphans than this per run; raise it once for a big cleanup. + max-orphans: 5 resilience4j: retry: diff --git a/src/main/resources/db/migration/V19__deleted_user.sql b/src/main/resources/db/migration/V19__deleted_user.sql new file mode 100644 index 0000000..8199423 --- /dev/null +++ b/src/main/resources/db/migration/V19__deleted_user.sql @@ -0,0 +1,6 @@ +-- Users Auth has flagged as deleted. Core hides them at once: no sync, not in the landing stats or the pile +-- snapshots. The nightly cleanup of Auth removes the row together with all rows of the user. +CREATE TABLE deleted_user ( + user_id BIGINT PRIMARY KEY, + deleted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/src/test/java/core_service/configuration/security/JwtServiceTest.java b/src/test/java/core_service/configuration/security/JwtServiceTest.java index 8f34d2d..e7455f5 100644 --- a/src/test/java/core_service/configuration/security/JwtServiceTest.java +++ b/src/test/java/core_service/configuration/security/JwtServiceTest.java @@ -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))); + } } diff --git a/src/test/java/core_service/configuration/security/ServiceTokenFilterTest.java b/src/test/java/core_service/configuration/security/ServiceTokenFilterTest.java new file mode 100644 index 0000000..5e54a97 --- /dev/null +++ b/src/test/java/core_service/configuration/security/ServiceTokenFilterTest.java @@ -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(); + } +} diff --git a/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java b/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java new file mode 100644 index 0000000..0fac1b0 --- /dev/null +++ b/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java @@ -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()); + } +} diff --git a/src/test/java/core_service/modules/account/api/InternalAccountDevProfileTest.java b/src/test/java/core_service/modules/account/api/InternalAccountDevProfileTest.java new file mode 100644 index 0000000..00f2e5c --- /dev/null +++ b/src/test/java/core_service/modules/account/api/InternalAccountDevProfileTest.java @@ -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()); + } +} diff --git a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java new file mode 100644 index 0000000..4bf788f --- /dev/null +++ b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java @@ -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 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 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 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 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(); + } +} diff --git a/src/test/java/core_service/modules/account/service/AccountCleanupServiceTest.java b/src/test/java/core_service/modules/account/service/AccountCleanupServiceTest.java new file mode 100644 index 0000000..8689f75 --- /dev/null +++ b/src/test/java/core_service/modules/account/service/AccountCleanupServiceTest.java @@ -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 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()); + } +} diff --git a/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java b/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java index bc58d6a..3ddb5a7 100644 --- a/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java @@ -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(); + } } diff --git a/src/test/java/core_service/modules/library/repo/UserSteamSyncRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserSteamSyncRepositoryTest.java new file mode 100644 index 0000000..5c38227 --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/UserSteamSyncRepositoryTest.java @@ -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(); + } +} diff --git a/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java b/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java index d7024a9..10083d2 100644 --- a/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java +++ b/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java @@ -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 appids(List> 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(); + } }