Public Access
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.
This commit is contained in:
1 parent
88aae0101f
commit
351cda411d
28 files changed
+941
-16
No files matched your search
@@ -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<br/>/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
|
||||
|
||||
@@ -34,7 +34,7 @@ final class AccessTokens {
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
private static Optional<String> bearer(HttpServletRequest request) {
|
||||
static Optional<String> bearer(HttpServletRequest request) {
|
||||
String header = request.getHeader("Authorization");
|
||||
return header != null && header.startsWith(BEARER) ? Optional.of(header.substring(BEARER.length())) : Optional.empty();
|
||||
}
|
||||
|
||||
@@ -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<ProfileTicket> verifyProfileTicket(String ticket) {
|
||||
try {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<Void> markDeleted(@Valid @RequestBody UserIdsRequest request) {
|
||||
accountCleanupService.markDeleted(request.userIds());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping("/deleted")
|
||||
public ResponseEntity<Void> replaceDeleted(@Valid @RequestBody UserIdsRequest request) {
|
||||
accountCleanupService.replaceDeleted(request.userIds());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/purge")
|
||||
public ResponseEntity<CleanupResultDto> purge(@Valid @RequestBody PurgeRequest request) {
|
||||
return ResponseEntity.ok(toDto(accountCleanupService.purge(request.userIds(), request.dryRun())));
|
||||
}
|
||||
|
||||
@PostMapping("/prune")
|
||||
public ResponseEntity<CleanupResultDto> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<Long> users, Map<String, Integer> rows, boolean refused) {
|
||||
}
|
||||
@@ -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<Long> knownUserIds, boolean dryRun) {
|
||||
}
|
||||
@@ -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<Long> userIds, boolean dryRun) {
|
||||
}
|
||||
@@ -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<Long> userIds) {
|
||||
}
|
||||
@@ -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<Long> users, Map<String, Integer> rows, boolean refused) {
|
||||
}
|
||||
@@ -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<String> 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<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
|
||||
"deleted_user");
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
|
||||
private static MapSqlParameterSource ids(Collection<Long> userIds) {
|
||||
return new MapSqlParameterSource("ids", userIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Also takes the users off the nightly sync list.
|
||||
*/
|
||||
public void addDeleted(Collection<Long> 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<Long> 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<Long> findDeleted(Collection<Long> 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<String, Integer> countRows(Collection<Long> userIds) {
|
||||
Map<String, Integer> 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<String, Integer> deleteRows(Collection<Long> userIds) {
|
||||
Map<String, Integer> 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<Long> findOrphans(Collection<Long> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<Long> 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<Long> 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<Long> userIds, boolean requestDryRun) {
|
||||
List<Long> 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<Long> knownUserIds, boolean requestDryRun) {
|
||||
if (knownUserIds.isEmpty()) {
|
||||
throw new IllegalArgumentException("knownUserIds must not be empty");
|
||||
}
|
||||
List<Long> 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<Long> userIds, boolean countOnly) {
|
||||
if (userIds.isEmpty()) {
|
||||
return new CleanupResult(countOnly, List.of(), Map.of(), false);
|
||||
}
|
||||
Map<String, Integer> 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -15,12 +15,13 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
|
||||
/**
|
||||
* Atomic claim-run upsert. Returns 1 if this caller successfully claimed the run
|
||||
* (row created, or existing row was not RUNNING / RUNNING marker stale).
|
||||
* Returns 0 if another sync is already in flight — caller must skip.
|
||||
* Returns 0 if another sync is already in flight, or the user is deleted (table deleted_user) — caller must skip.
|
||||
*/
|
||||
@Modifying
|
||||
@Query(value = """
|
||||
INSERT INTO user_steam_sync (user_id, steam_id, last_sync_status)
|
||||
VALUES (:userId, :steamId, 'RUNNING')
|
||||
SELECT :userId, :steamId, 'RUNNING'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM deleted_user WHERE user_id = :userId)
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET steam_id = EXCLUDED.steam_id,
|
||||
last_sync_status = 'RUNNING'
|
||||
|
||||
@@ -83,7 +83,7 @@ public class LibrarySyncWorker {
|
||||
String historySteamId = before != null ? before.getHistorySteamId() : null;
|
||||
int claimed = userSteamSyncRepository.tryClaimRun(userId, steamId, runningStaleBefore);
|
||||
if (claimed == 0) {
|
||||
log.info("Sync skipped for user {} — another run already in flight", userId);
|
||||
log.info("Sync skipped for user {} — another run already in flight, or the account is deleted", userId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ import java.util.Map;
|
||||
@Repository
|
||||
public class StatsRepository {
|
||||
|
||||
private static final String LISTED =
|
||||
"removed_at IS NULL AND hidden_at IS NULL AND user_id NOT IN (SELECT user_id FROM deleted_user)";
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
@Autowired
|
||||
@@ -19,31 +22,31 @@ public class StatsRepository {
|
||||
|
||||
public long countDistinctGames() {
|
||||
Long v = jdbc.queryForObject(
|
||||
"SELECT COUNT(DISTINCT appid) FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL", Long.class);
|
||||
"SELECT COUNT(DISTINCT appid) FROM user_game WHERE " + LISTED, Long.class);
|
||||
return v != null ? v : 0L;
|
||||
}
|
||||
|
||||
public long countLibraryEntries() {
|
||||
Long v = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL", Long.class);
|
||||
"SELECT COUNT(*) FROM user_game WHERE " + LISTED, Long.class);
|
||||
return v != null ? v : 0L;
|
||||
}
|
||||
|
||||
public long countGamesPlayed() {
|
||||
Long v = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL AND playtime_minutes > 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 ?
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
@@ -136,4 +136,34 @@ class JwtServiceTest extends AbstractUnitTest {
|
||||
void parseLoginToken_ProfileTicketIsNoLogin() {
|
||||
assertEquals(Optional.empty(), jwtService.parseLoginToken(signTicket("public-profile", "42", 60_000)));
|
||||
}
|
||||
|
||||
private String signServiceToken(PrivateKey key, String type, Long expiresInMs) {
|
||||
io.jsonwebtoken.JwtBuilder builder = Jwts.builder().subject("tbp-auth").claim("typ", type).issuedAt(new Date());
|
||||
if (expiresInMs != null) {
|
||||
builder.expiration(new Date(System.currentTimeMillis() + expiresInMs));
|
||||
}
|
||||
return builder.signWith(key, Jwts.SIG.EdDSA).compact();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isServiceToken_AcceptsAServiceTokenOfAuth() {
|
||||
assertTrue(jwtService.isServiceToken(signServiceToken(signingKey, "service", 60_000L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isServiceToken_RejectsEverythingElse() throws Exception {
|
||||
PrivateKey otherKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPrivate();
|
||||
|
||||
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, "service", null)), "no exp");
|
||||
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, "service", -1_000L)), "expired");
|
||||
assertFalse(jwtService.isServiceToken(signServiceToken(otherKey, "service", 60_000L)), "foreign key");
|
||||
assertFalse(jwtService.isServiceToken(signToken("42", "a@b.de", null)), "login token");
|
||||
assertFalse(jwtService.isServiceToken(signTicket("public-profile", "42", 60_000)), "profile ticket");
|
||||
assertFalse(jwtService.isServiceToken("garbage"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseLoginToken_ServiceTokenIsNoLogin() {
|
||||
assertEquals(Optional.empty(), jwtService.parseLoginToken(signServiceToken(signingKey, "service", 60_000L)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package core_service.configuration.security;
|
||||
|
||||
import core_service.shared.test.AbstractUnitTest;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.mock.web.MockFilterChain;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
|
||||
class ServiceTokenFilterTest extends AbstractUnitTest {
|
||||
|
||||
@Mock
|
||||
private JwtService jwtService;
|
||||
|
||||
@AfterEach
|
||||
void clearContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
private Authentication authenticationAfter(MockHttpServletRequest request) throws Exception {
|
||||
lenient().when(jwtService.isServiceToken("good")).thenReturn(true);
|
||||
new ServiceTokenFilter(jwtService).doFilter(request, new MockHttpServletResponse(), new MockFilterChain());
|
||||
return SecurityContextHolder.getContext().getAuthentication();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bearerServiceTokenGivesTheServiceAuthority() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader("Authorization", "Bearer good");
|
||||
|
||||
assertThat(authenticationAfter(request).getAuthorities()).extracting(GrantedAuthority::getAuthority)
|
||||
.containsExactly(ServiceTokenFilter.SERVICE_AUTHORITY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cookieAndInvalidTokensAuthenticateNothing() throws Exception {
|
||||
MockHttpServletRequest cookie = new MockHttpServletRequest();
|
||||
cookie.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "good"));
|
||||
assertThat(authenticationAfter(cookie)).isNull();
|
||||
|
||||
MockHttpServletRequest invalid = new MockHttpServletRequest();
|
||||
invalid.addHeader("Authorization", "Bearer bad");
|
||||
assertThat(authenticationAfter(invalid)).isNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package core_service.modules.account.api;
|
||||
|
||||
import core_service.shared.test.AbstractIntegrationTest;
|
||||
import io.jsonwebtoken.JwtBuilder;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.ResultActions;
|
||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* The real (non-dev) filter chain, like ExpiredAccessTokenTest, so both share one Spring context.
|
||||
*/
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles(value = "prod", inheritProfiles = false)
|
||||
class InternalAccountControllerTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final long USER_ID = 910_001L;
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@AfterEach
|
||||
void cleanUp() {
|
||||
jdbc.update("delete from deleted_user where user_id = ?", USER_ID);
|
||||
}
|
||||
|
||||
private static String token(JwtBuilder builder) {
|
||||
return builder.expiration(new Date(System.currentTimeMillis() + 60_000))
|
||||
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
|
||||
}
|
||||
|
||||
private static String serviceToken() {
|
||||
return token(Jwts.builder().subject("tbp-auth").claim("typ", "service"));
|
||||
}
|
||||
|
||||
private static String loginToken() {
|
||||
return token(Jwts.builder().subject("a@b.de").claim("userId", String.valueOf(USER_ID)));
|
||||
}
|
||||
|
||||
private ResultActions send(MockHttpServletRequestBuilder request, String body, String token) throws Exception {
|
||||
request.contentType(MediaType.APPLICATION_JSON).content(body);
|
||||
if (token != null) {
|
||||
request.header("Authorization", "Bearer " + token);
|
||||
}
|
||||
return mockMvc.perform(request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutAServiceToken_Is401() throws Exception {
|
||||
String body = "{\"userIds\":[" + USER_ID + "]}";
|
||||
|
||||
send(post("/internal/accounts/deleted"), body, null).andExpect(status().isUnauthorized());
|
||||
send(post("/internal/accounts/deleted"), body, loginToken()).andExpect(status().isUnauthorized());
|
||||
assertThat(jdbc.queryForList("select user_id from deleted_user where user_id = ?", Long.class, USER_ID)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceToken_IsNoLoginForTheApi() throws Exception {
|
||||
mockMvc.perform(get("/api/library/summary").header("Authorization", "Bearer " + serviceToken()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void markThenPurge_InDryRunCountsAndKeepsTheTombstone() throws Exception {
|
||||
String body = "{\"userIds\":[" + USER_ID + "]}";
|
||||
|
||||
send(post("/internal/accounts/deleted"), body, serviceToken()).andExpect(status().isNoContent());
|
||||
send(post("/internal/accounts/purge"), "{\"userIds\":[" + USER_ID + "],\"dryRun\":false}", serviceToken())
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.dryRun").value(true))
|
||||
.andExpect(jsonPath("$.users[0]").value(USER_ID))
|
||||
.andExpect(jsonPath("$.rows.user_game").value(0))
|
||||
.andExpect(jsonPath("$.refused").value(false));
|
||||
|
||||
assertThat(jdbc.queryForList("select user_id from deleted_user where user_id = ?", Long.class, USER_ID))
|
||||
.containsExactly(USER_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaceDeleted_RemovesTombstonesAuthNoLongerFlags() throws Exception {
|
||||
send(post("/internal/accounts/deleted"), "{\"userIds\":[" + USER_ID + "]}", serviceToken())
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
send(put("/internal/accounts/deleted"), "{\"userIds\":[]}", serviceToken()).andExpect(status().isNoContent());
|
||||
|
||||
assertThat(jdbc.queryForList("select user_id from deleted_user where user_id = ?", Long.class, USER_ID)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void prune_WithAnEmptyListIs400() throws Exception {
|
||||
send(post("/internal/accounts/prune"), "{\"knownUserIds\":[],\"dryRun\":false}", serviceToken()).andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package core_service.modules.account.api;
|
||||
|
||||
import core_service.shared.test.AbstractIntegrationTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* The dev profile authenticates every API request as user 1; /internal must still need a service token.
|
||||
*/
|
||||
@AutoConfigureMockMvc
|
||||
class InternalAccountDevProfileTest extends AbstractIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void withoutAServiceToken_Is401() throws Exception {
|
||||
mockMvc.perform(post("/internal/accounts/deleted").contentType(MediaType.APPLICATION_JSON).content("{\"userIds\":[1]}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package core_service.modules.account.repo;
|
||||
|
||||
import core_service.shared.test.AbstractPersistenceTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@Import(AccountCleanupRepository.class)
|
||||
class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
|
||||
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
|
||||
"user_steam_sync", "user_game");
|
||||
|
||||
@Autowired
|
||||
private AccountCleanupRepository repository;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbc.update("delete from backlog_snapshot");
|
||||
jdbc.update("insert into steam_game (appid, name) values (220, 'Half-Life 2') on conflict do nothing");
|
||||
for (long userId : List.of(1L, 2L)) {
|
||||
// The V14 trigger adds the user_game_status_change row.
|
||||
jdbc.update("insert into user_game (user_id, appid, status) values (?, 220, 'BACKLOG')", userId);
|
||||
jdbc.update("insert into user_game_achievement (user_id, appid, api_name) values (?, 220, 'ACH_1')", userId);
|
||||
jdbc.update("insert into user_game_achievement_meta (user_id, appid) values (?, 220)", userId);
|
||||
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, 220, 30)", userId);
|
||||
jdbc.update("insert into wishlist_game (user_id, appid) values (?, 620)", userId);
|
||||
jdbc.update("""
|
||||
insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, listed_games)
|
||||
values (?, current_date, 1, 1, 0, 1)""", userId);
|
||||
jdbc.update("""
|
||||
insert into user_steam_sync (user_id, steam_id, user_seen_at) values (?, '76561198000000001', now())""",
|
||||
userId);
|
||||
}
|
||||
}
|
||||
|
||||
private int rows(String table, long userId) {
|
||||
Integer count = jdbc.queryForObject("select count(*) from " + table + " where user_id = ?", Integer.class, userId);
|
||||
return count != null ? count : 0;
|
||||
}
|
||||
|
||||
private List<Long> deleted() {
|
||||
return jdbc.queryForList("select user_id from deleted_user order by user_id", Long.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addDeleted_IsIdempotentAndStopsTheNightlySync() {
|
||||
repository.addDeleted(List.of(1L));
|
||||
repository.addDeleted(List.of(1L));
|
||||
|
||||
assertThat(deleted()).containsExactly(1L);
|
||||
assertThat(jdbc.queryForObject("select user_seen_at from user_steam_sync where user_id = 1", Object.class)).isNull();
|
||||
assertThat(jdbc.queryForObject("select user_seen_at from user_steam_sync where user_id = 2", Object.class)).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaceDeleted_KeepsOnlyTheGivenUsers() {
|
||||
repository.addDeleted(List.of(1L, 2L));
|
||||
|
||||
repository.replaceDeleted(List.of(2L, 3L));
|
||||
assertThat(deleted()).containsExactly(2L, 3L);
|
||||
|
||||
repository.replaceDeleted(List.of());
|
||||
assertThat(deleted()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findDeleted_ReturnsOnlyTombstonedUsers() {
|
||||
repository.addDeleted(List.of(2L));
|
||||
|
||||
assertThat(repository.findDeleted(List.of(1L, 2L))).containsExactly(2L);
|
||||
assertThat(repository.findDeleted(List.of())).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void countRows_CountsPerTableAndDeletesNothing() {
|
||||
Map<String, Integer> counts = repository.countRows(List.of(1L));
|
||||
|
||||
assertThat(counts.keySet()).containsAll(USER_TABLES);
|
||||
USER_TABLES.forEach(table -> assertThat(counts.get(table)).as(table).isEqualTo(1));
|
||||
USER_TABLES.forEach(table -> assertThat(rows(table, 1L)).as(table).isEqualTo(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRows_RemovesEveryRowOfTheUserAndKeepsTheOthers() {
|
||||
repository.addDeleted(List.of(1L));
|
||||
|
||||
Map<String, Integer> deletedRows = repository.deleteRows(List.of(1L));
|
||||
|
||||
USER_TABLES.forEach(table -> assertThat(deletedRows.get(table)).as(table).isEqualTo(1));
|
||||
assertThat(deletedRows.get("deleted_user")).isEqualTo(1);
|
||||
USER_TABLES.forEach(table -> assertThat(rows(table, 1L)).as(table).isZero());
|
||||
USER_TABLES.forEach(table -> assertThat(rows(table, 2L)).as(table).isEqualTo(1));
|
||||
assertThat(deleted()).isEmpty();
|
||||
assertThat(jdbc.queryForObject("select count(*) from steam_game where appid = 220", Integer.class)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrphans_ReturnsUsersNotInTheList() {
|
||||
repository.addDeleted(List.of(7L));
|
||||
|
||||
assertThat(repository.findOrphans(List.of(2L))).containsExactly(1L, 7L);
|
||||
assertThat(repository.findOrphans(List.of(1L, 2L, 7L))).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package core_service.modules.account.service;
|
||||
|
||||
import core_service.modules.account.domain.CleanupResult;
|
||||
import core_service.modules.account.repo.AccountCleanupRepository;
|
||||
import core_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AccountCleanupServiceTest extends AbstractUnitTest {
|
||||
|
||||
private static final Map<String, Integer> ROWS = Map.of("user_game", 3);
|
||||
|
||||
@Mock
|
||||
private AccountCleanupRepository repository;
|
||||
|
||||
private AccountCleanupService service(boolean dryRun) {
|
||||
return new AccountCleanupService(repository, dryRun, 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void purge_OnlyTouchesTombstonedUsers() {
|
||||
when(repository.findDeleted(List.of(1L, 2L))).thenReturn(List.of(2L));
|
||||
when(repository.deleteRows(List.of(2L))).thenReturn(ROWS);
|
||||
|
||||
CleanupResult result = service(false).purge(List.of(1L, 2L), false);
|
||||
|
||||
assertThat(result).isEqualTo(new CleanupResult(false, List.of(2L), ROWS, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void purge_InDryRunOnlyCounts() {
|
||||
when(repository.findDeleted(List.of(2L))).thenReturn(List.of(2L));
|
||||
when(repository.countRows(List.of(2L))).thenReturn(ROWS);
|
||||
|
||||
CleanupResult result = service(true).purge(List.of(2L), false);
|
||||
|
||||
assertThat(result).isEqualTo(new CleanupResult(true, List.of(2L), ROWS, false));
|
||||
verify(repository, never()).deleteRows(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prune_RefusesAnEmptyList() {
|
||||
assertThatThrownBy(() -> service(false).prune(List.of(), false)).isInstanceOf(IllegalArgumentException.class);
|
||||
verify(repository, never()).findOrphans(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prune_RefusesMoreOrphansThanAllowed() {
|
||||
when(repository.findOrphans(List.of(9L))).thenReturn(List.of(1L, 2L, 3L));
|
||||
|
||||
CleanupResult result = service(false).prune(List.of(9L), false);
|
||||
|
||||
assertThat(result).isEqualTo(new CleanupResult(false, List.of(1L, 2L, 3L), Map.of(), true));
|
||||
verify(repository, never()).deleteRows(any());
|
||||
verify(repository, never()).countRows(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prune_DeletesTheOrphans() {
|
||||
when(repository.findOrphans(List.of(9L))).thenReturn(List.of(1L, 2L));
|
||||
when(repository.deleteRows(List.of(1L, 2L))).thenReturn(ROWS);
|
||||
|
||||
assertThat(service(false).prune(List.of(9L), false)).isEqualTo(new CleanupResult(false, List.of(1L, 2L), ROWS, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void prune_InDryRunOnlyCounts() {
|
||||
when(repository.findOrphans(List.of(9L))).thenReturn(List.of(1L));
|
||||
when(repository.countRows(List.of(1L))).thenReturn(ROWS);
|
||||
|
||||
assertThat(service(true).prune(List.of(9L), false)).isEqualTo(new CleanupResult(true, List.of(1L), ROWS, false));
|
||||
verify(repository, never()).deleteRows(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prune_WithoutOrphansDoesNothing() {
|
||||
when(repository.findOrphans(List.of(9L))).thenReturn(List.of());
|
||||
|
||||
assertThat(service(false).prune(List.of(9L), false)).isEqualTo(new CleanupResult(false, List.of(), Map.of(), false));
|
||||
verify(repository, never()).deleteRows(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void purge_AuthsDryRunOnlyCounts() {
|
||||
when(repository.findDeleted(List.of(2L))).thenReturn(List.of(2L));
|
||||
when(repository.countRows(List.of(2L))).thenReturn(ROWS);
|
||||
|
||||
assertThat(service(false).purge(List.of(2L), true)).isEqualTo(new CleanupResult(true, List.of(2L), ROWS, false));
|
||||
verify(repository, never()).deleteRows(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prune_AuthsDryRunOnlyCounts() {
|
||||
when(repository.findOrphans(List.of(9L))).thenReturn(List.of(1L));
|
||||
when(repository.countRows(List.of(1L))).thenReturn(ROWS);
|
||||
|
||||
assertThat(service(false).prune(List.of(9L), true)).isEqualTo(new CleanupResult(true, List.of(1L), ROWS, false));
|
||||
verify(repository, never()).deleteRows(any());
|
||||
}
|
||||
}
|
||||
@@ -103,4 +103,15 @@ class BacklogSnapshotRepositoryTest extends AbstractPersistenceTest {
|
||||
assertThat(repository.live(1L, DAY)).isEqualTo(new UserStats.PileDay(DAY, 1, 1, 600));
|
||||
assertThat(repository.live(3L, DAY)).isEqualTo(new UserStats.PileDay(DAY, 0, 0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeAll_SkipsDeletedUsers() {
|
||||
userGameRepository.saveAllAndFlush(List.of(game(1L, 220L, 0, GameStatus.BACKLOG),
|
||||
game(2L, 220L, 0, GameStatus.BACKLOG)));
|
||||
jdbc.update("insert into deleted_user (user_id) values (2)");
|
||||
|
||||
assertThat(repository.writeAll(DAY)).isEqualTo(1);
|
||||
|
||||
assertThat(repository.findSince(2L, DAY)).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.UserSteamSync;
|
||||
import core_service.shared.test.AbstractPersistenceTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class UserSteamSyncRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
private static final String STEAM_ID = "76561198000000001";
|
||||
|
||||
@Autowired
|
||||
private UserSteamSyncRepository repository;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
private int claim(long userId) {
|
||||
return repository.tryClaimRun(userId, STEAM_ID, Instant.now().minusSeconds(600));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tryClaimRun_ClaimsForALiveUser() {
|
||||
assertThat(claim(1L)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tryClaimRun_RefusesADeletedUserWithoutASyncRow() {
|
||||
jdbc.update("insert into deleted_user (user_id) values (2)");
|
||||
|
||||
assertThat(claim(2L)).isZero();
|
||||
assertThat(repository.findById(2L)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void tryClaimRun_RefusesADeletedUserWithAnIdleSyncRow() {
|
||||
repository.saveAndFlush(new UserSteamSync(3L, STEAM_ID));
|
||||
jdbc.update("insert into deleted_user (user_id) values (3)");
|
||||
|
||||
assertThat(claim(3L)).isZero();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import core_service.shared.test.AbstractPersistenceTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.time.Instant;
|
||||
@@ -28,6 +29,9 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
|
||||
@Autowired
|
||||
private UserGameRepository userGameRepository;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
userGameRepository.deleteAll();
|
||||
@@ -61,4 +65,15 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
|
||||
private static List<Long> appids(List<Map<String, Object>> rows) {
|
||||
return rows.stream().map(row -> ((Number) row.get("appid")).longValue()).toList();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletedUsers_LeaveEveryNumberAndTheTopLists() {
|
||||
steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now()));
|
||||
userGameRepository.saveAllAndFlush(List.of(new UserGame(3L, 620L, 900, Instant.now(), Instant.now()),
|
||||
new UserGame(3L, 220L, 0, Instant.now(), Instant.now())));
|
||||
jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)");
|
||||
|
||||
hiddenGames_LeaveEveryNumber();
|
||||
hiddenGames_LeaveTheTopLists();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user