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