Compare commits

..
2 Commits
Author SHA1 Message Date
lobo 10b7dc2272 feat(account): reject tokens of blocked users and old roles
Build, Test & Push Core Service / docker (push) Successful in 1m30s
Build, Test & Push Core Service / quality-check (push) Successful in 1m23s
V32 stores the last role change per user, sent by Auth. A token
issued before that change, or of a blocked user, gets 401 within
about 30 s instead of working until it expires.
2026-10-04 20:09:22 +02:00
lobo 35b1db45e6 feat(sync): start Steam sync on visit when data is older than 20 h
A visit starts a background sync when the last successful sync is
older than 20 h; one try per hour, one sync at a time. Migration V31
adds last_success_at and login_sync_tried_at. The nightly sync skips
only users with a successful sync today after 23:00. games-count
returns syncStarted.
2026-10-04 19:05:23 +02:00
34 changed files with 869 additions and 107 deletions

No files matched your search

+6 -4
View File
@@ -179,7 +179,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public | | `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public |
| `GET` | `/api/stats/public/games?key=&arg=&page=` (the games behind a landing number, per game `libraries` and `minutes`; `400 BAD_ARG`) | public | | `GET` | `/api/stats/public/games?key=&arg=&page=` (the games behind a landing number, per game `libraries` and `minutes`; `400 BAD_ARG`) | public |
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token | | `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked}]}`, `displayName` optional, at most 100 characters, kept in `account_name` for the audit log; POST updates, PUT replaces the whole set; `204`) | service token | | `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked, roleChangedAt}]}`, `displayName` and `roleChangedAt` optional, `displayName` at most 100 characters, kept in `account_name` for the audit log, `roleChangedAt` kept in `account_role_change`; POST updates, PUT replaces the whole set; `204`) | service token |
| `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | 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 | | `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
@@ -236,7 +236,7 @@ flowchart LR
| `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data | | `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data |
| `stats` | Public numbers for the landing page | | `stats` | Public numbers for the landing page |
| `version` | `GET /api/version`: commit and build time of the running image | | `version` | `GET /api/version`: commit and build time of the running image |
| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, display names in `account_name`, Steam ID changes stop the nightly sync | | `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, role changes in `account_role_change`, display names in `account_name`, Steam ID changes stop the nightly sync |
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures | | `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 | | `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 | | `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
@@ -426,8 +426,10 @@ The purge also removes the user's `account_name` row and link requests (`game_li
Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account
took by a verified link, a display name change, a block and an unblock; its nightly job sends every user (`PUT`). A blocked user is in took by a verified link, a display name change, a block and an unblock; its nightly job sends every user (`PUT`). A blocked user is in
`blocked_user`: no nightly sync, no new sync run, not in the landing stats. The access token still works in Core until `blocked_user`: no nightly sync, no new sync run, not in the landing stats. `JwtAuthenticationFilter` refuses a blocked
it expires (max 15 min). A new Steam ID or no Steam ID takes the user out of the nightly sync; the next visit syncs user's token and a token issued before the user's last role change (`account_role_change`, sent as `roleChangedAt`),
through the cache `loginStates` (30 s, evicted by every state call). An Auth that does not send `roleChangedAt` changes
only the blocks. A new Steam ID or no Steam ID takes the user out of the nightly sync; the next visit syncs
the ID of the token (Core's copy changes only then, so that sync sees the change and drops the old account's games). A visit with an access token that still has the old ID (max 15 min) can sync it once the ID of the token (Core's copy changes only then, so that sync sees the change and drops the old account's games). A visit with an access token that still has the old ID (max 15 min) can sync it once
more and put the user back on the nightly list; the nightly `PUT` at 04:15 takes the user off again, so at most one more and put the user back on the nightly list; the nightly `PUT` at 04:15 takes the user off again, so at most one
nightly sync (23:55) runs with the old ID. Unblock and the nightly `PUT` lift the block. nightly sync (23:55) runs with the old ID. Unblock and the nightly `PUT` lift the block.
@@ -8,6 +8,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered; import org.springframework.core.Ordered;
import java.time.Duration;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@Configuration @Configuration
@@ -22,6 +23,11 @@ public class CacheConfig {
cacheManager.setCaffeine(Caffeine.newBuilder() cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES) .expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(1000)); .maximumSize(1000));
// The real limit after a block or a role change: the state call evicts it, a missed call heals after 30 s.
cacheManager.registerCustomCache("loginStates", Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(30))
.build());
return cacheManager; return cacheManager;
} }
} }
@@ -1,5 +1,6 @@
package core_service.configuration.security; package core_service.configuration.security;
import core_service.shared.security.LoginStateCheck;
import core_service.shared.security.UserPrincipal; import core_service.shared.security.UserPrincipal;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
@@ -17,12 +18,16 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Optional; import java.util.Optional;
/**
* A blocked user and a token issued before the last role change of its user count as not logged in.
*/
@Component("jwtAuthenticationFilter") @Component("jwtAuthenticationFilter")
@Profile("!dev") @Profile("!dev")
@RequiredArgsConstructor @RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService; private final JwtService jwtService;
private final LoginStateCheck loginStateCheck;
@Override @Override
protected void doFilterInternal( protected void doFilterInternal(
@@ -34,6 +39,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
Optional<UserPrincipal> principal = AccessTokens.candidates(request) Optional<UserPrincipal> principal = AccessTokens.candidates(request)
.map(jwtService::parseLoginToken) .map(jwtService::parseLoginToken)
.flatMap(Optional::stream) .flatMap(Optional::stream)
.filter(each -> loginStateCheck.state(each.getIdAsLong()).accepts(each.issuedAt()))
.findFirst(); .findFirst();
if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) { if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
@@ -58,7 +58,8 @@ public class JwtService implements ProfileTicketVerifier {
if (userId == null) { if (userId == null) {
return Optional.empty(); return Optional.empty();
} }
return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class), role(claims))); return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class), role(claims),
claims.getIssuedAt() == null ? null : claims.getIssuedAt().toInstant()));
} catch (Exception e) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
} }
@@ -64,7 +64,8 @@ public class InternalAccountController {
private static List<AccountState> toStates(AccountStatesRequest request) { private static List<AccountState> toStates(AccountStatesRequest request) {
return request.accounts().stream() return request.accounts().stream()
.map(account -> new AccountState(account.userId(), account.steamId(), account.displayName(), account.blocked())) .map(account -> new AccountState(account.userId(), account.steamId(), account.displayName(),
account.blocked(), account.roleChangedAt()))
.toList(); .toList();
} }
@@ -4,14 +4,16 @@ import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.util.List; import java.util.List;
public record AccountStatesRequest(@NotNull List<@Valid AccountStateDto> accounts) { public record AccountStatesRequest(@NotNull List<@Valid AccountStateDto> accounts) {
/** /**
* @param displayName null from an Auth that does not send it yet * @param displayName null from an Auth that does not send it yet
* @param roleChangedAt null from an Auth that does not send it yet
*/ */
public record AccountStateDto(@NotNull Long userId, Long steamId, @Size(max = 100) String displayName, public record AccountStateDto(@NotNull Long userId, Long steamId, @Size(max = 100) String displayName,
boolean blocked) { boolean blocked, Instant roleChangedAt) {
} }
} }
@@ -1,10 +1,13 @@
package core_service.modules.account.domain; package core_service.modules.account.domain;
import java.time.Instant;
/** /**
* What Core copies from Auth for one user. * What Core copies from Auth for one user.
* *
* @param steamId null when the user has no Steam ID (unlinked, or taken by another account's verified link) * @param steamId null when the user has no Steam ID (unlinked, or taken by another account's verified link)
* @param displayName null when Auth did not send one; Core then keeps the stored name * @param displayName null when Auth did not send one; Core then keeps the stored name
* @param roleChangedAt null from an Auth that does not send it; Core then keeps the stored value
*/ */
public record AccountState(long userId, Long steamId, String displayName, boolean blocked) { public record AccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt) {
} }
@@ -23,11 +23,11 @@ public class AccountCleanupRepository {
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement", 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_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped", "sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped",
"blocked_user", "deleted_user"); "account_role_change", "blocked_user", "deleted_user");
// Every user with data has a row in one of these; deleteRows then removes the child rows too. // 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", private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
"game_import", "blocked_user", "deleted_user", "account_name"); "game_import", "blocked_user", "account_role_change", "deleted_user", "account_name");
private final NamedParameterJdbcTemplate jdbc; private final NamedParameterJdbcTemplate jdbc;
@@ -1,16 +1,19 @@
package core_service.modules.account.repo; package core_service.modules.account.repo;
import core_service.modules.account.domain.AccountState; import core_service.modules.account.domain.AccountState;
import core_service.shared.security.LoginState;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
/** /**
* Blocks in blocked_user, the display name in account_name, and what a Steam ID change means for user_steam_sync. * Blocks in blocked_user, role changes in account_role_change, the display name in account_name, and what a Steam ID
* change means for user_steam_sync.
* Plain SQL, because the library module owns the entities. * Plain SQL, because the library module owns the entities.
*/ */
@Repository @Repository
@@ -27,6 +30,12 @@ public class AccountStateRepository {
+ "on conflict (user_id) do update set display_name = excluded.display_name", + "on conflict (user_id) do update set display_name = excluded.display_name",
params.addValue("displayName", state.displayName())); params.addValue("displayName", state.displayName()));
} }
if (state.roleChangedAt() != null) {
jdbc.update("insert into account_role_change (user_id, changed_at) values (:userId, :roleChangedAt) "
+ "on conflict (user_id) do update set "
+ "changed_at = greatest(account_role_change.changed_at, excluded.changed_at)",
params.addValue("roleChangedAt", Timestamp.from(state.roleChangedAt())));
}
if (state.blocked()) { if (state.blocked()) {
jdbc.update("insert into blocked_user (user_id) values (:userId) on conflict do nothing", params); jdbc.update("insert into blocked_user (user_id) values (:userId) on conflict do nothing", params);
} else { } else {
@@ -44,6 +53,17 @@ public class AccountStateRepository {
} }
} }
public LoginState findLoginState(long userId) {
return jdbc.queryForObject("""
select exists(select 1 from blocked_user where user_id = :userId) as blocked,
(select changed_at from account_role_change where user_id = :userId) as changed_at""",
new MapSqlParameterSource("userId", userId),
(rs, rowNum) -> {
Timestamp changedAt = rs.getTimestamp("changed_at");
return new LoginState(rs.getBoolean("blocked"), changedAt == null ? null : changedAt.toInstant());
});
}
/** /**
* After this, exactly the blocked users of the list are blocked. * After this, exactly the blocked users of the list are blocked.
*/ */
@@ -24,14 +24,14 @@ public class AccountStateService {
private final AccountStateRepository repository; private final AccountStateRepository repository;
@Transactional @Transactional
@CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true) @CacheEvict(value = {"publicStats", "publicStatGames", "loginStates"}, allEntries = true)
public void apply(List<AccountState> states) { public void apply(List<AccountState> states) {
states.forEach(repository::apply); states.forEach(repository::apply);
log.info("Account state: {} users updated", states.size()); log.info("Account state: {} users updated", states.size());
} }
@Transactional @Transactional
@CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true) @CacheEvict(value = {"publicStats", "publicStatGames", "loginStates"}, allEntries = true)
public void replaceAll(List<AccountState> states) { public void replaceAll(List<AccountState> states) {
repository.replaceAll(states); repository.replaceAll(states);
log.info("Account state: {} users replaced", states.size()); log.info("Account state: {} users replaced", states.size());
@@ -0,0 +1,23 @@
package core_service.modules.account.service;
import core_service.modules.account.repo.AccountStateRepository;
import core_service.shared.security.LoginState;
import core_service.shared.security.LoginStateCheck;
import core_service.shared.security.Public;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
@Public
@RequiredArgsConstructor
public class LoginStateService implements LoginStateCheck {
private final AccountStateRepository repository;
@Override
@Cacheable(value = "loginStates", key = "#userId")
public LoginState state(long userId) {
return repository.findLoginState(userId);
}
}
@@ -115,10 +115,15 @@ public class LibraryController {
@GetMapping("/games-count") @GetMapping("/games-count")
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
Long userId = principal.getIdAsLong(); Long userId = principal.getIdAsLong();
principal.steamIdText().ifPresentOrElse(steamId -> syncService.syncIfStale(userId, steamId), boolean syncStarted = principal.steamIdText()
() -> syncService.stopNightlySync(userId)); .map(steamId -> syncService.syncIfStale(userId, steamId))
.orElseGet(() -> {
syncService.stopNightlySync(userId);
return false;
});
long count = queryService.countOwnedGames(userId); long count = queryService.countOwnedGames(userId);
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null)); return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null,
"syncStarted", syncStarted));
} }
@GetMapping("/games") @GetMapping("/games")
@@ -2,5 +2,6 @@ package core_service.modules.library.domain;
public enum SyncTrigger { public enum SyncTrigger {
MANUAL, MANUAL,
AUTO AUTO,
LOGIN
} }
@@ -45,6 +45,12 @@ public class UserSteamSync {
@Column(name = "user_seen_at") @Column(name = "user_seen_at")
private Instant userSeenAt; private Instant userSeenAt;
@Column(name = "last_success_at")
private Instant lastSuccessAt;
@Column(name = "login_sync_tried_at")
private Instant loginSyncTriedAt;
public UserSteamSync(Long userId, String steamId) { public UserSteamSync(Long userId, String steamId) {
this.userId = userId; this.userId = userId;
this.steamId = steamId; this.steamId = steamId;
@@ -38,7 +38,9 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
WHERE NOT EXISTS (SELECT 1 FROM deleted_user WHERE user_id = :userId) WHERE NOT EXISTS (SELECT 1 FROM deleted_user WHERE user_id = :userId)
AND NOT EXISTS (SELECT 1 FROM blocked_user WHERE user_id = :userId) AND NOT EXISTS (SELECT 1 FROM blocked_user WHERE user_id = :userId)
ON CONFLICT (user_id) DO UPDATE ON CONFLICT (user_id) DO UPDATE
SET steam_id = EXCLUDED.steam_id, SET last_success_at = CASE WHEN user_steam_sync.steam_id IS DISTINCT FROM EXCLUDED.steam_id
THEN NULL ELSE user_steam_sync.last_success_at END,
steam_id = EXCLUDED.steam_id,
last_sync_status = 'RUNNING', last_sync_status = 'RUNNING',
sync_started_at = EXCLUDED.sync_started_at sync_started_at = EXCLUDED.sync_started_at
WHERE user_steam_sync.last_sync_status <> 'RUNNING' WHERE user_steam_sync.last_sync_status <> 'RUNNING'
@@ -50,6 +52,34 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
@Param("startedAt") Instant startedAt, @Param("startedAt") Instant startedAt,
@Param("runningStaleBefore") Instant runningStaleBefore); @Param("runningStaleBefore") Instant runningStaleBefore);
/**
* The gate of the sync on a visit: stamps login_sync_tried_at and returns 1 only when a sync is due, so of many
* parallel requests one wins. Due: no fresh RUNNING run, and either another Steam ID, or no OK sync since
* successBefore and no try since triedBefore. 0 for a deleted or blocked user. A new row gets only the Steam ID.
* steam_id is not updated: the worker claim compares the old ID to detect an account change.
*/
@Modifying
@Transactional
@Query(value = """
INSERT INTO user_steam_sync (user_id, steam_id, login_sync_tried_at)
SELECT :userId, :steamId, :now
WHERE NOT EXISTS (SELECT 1 FROM deleted_user WHERE user_id = :userId)
AND NOT EXISTS (SELECT 1 FROM blocked_user WHERE user_id = :userId)
ON CONFLICT (user_id) DO UPDATE
SET login_sync_tried_at = EXCLUDED.login_sync_tried_at
WHERE NOT (user_steam_sync.last_sync_status = 'RUNNING'
AND user_steam_sync.sync_started_at IS NOT NULL
AND user_steam_sync.sync_started_at >= :runningStaleBefore)
AND (user_steam_sync.steam_id <> EXCLUDED.steam_id
OR ((user_steam_sync.last_success_at IS NULL OR user_steam_sync.last_success_at < :successBefore)
AND (user_steam_sync.login_sync_tried_at IS NULL
OR user_steam_sync.login_sync_tried_at < :triedBefore)))
""", nativeQuery = true)
int tryStartLoginSync(@Param("userId") Long userId, @Param("steamId") String steamId,
@Param("now") Instant now, @Param("successBefore") Instant successBefore,
@Param("triedBefore") Instant triedBefore,
@Param("runningStaleBefore") Instant runningStaleBefore);
/** /**
* @return 0 when another run took over the stale claim; that run's RUNNING stays * @return 0 when another run took over the stale claim; that run's RUNNING stays
*/ */
@@ -57,7 +87,8 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
@Query(value = """ @Query(value = """
UPDATE user_steam_sync UPDATE user_steam_sync
SET last_synced_at = :now, SET last_synced_at = :now,
last_sync_status = :status last_sync_status = :status,
last_success_at = CASE WHEN :status = 'OK' THEN :now ELSE last_success_at END
WHERE user_id = :userId WHERE user_id = :userId
AND sync_started_at = :startedAt AND sync_started_at = :startedAt
""", nativeQuery = true) """, nativeQuery = true)
@@ -15,7 +15,9 @@ import org.springframework.stereotype.Service;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List; import java.util.List;
@Service @Service
@@ -24,12 +26,14 @@ import java.util.List;
@Slf4j @Slf4j
public class LibrarySyncService { public class LibrarySyncService {
private static final LocalTime NIGHTLY_SKIP_FROM = LocalTime.of(23, 0);
private final UserSteamSyncRepository userSteamSyncRepository; private final UserSteamSyncRepository userSteamSyncRepository;
private final LibrarySyncWorker worker; private final LibrarySyncWorker worker;
private final BacklogSnapshotRepository backlogSnapshotRepository; private final BacklogSnapshotRepository backlogSnapshotRepository;
private final FinishedEstimateService finishedEstimateService; private final FinishedEstimateService finishedEstimateService;
@Value("${app.library.sync-ttl-hours:24}") @Value("${app.library.sync-ttl-hours:20}")
private long syncTtlHours; private long syncTtlHours;
@Value("${app.library.force-sync-cooldown-seconds:60}") @Value("${app.library.force-sync-cooldown-seconds:60}")
@@ -38,8 +42,8 @@ public class LibrarySyncService {
@Value("${app.library.running-stale-minutes:5}") @Value("${app.library.running-stale-minutes:5}")
private long runningStaleMinutes; private long runningStaleMinutes;
@Value("${app.library.failed-retry-minutes:15}") @Value("${app.library.login-retry-minutes:60}")
private long failedRetryMinutes; private long loginRetryMinutes;
@Value("${app.library.nightly-active-days:14}") @Value("${app.library.nightly-active-days:14}")
private long nightlyActiveDays; private long nightlyActiveDays;
@@ -48,36 +52,51 @@ public class LibrarySyncService {
private ZoneId zone; private ZoneId zone;
/** /**
* Trigger sync only if data is older than TTL or was synced for another Steam ID. * Starts a background sync on a visit when the last OK sync is older than the TTL, at most one try per
* Cheap gate check on caller thread, actual sync runs async via worker. * {@code login-retry-minutes} (a dropped or failed try counts too), or when the Steam ID changed.
* The check on the loaded row saves the write; the conditional upsert lets one of many parallel requests win.
*
* @return true when a sync of this visit is running or was just started, by this call or by a parallel
* request that won the gate
*/ */
public void syncIfStale(Long userId, String steamId) { public boolean syncIfStale(Long userId, String steamId) {
Instant now = Instant.now();
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null); UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
touchUserSeen(sync); touchUserSeen(sync);
if (sync != null && isAlreadyRunning(sync)) { Instant successBefore = now.minus(Duration.ofHours(syncTtlHours));
log.debug("Sync already running for user {}", userId); Instant triedBefore = now.minus(Duration.ofMinutes(loginRetryMinutes));
return; Instant runningStaleBefore = now.minus(Duration.ofMinutes(runningStaleMinutes));
if (sync != null && !isLoginSyncDue(sync, steamId, successBefore, triedBefore)) {
return isSyncActive(sync, now);
} }
if (sync != null && steamId.equals(sync.getSteamId()) && !isStale(sync)) { if (userSteamSyncRepository.tryStartLoginSync(userId, steamId, now, successBefore, triedBefore,
return; runningStaleBefore) == 0) {
// Lost the gate: the winner may be a parallel request that wrote after our read.
return userSteamSyncRepository.findById(userId).map(s -> isSyncActive(s, now)).orElse(false);
} }
worker.runSync(userId, steamId, SyncTrigger.AUTO); worker.runSync(userId, steamId, SyncTrigger.LOGIN);
return true;
} }
/** /**
* Syncs every known Steam account shortly before midnight, so the playtime history gets the growth of each * Syncs every known Steam account shortly before midnight, so the playtime history gets the growth of each
* day even when the user does not open the app. Accounts are synced one after the other on the scheduler * day even when the user does not open the app. Accounts are synced one after the other on the scheduler
* thread; accounts without a user request in the last {@code nightly-active-days} are skipped. Afterwards every * thread; accounts without a user request in the last {@code nightly-active-days}, or with an OK sync of the
* user's pile size of the day goes to backlog_snapshot, and old finished games get a date from their ending * same day after 23:00, are skipped (the nightly sync books the playtime growth of the
* achievement. * day, so an earlier sync must not replace it). Afterwards every user's pile size of the day goes to backlog_snapshot, and old
* finished games get a date from their ending achievement.
*/ */
@Scheduled(cron = "${app.library.nightly-sync-cron:0 55 23 * * *}", zone = "${app.library.zone:Europe/Berlin}") @Scheduled(cron = "${app.library.nightly-sync-cron:0 55 23 * * *}", zone = "${app.library.zone:Europe/Berlin}")
public void syncAllNightly() { public void syncAllNightly() {
Instant activeSince = Instant.now().minus(Duration.ofDays(nightlyActiveDays)); Instant now = Instant.now();
List<UserSteamSync> accounts = userSteamSyncRepository.findByUserSeenAtAfter(activeSince); LocalDate today = LocalDate.now(zone);
log.info("Nightly sync for {} Steam accounts", accounts.size()); List<UserSteamSync> active =
userSteamSyncRepository.findByUserSeenAtAfter(now.minus(Duration.ofDays(nightlyActiveDays)));
List<UserSteamSync> accounts =
active.stream().filter(s -> !syncedLateToday(s.getLastSuccessAt(), today)).toList();
log.info("Nightly sync for {} of {} active Steam accounts", accounts.size(), active.size());
// Taken before the syncs: the loop can run past midnight. // Taken before the syncs: the loop can run past midnight.
LocalDate day = LocalDate.now(zone); LocalDate day = today;
try { try {
accounts.forEach(sync -> worker.runSyncNow(sync.getUserId(), sync.getSteamId(), SyncTrigger.AUTO)); accounts.forEach(sync -> worker.runSyncNow(sync.getUserId(), sync.getSteamId(), SyncTrigger.AUTO));
} finally { } finally {
@@ -136,12 +155,29 @@ public class LibrarySyncService {
} }
} }
private boolean isStale(UserSteamSync sync) { private boolean isLoginSyncDue(UserSteamSync sync, String steamId, Instant successBefore, Instant triedBefore) {
if (sync.getLastSyncedAt() == null) return true; return !isAlreadyRunning(sync)
Duration maxAge = sync.getLastSyncStatus() == UserSteamSync.Status.FAILED && (!steamId.equals(sync.getSteamId())
? Duration.ofMinutes(failedRetryMinutes) || (isNullOrBefore(sync.getLastSuccessAt(), successBefore)
: Duration.ofHours(syncTtlHours); && isNullOrBefore(sync.getLoginSyncTriedAt(), triedBefore)));
return sync.getLastSyncedAt().isBefore(Instant.now().minus(maxAge)); }
private boolean syncedLateToday(Instant lastSuccessAt, LocalDate today) {
if (lastSuccessAt == null) return false;
ZonedDateTime at = lastSuccessAt.atZone(zone);
return at.toLocalDate().equals(today) && !at.toLocalTime().isBefore(NIGHTLY_SKIP_FROM);
}
// A sync of this visit is running, or the gate was won and the worker has not finished (or claimed) yet.
private boolean isSyncActive(UserSteamSync sync, Instant now) {
Instant tried = sync.getLoginSyncTriedAt();
boolean justStarted = tried != null && tried.isAfter(now.minus(Duration.ofMinutes(runningStaleMinutes)))
&& (sync.getLastSyncedAt() == null || sync.getLastSyncedAt().isBefore(tried));
return isAlreadyRunning(sync) || justStarted;
}
private static boolean isNullOrBefore(Instant value, Instant limit) {
return value == null || value.isBefore(limit);
} }
private boolean isAlreadyRunning(UserSteamSync sync) { private boolean isAlreadyRunning(UserSteamSync sync) {
@@ -0,0 +1,20 @@
package core_service.shared.security;
import java.time.Instant;
/**
* What Core knows from Auth about the login of one user.
*
* @param roleChangedAt null when Auth never sent a role change
*/
public record LoginState(boolean blocked, Instant roleChangedAt) {
public static final LoginState NONE = new LoginState(false, null);
/**
* A token issued before roleChangedAt is refused. roleChangedAt is the start of the second after the change.
*/
public boolean accepts(Instant issuedAt) {
return !blocked && (roleChangedAt == null || (issuedAt != null && !issuedAt.isBefore(roleChangedAt)));
}
}
@@ -0,0 +1,9 @@
package core_service.shared.security;
/**
* Port of the login filter, implemented by the account module.
*/
public interface LoginStateCheck {
LoginState state(long userId);
}
@@ -1,17 +1,24 @@
package core_service.shared.security; package core_service.shared.security;
import java.security.Principal; import java.security.Principal;
import java.time.Instant;
import java.util.Optional; import java.util.Optional;
/** /**
* @param role the active role of this login ("view as"), set by Auth * @param role the active role of this login ("view as"), set by Auth
* @param issuedAt {@code iat} of the token, null when it has none
*/ */
public record UserPrincipal( public record UserPrincipal(
String id, String id,
Long steamId, Long steamId,
Role role Role role,
Instant issuedAt
) implements Principal { ) implements Principal {
public UserPrincipal(String id, Long steamId, Role role) {
this(id, steamId, role, null);
}
public UserPrincipal(String id, Long steamId) { public UserPrincipal(String id, Long steamId) {
this(id, steamId, Role.USER); this(id, steamId, Role.USER);
} }
+3 -1
View File
@@ -56,7 +56,9 @@ app:
enabled: true enabled: true
base-url: https://www.speedrun.com/api/v1 base-url: https://www.speedrun.com/api/v1
library: library:
sync-ttl-hours: 24 sync-ttl-hours: 20
# At most one sync try per user and hour on a visit, also after a failure.
login-retry-minutes: 60
force-sync-cooldown-seconds: 60 force-sync-cooldown-seconds: 60
running-stale-minutes: 5 running-stale-minutes: 5
achievement-ttl-hours: 1 achievement-ttl-hours: 1
@@ -0,0 +1,4 @@
-- last_success_at: end of the last OK sync (any trigger). login_sync_tried_at: last start of a sync on a visit.
ALTER TABLE user_steam_sync ADD COLUMN last_success_at TIMESTAMPTZ;
ALTER TABLE user_steam_sync ADD COLUMN login_sync_tried_at TIMESTAMPTZ;
UPDATE user_steam_sync SET last_success_at = last_synced_at WHERE last_sync_status = 'OK';
@@ -0,0 +1,6 @@
-- When Auth last changed a user's role on the admin page (X4 path, like blocked_user). A login token issued before
-- this time is refused.
CREATE TABLE account_role_change (
user_id BIGINT PRIMARY KEY,
changed_at TIMESTAMPTZ NOT NULL
);
@@ -1,9 +1,13 @@
package core_service.configuration.security; package core_service.configuration.security;
import core_service.shared.security.LoginState;
import core_service.shared.security.LoginStateCheck;
import core_service.shared.security.Role;
import core_service.shared.security.UserPrincipal; import core_service.shared.security.UserPrincipal;
import core_service.shared.test.AbstractUnitTest; import core_service.shared.test.AbstractUnitTest;
import jakarta.servlet.http.Cookie; import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.Mock; import org.mockito.Mock;
import org.springframework.mock.web.MockFilterChain; import org.springframework.mock.web.MockFilterChain;
@@ -12,10 +16,12 @@ import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import java.time.Instant;
import java.util.Optional; import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.lenient;
/** /**
@@ -24,10 +30,19 @@ import static org.mockito.Mockito.lenient;
class AccessTokenFiltersTest extends AbstractUnitTest { class AccessTokenFiltersTest extends AbstractUnitTest {
private static final UserPrincipal USER = new UserPrincipal("42", 76561198000000042L); private static final UserPrincipal USER = new UserPrincipal("42", 76561198000000042L);
private static final Instant ROLE_CHANGE = Instant.parse("2026-10-04T12:00:00Z");
@Mock @Mock
private JwtService jwtService; private JwtService jwtService;
@Mock
private LoginStateCheck loginStateCheck;
@BeforeEach
void everyoneMayLogIn() {
lenient().when(loginStateCheck.state(anyLong())).thenReturn(LoginState.NONE);
}
@AfterEach @AfterEach
void clearContext() { void clearContext() {
SecurityContextHolder.clearContext(); SecurityContextHolder.clearContext();
@@ -39,6 +54,10 @@ class AccessTokenFiltersTest extends AbstractUnitTest {
return authentication == null ? null : authentication.getPrincipal(); return authentication == null ? null : authentication.getPrincipal();
} }
private JwtAuthenticationFilter jwtFilter() {
return new JwtAuthenticationFilter(jwtService, loginStateCheck);
}
private void validToken(String token) { private void validToken(String token) {
lenient().when(jwtService.parseLoginToken(token)).thenReturn(Optional.of(USER)); lenient().when(jwtService.parseLoginToken(token)).thenReturn(Optional.of(USER));
} }
@@ -49,7 +68,7 @@ class AccessTokenFiltersTest extends AbstractUnitTest {
MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "good")); request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "good"));
assertEquals(USER, principalAfter(new JwtAuthenticationFilter(jwtService), request)); assertEquals(USER, principalAfter(jwtFilter(), request));
} }
@Test @Test
@@ -58,7 +77,7 @@ class AccessTokenFiltersTest extends AbstractUnitTest {
MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer good"); request.addHeader("Authorization", "Bearer good");
assertEquals(USER, principalAfter(new JwtAuthenticationFilter(jwtService), request)); assertEquals(USER, principalAfter(jwtFilter(), request));
} }
@Test @Test
@@ -69,7 +88,7 @@ class AccessTokenFiltersTest extends AbstractUnitTest {
request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "stale")); request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "stale"));
request.addHeader("Authorization", "Bearer good"); request.addHeader("Authorization", "Bearer good");
assertEquals(USER, principalAfter(new JwtAuthenticationFilter(jwtService), request)); assertEquals(USER, principalAfter(jwtFilter(), request));
} }
@Test @Test
@@ -78,7 +97,54 @@ class AccessTokenFiltersTest extends AbstractUnitTest {
MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "bad")); request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "bad"));
assertNull(principalAfter(new JwtAuthenticationFilter(jwtService), request)); assertNull(principalAfter(jwtFilter(), request));
}
private static UserPrincipal issuedAt(Instant issuedAt) {
return new UserPrincipal("42", 76561198000000042L, Role.AUDITOR, issuedAt);
}
private MockHttpServletRequest bearer(String token) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer " + token);
return request;
}
@Test
void tokenFromBeforeTheRoleChange_StaysAnonymous() throws Exception {
lenient().when(jwtService.parseLoginToken("old")).thenReturn(Optional.of(issuedAt(ROLE_CHANGE.minusSeconds(5))));
lenient().when(loginStateCheck.state(42)).thenReturn(new LoginState(false, ROLE_CHANGE));
assertNull(principalAfter(jwtFilter(), bearer("old")));
}
@Test
void tokenFromAfterTheRoleChange_Authenticates() throws Exception {
UserPrincipal fresh = issuedAt(ROLE_CHANGE.plusSeconds(1));
lenient().when(jwtService.parseLoginToken("new")).thenReturn(Optional.of(fresh));
lenient().when(loginStateCheck.state(42)).thenReturn(new LoginState(false, ROLE_CHANGE));
assertEquals(fresh, principalAfter(jwtFilter(), bearer("new")));
}
@Test
void blockedUser_StaysAnonymous() throws Exception {
validToken("good");
lenient().when(loginStateCheck.state(42)).thenReturn(new LoginState(true, null));
assertNull(principalAfter(jwtFilter(), bearer("good")));
}
@Test
void staleCookieNextToAFreshBearer_TheBearerWins() throws Exception {
UserPrincipal fresh = issuedAt(ROLE_CHANGE.plusSeconds(1));
lenient().when(jwtService.parseLoginToken("stale")).thenReturn(Optional.of(issuedAt(ROLE_CHANGE.minusSeconds(5))));
lenient().when(jwtService.parseLoginToken("new")).thenReturn(Optional.of(fresh));
lenient().when(loginStateCheck.state(42)).thenReturn(new LoginState(false, ROLE_CHANGE));
MockHttpServletRequest request = bearer("new");
request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "stale"));
assertEquals(fresh, principalAfter(jwtFilter(), request));
} }
@Test @Test
@@ -15,6 +15,7 @@ import org.springframework.web.client.RestClient;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.KeyPairGenerator; import java.security.KeyPairGenerator;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.time.Instant;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
@@ -65,8 +66,12 @@ class JwtServiceTest extends AbstractUnitTest {
void parseLoginToken_ReturnsTheUser() { void parseLoginToken_ReturnsTheUser() {
String token = signToken("0815", 123456789L); String token = signToken("0815", 123456789L);
assertEquals(Optional.of(new UserPrincipal("0815", 123456789L)), UserPrincipal principal = jwtService.parseLoginToken(token).orElseThrow();
jwtService.parseLoginToken(token));
assertEquals("0815", principal.id());
assertEquals(123456789L, principal.steamId());
assertEquals(Role.USER, principal.role());
assertFalse(principal.issuedAt().isAfter(Instant.now()));
} }
@Test @Test
@@ -15,6 +15,8 @@ import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import tools.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -45,6 +47,7 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID); jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID);
jdbc.update("delete from blocked_user where user_id in (?, 42)", USER_ID); jdbc.update("delete from blocked_user where user_id in (?, 42)", USER_ID);
jdbc.update("delete from account_name where user_id in (?, 42)", USER_ID); jdbc.update("delete from account_name where user_id in (?, 42)", USER_ID);
jdbc.update("delete from account_role_change where user_id in (?, 42)", USER_ID);
} }
private static String serviceToken() { private static String serviceToken() {
@@ -131,6 +134,27 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
@Test
void stateCallWithoutARoleChange_AnOldAuth_StoresTheBlockAndNoRoleChange() throws Exception {
send(post("/internal/accounts/states"), "{\"accounts\":[{\"userId\":" + USER_ID + ",\"blocked\":true}]}",
serviceToken()).andExpect(status().isNoContent());
assertThat(jdbc.queryForList("select user_id from blocked_user where user_id = ?", Long.class, USER_ID))
.containsExactly(USER_ID);
assertThat(jdbc.queryForList("select user_id from account_role_change where user_id = ?", Long.class, USER_ID))
.isEmpty();
}
@Test
void stateCallWithARoleChange_StoresIt() throws Exception {
JsonNode call = ContractTokens.internalCall("applyStates");
send(contractRequest(call), call.path("request").toString(), serviceToken()).andExpect(status().isNoContent());
assertThat(jdbc.queryForObject("select changed_at from account_role_change where user_id = 42",
Timestamp.class).toInstant()).isEqualTo(Instant.parse("2026-10-04T12:00:00Z"));
}
// The calls as Auth's CoreAccountService sends them. // The calls as Auth's CoreAccountService sends them.
@Test @Test
void contractCalls_AreAnswered() throws Exception { void contractCalls_AreAnswered() throws Exception {
@@ -0,0 +1,89 @@
package core_service.modules.account.api;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
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.context.annotation.Import;
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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* A role change or a block that Auth sends reaches the real login filter at once (the state call evicts the cache).
*/
@AutoConfigureMockMvc
@ActiveProfiles(value = "prod", inheritProfiles = false)
@Import(LoginStateFlowTest.ProbeController.class)
class LoginStateFlowTest extends AbstractIntegrationTest {
private static final long USER_ID = 910_002L;
private static final Instant ISSUED = Instant.parse("2026-10-04T12:00:00Z");
@RestController
static class ProbeController {
@GetMapping("/api/login-state-probe")
String probe() {
return "ok";
}
}
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@AfterEach
void cleanUp() {
jdbc.update("delete from blocked_user where user_id = ?", USER_ID);
jdbc.update("delete from account_role_change where user_id = ?", USER_ID);
}
private ResultActions probe(Instant issuedAt) throws Exception {
return mockMvc.perform(get("/api/login-state-probe")
.header("Authorization", "Bearer " + ContractTokens.loginToken(USER_ID, Role.AUDITOR, issuedAt)));
}
private void sendState(boolean blocked, Instant roleChangedAt) throws Exception {
String changed = roleChangedAt == null ? "" : ",\"roleChangedAt\":\"" + roleChangedAt + "\"";
mockMvc.perform(post("/internal/accounts/states").contentType(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer " + ContractTokens.serviceToken())
.content("{\"accounts\":[{\"userId\":" + USER_ID + ",\"blocked\":" + blocked + changed + "}]}"))
.andExpect(status().isNoContent());
}
@Test
void roleChange_RefusesTheOldTokenAndAcceptsANewOne() throws Exception {
probe(ISSUED).andExpect(status().isOk());
sendState(false, ISSUED.plusSeconds(1));
probe(ISSUED).andExpect(status().isUnauthorized());
probe(ISSUED.plusSeconds(1)).andExpect(status().isOk());
}
@Test
void block_RefusesTheTokenAndUnblockAcceptsItAgain() throws Exception {
probe(ISSUED).andExpect(status().isOk());
sendState(true, null);
probe(ISSUED).andExpect(status().isUnauthorized());
sendState(false, null);
probe(ISSUED).andExpect(status().isOk());
}
}
@@ -1,6 +1,7 @@
package core_service.modules.account.repo; package core_service.modules.account.repo;
import core_service.modules.account.domain.AccountState; import core_service.modules.account.domain.AccountState;
import core_service.shared.security.LoginState;
import core_service.shared.test.AbstractPersistenceTest; import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -8,6 +9,8 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -29,6 +32,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
void seed() { void seed() {
jdbc.update("delete from blocked_user"); jdbc.update("delete from blocked_user");
jdbc.update("delete from account_name"); jdbc.update("delete from account_name");
jdbc.update("delete from account_role_change");
for (long userId : List.of(1L, 2L)) { for (long userId : List.of(1L, 2L)) {
jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, user_seen_at) " jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, user_seen_at) "
+ "values (?, ?, now(), now())", userId, String.valueOf(OLD_STEAM_ID)); + "values (?, ?, now(), now())", userId, String.valueOf(OLD_STEAM_ID));
@@ -46,24 +50,24 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void displayName_IsStoredAndReplacedAndAStateWithoutOneKeepsIt() { void displayName_IsStoredAndReplacedAndAStateWithoutOneKeepsIt() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, "lobo", false)); repository.apply(new AccountState(1L, OLD_STEAM_ID, "lobo", false, null));
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false)); repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null));
assertThat(jdbc.queryForObject("select display_name from account_name where user_id = 1", String.class)).isEqualTo("lobo"); assertThat(jdbc.queryForObject("select display_name from account_name where user_id = 1", String.class)).isEqualTo("lobo");
repository.apply(new AccountState(1L, OLD_STEAM_ID, "wolf", false)); repository.apply(new AccountState(1L, OLD_STEAM_ID, "wolf", false, null));
assertThat(jdbc.queryForList("select display_name from account_name", String.class)).containsExactly("wolf"); assertThat(jdbc.queryForList("select display_name from account_name", String.class)).containsExactly("wolf");
} }
@Test @Test
void block_StopsTheNightlySyncAndUnblockLiftsTheMarker() { void block_StopsTheNightlySyncAndUnblockLiftsTheMarker() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true)); repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, null));
assertThat(blocked()).containsExactly(1L); assertThat(blocked()).containsExactly(1L);
assertThat(sync(1L).get("user_seen_at")).isNull(); assertThat(sync(1L).get("user_seen_at")).isNull();
assertThat(sync(2L).get("user_seen_at")).isNotNull(); assertThat(sync(2L).get("user_seen_at")).isNotNull();
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false)); repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null));
assertThat(blocked()).isEmpty(); assertThat(blocked()).isEmpty();
} }
@@ -71,7 +75,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
// steam_id stays: the next sync with the new ID compares with it and drops the old account's games. // steam_id stays: the next sync with the new ID compares with it and drops the old account's games.
@Test @Test
void newSteamId_StopsTheNightlySyncOfTheOldIdAndSyncsOnTheNextVisit() { void newSteamId_StopsTheNightlySyncOfTheOldIdAndSyncsOnTheNextVisit() {
repository.apply(new AccountState(1L, NEW_STEAM_ID, null, false)); repository.apply(new AccountState(1L, NEW_STEAM_ID, null, false, null));
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID)); assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
assertThat(sync(1L).get("last_synced_at")).isNull(); assertThat(sync(1L).get("last_synced_at")).isNull();
@@ -80,14 +84,14 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void sameSteamId_KeepsTheLastSync() { void sameSteamId_KeepsTheLastSync() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false)); repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null));
assertThat(sync(1L).get("last_synced_at")).isNotNull(); assertThat(sync(1L).get("last_synced_at")).isNotNull();
} }
@Test @Test
void noSteamId_StopsTheNightlySync() { void noSteamId_StopsTheNightlySync() {
repository.apply(new AccountState(1L, null, null, false)); repository.apply(new AccountState(1L, null, null, false, null));
assertThat(sync(1L).get("user_seen_at")).isNull(); assertThat(sync(1L).get("user_seen_at")).isNull();
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID)); assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
@@ -95,7 +99,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void userWithoutASyncRow_GetsOnlyTheBlock() { void userWithoutASyncRow_GetsOnlyTheBlock() {
repository.apply(new AccountState(3L, NEW_STEAM_ID, null, true)); repository.apply(new AccountState(3L, NEW_STEAM_ID, null, true, null));
assertThat(blocked()).containsExactly(3L); assertThat(blocked()).containsExactly(3L);
assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty(); assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty();
@@ -103,13 +107,55 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void replaceAll_LiftsTheBlocksAuthNoLongerSends() { void replaceAll_LiftsTheBlocksAuthNoLongerSends() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true)); repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, null));
repository.apply(new AccountState(2L, OLD_STEAM_ID, null, true)); repository.apply(new AccountState(2L, OLD_STEAM_ID, null, true, null));
repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, true))); repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, true, null)));
assertThat(blocked()).containsExactly(2L); assertThat(blocked()).containsExactly(2L);
repository.replaceAll(List.of()); repository.replaceAll(List.of());
assertThat(blocked()).isEmpty(); assertThat(blocked()).isEmpty();
} }
private Instant changedAt(long userId) {
return jdbc.queryForObject("select changed_at from account_role_change where user_id = ?",
Timestamp.class, userId).toInstant();
}
@Test
void roleChangedAt_IsStoredOverwrittenAndANullKeepsIt() {
Instant first = Instant.parse("2026-10-04T12:00:00Z");
Instant second = first.plusSeconds(60);
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first));
assertThat(changedAt(1L)).isEqualTo(first);
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null));
assertThat(changedAt(1L)).isEqualTo(first);
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, second));
assertThat(changedAt(1L)).isEqualTo(second);
}
@Test
void anOlderRoleChangeArrivingLateDoesNotLowerTheStoredOne() {
Instant first = Instant.parse("2026-10-04T12:00:01Z");
Instant second = first.plusSeconds(1);
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, second));
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first));
assertThat(changedAt(1L)).isEqualTo(second);
}
@Test
void findLoginState_ReadsTheBlockAndTheRoleChange() {
Instant change = Instant.parse("2026-10-04T12:00:00Z");
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, change));
repository.apply(new AccountState(2L, OLD_STEAM_ID, null, false, null));
assertThat(repository.findLoginState(1L)).isEqualTo(new LoginState(true, change));
assertThat(repository.findLoginState(2L)).isEqualTo(LoginState.NONE);
assertThat(repository.findLoginState(99L)).isEqualTo(LoginState.NONE);
}
} }
@@ -16,6 +16,7 @@ import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** /**
@@ -46,7 +47,9 @@ class LibraryControllerSteamIdGoneTest extends AbstractIntegrationTest {
@Test @Test
void gamesCount_WithoutSteamIdClaim_StopsTheNightlySyncOfTheOldId() throws Exception { void gamesCount_WithoutSteamIdClaim_StopsTheNightlySyncOfTheOldId() throws Exception {
mockMvc.perform(get("/api/library/games-count").cookie(loginWithoutSteamId())).andExpect(status().isOk()); mockMvc.perform(get("/api/library/games-count").cookie(loginWithoutSteamId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.syncStarted").value(false));
assertTrue(userSteamSyncRepository.findByUserSeenAtAfter(Instant.now().minus(Duration.ofDays(14))).stream() assertTrue(userSteamSyncRepository.findByUserSeenAtAfter(Instant.now().minus(Duration.ofDays(14))).stream()
.noneMatch(sync -> sync.getUserId() == USER_ID)); .noneMatch(sync -> sync.getUserId() == USER_ID));
@@ -5,8 +5,17 @@ import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -68,4 +77,188 @@ class UserSteamSyncRepositoryTest extends AbstractPersistenceTest {
assertThat(claim(5L)).isZero(); assertThat(claim(5L)).isZero();
} }
private int tryLogin(long userId, String steamId) {
Instant now = Instant.now();
return repository.tryStartLoginSync(userId, steamId, now, now.minusSeconds(20 * 3600),
now.minusSeconds(3600), now.minusSeconds(300));
}
private void givenRow(long userId, String steamId, String status, String startedAgo, String successAgo,
String triedAgo) {
jdbc.update("insert into user_steam_sync (user_id, steam_id, last_sync_status, sync_started_at, "
+ "last_success_at, login_sync_tried_at) values (?, ?, ?, "
+ ago(startedAgo) + ", " + ago(successAgo) + ", " + ago(triedAgo) + ")",
userId, steamId, status);
}
private static String ago(String interval) {
return interval == null ? "null" : "now() - interval '" + interval + "'";
}
@Test
void tryStartLoginSync_NoRow_CreatesItWithTheTryStamp() {
assertThat(tryLogin(10L, STEAM_ID)).isEqualTo(1);
UserSteamSync row = repository.findById(10L).orElseThrow();
assertThat(row.getSteamId()).isEqualTo(STEAM_ID);
assertThat(row.getLoginSyncTriedAt()).isNotNull();
assertThat(row.getLastSyncStatus()).isNull();
}
@Test
void tryStartLoginSync_SuccessTenHoursAgo_IsRefused() {
givenRow(11L, STEAM_ID, "OK", null, "10 hours", null);
assertThat(tryLogin(11L, STEAM_ID)).isZero();
}
@Test
void tryStartLoginSync_SuccessTwentyOneHoursAgoNeverTried_Starts() {
givenRow(12L, STEAM_ID, "OK", null, "21 hours", null);
assertThat(tryLogin(12L, STEAM_ID)).isEqualTo(1);
}
@Test
void tryStartLoginSync_TriedThirtyMinutesAgo_IsRefused() {
givenRow(13L, STEAM_ID, "FAILED", null, "21 hours", "30 minutes");
assertThat(tryLogin(13L, STEAM_ID)).isZero();
}
@Test
void tryStartLoginSync_TriedSixtyOneMinutesAgo_Starts() {
givenRow(14L, STEAM_ID, "FAILED", null, "21 hours", "61 minutes");
assertThat(tryLogin(14L, STEAM_ID)).isEqualTo(1);
}
@Test
void tryStartLoginSync_SecondCallAfterAWinner_IsRefused() {
givenRow(15L, STEAM_ID, "OK", null, "21 hours", null);
assertThat(tryLogin(15L, STEAM_ID)).isEqualTo(1);
assertThat(tryLogin(15L, STEAM_ID)).isZero();
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void tryStartLoginSync_ParallelCallsOnAStaleRow_OnlyOneWins() throws Exception {
long userId = 16L;
jdbc.update("delete from user_steam_sync where user_id = ?", userId);
givenRow(userId, STEAM_ID, "OK", null, "21 hours", null);
int callers = 8;
ExecutorService pool = Executors.newFixedThreadPool(callers);
CountDownLatch go = new CountDownLatch(1);
try {
List<Future<Integer>> results = new ArrayList<>();
for (int i = 0; i < callers; i++) {
results.add(pool.submit(() -> {
go.await();
return tryLogin(userId, STEAM_ID);
}));
}
go.countDown();
int wins = 0;
for (Future<Integer> result : results) {
wins += result.get();
}
assertThat(wins).isEqualTo(1);
} finally {
pool.shutdownNow();
jdbc.update("delete from user_steam_sync where user_id = ?", userId);
}
}
@Test
void tryStartLoginSync_FreshRunning_IsRefused() {
givenRow(17L, STEAM_ID, "RUNNING", "10 seconds", "21 hours", null);
assertThat(tryLogin(17L, STEAM_ID)).isZero();
}
@Test
void tryStartLoginSync_RunningStartedTwentyMinutesAgo_Starts() {
givenRow(18L, STEAM_ID, "RUNNING", "20 minutes", "21 hours", null);
assertThat(tryLogin(18L, STEAM_ID)).isEqualTo(1);
}
@Test
void tryStartLoginSync_RunningWithoutStartTime_Starts() {
givenRow(19L, STEAM_ID, "RUNNING", null, "21 hours", null);
assertThat(tryLogin(19L, STEAM_ID)).isEqualTo(1);
}
@Test
void tryStartLoginSync_FreshSuccessButOtherSteamId_StartsAndKeepsTheOldId() {
givenRow(20L, "76561198005679790", "OK", null, "1 hour", null);
assertThat(tryLogin(20L, STEAM_ID)).isEqualTo(1);
assertThat(repository.findById(20L).orElseThrow().getSteamId()).isEqualTo("76561198005679790");
}
@Test
void tryStartLoginSync_FailedFirstSyncOfANewSteamId_RetriesAfterAnHour() {
givenRow(26L, "76561198005679790", "OK", null, "1 hour", null);
assertThat(tryLogin(26L, STEAM_ID)).isEqualTo(1);
Instant started = Instant.now();
repository.tryClaimRun(26L, STEAM_ID, started, started.minusSeconds(300));
repository.markFinished(26L, started, "FAILED", Instant.now());
Instant inAnHour = Instant.now().plusSeconds(61 * 60);
int started2 = repository.tryStartLoginSync(26L, STEAM_ID, inAnHour, inAnHour.minusSeconds(20 * 3600),
inAnHour.minusSeconds(3600), inAnHour.minusSeconds(300));
assertThat(started2).isEqualTo(1);
}
@Test
void tryStartLoginSync_DeletedUser_IsRefusedWithoutARow() {
jdbc.update("insert into deleted_user (user_id) values (21)");
assertThat(tryLogin(21L, STEAM_ID)).isZero();
assertThat(repository.findById(21L)).isEmpty();
}
@Test
void tryStartLoginSync_BlockedUser_IsRefusedWithoutARow() {
jdbc.update("insert into blocked_user (user_id) values (22)");
assertThat(tryLogin(22L, STEAM_ID)).isZero();
assertThat(repository.findById(22L)).isEmpty();
}
private Instant lastSuccessAt(long userId) {
Timestamp value = jdbc.queryForObject(
"select last_success_at from user_steam_sync where user_id = ?", Timestamp.class, userId);
return value == null ? null : value.toInstant();
}
@Test
void markFinished_Ok_SetsLastSuccessAt() {
Instant started = Instant.now();
repository.tryClaimRun(23L, STEAM_ID, started, started.minusSeconds(600));
repository.markFinished(23L, started, "OK", Instant.now());
assertThat(lastSuccessAt(23L)).isNotNull();
}
@Test
void markFinished_Failed_KeepsLastSuccessAt() {
Instant started = Instant.now();
repository.tryClaimRun(24L, STEAM_ID, started, started.minusSeconds(600));
repository.markFinished(24L, started, "OK", Instant.now().minusSeconds(3600));
Instant success = lastSuccessAt(24L);
Instant secondRun = Instant.now();
repository.tryClaimRun(24L, STEAM_ID, secondRun, secondRun.minusSeconds(600));
repository.markFinished(24L, secondRun, "FAILED", Instant.now());
assertThat(lastSuccessAt(24L)).isEqualTo(success);
}
} }
@@ -19,7 +19,9 @@ import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
@@ -53,72 +55,107 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
ReflectionTestUtils.setField(syncService, "syncTtlHours", 24L); ReflectionTestUtils.setField(syncService, "syncTtlHours", 20L);
ReflectionTestUtils.setField(syncService, "forceSyncCooldownSeconds", 60L); ReflectionTestUtils.setField(syncService, "forceSyncCooldownSeconds", 60L);
ReflectionTestUtils.setField(syncService, "runningStaleMinutes", 5L); ReflectionTestUtils.setField(syncService, "runningStaleMinutes", 5L);
ReflectionTestUtils.setField(syncService, "failedRetryMinutes", 15L); ReflectionTestUtils.setField(syncService, "loginRetryMinutes", 60L);
ReflectionTestUtils.setField(syncService, "zone", ZoneId.of("Europe/Berlin")); ReflectionTestUtils.setField(syncService, "zone", ZoneId.of("Europe/Berlin"));
} }
private void givenSync(String steamId, UserSteamSync.Status status, Duration age) { private void givenSync(String steamId, UserSteamSync.Status status, Duration successAge, Duration triedAge) {
UserSteamSync sync = new UserSteamSync(USER_ID, steamId); UserSteamSync sync = new UserSteamSync(USER_ID, steamId);
sync.setLastSyncStatus(status); sync.setLastSyncStatus(status);
sync.setLastSyncedAt(Instant.now().minus(age)); sync.setLastSyncedAt(Instant.now());
sync.setSyncStartedAt(Instant.now().minus(age)); sync.setSyncStartedAt(Instant.now().minus(Duration.ofSeconds(10)));
sync.setLastSuccessAt(successAge == null ? null : Instant.now().minus(successAge));
sync.setLoginSyncTriedAt(triedAge == null ? null : Instant.now().minus(triedAge));
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(sync)); when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(sync));
} }
private void givenGate(int result) {
when(userSteamSyncRepository.tryStartLoginSync(eq(USER_ID), eq(STEAM_ID), any(), any(), any(), any()))
.thenReturn(result);
}
private void verifyGateNotCalled() {
verify(userSteamSyncRepository, never()).tryStartLoginSync(anyLong(), anyString(), any(), any(), any(), any());
}
@Test @Test
void syncIfStale_NoSyncYet_Runs() { void syncIfStale_NoSyncYet_AsksTheGateAndRuns() {
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.empty()); when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.empty());
givenGate(1);
syncService.syncIfStale(USER_ID, STEAM_ID); assertTrue(syncService.syncIfStale(USER_ID, STEAM_ID));
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO); verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.LOGIN);
} }
@Test @Test
void syncIfStale_FreshOkSync_Skips() { void syncIfStale_SuccessNineteenHoursAgo_SkipsWithoutAWrite() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofMinutes(10)); givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofHours(19), null);
syncService.syncIfStale(USER_ID, STEAM_ID); assertFalse(syncService.syncIfStale(USER_ID, STEAM_ID));
verifyGateNotCalled();
verify(worker, never()).runSync(anyLong(), anyString(), any());
}
@Test
void syncIfStale_StaleButTriedThirtyMinutesAgo_SkipsWithoutAWrite() {
givenSync(STEAM_ID, UserSteamSync.Status.FAILED, Duration.ofHours(21), Duration.ofMinutes(30));
assertFalse(syncService.syncIfStale(USER_ID, STEAM_ID));
verifyGateNotCalled();
verify(worker, never()).runSync(anyLong(), anyString(), any());
}
@Test
void syncIfStale_StaleAndTriedLongAgo_GateWins_RunsALoginSync() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofHours(21), Duration.ofMinutes(61));
givenGate(1);
assertTrue(syncService.syncIfStale(USER_ID, STEAM_ID));
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.LOGIN);
}
@Test
void syncIfStale_NeverSucceeded_GateWins_Runs() {
givenSync(STEAM_ID, UserSteamSync.Status.FAILED, null, null);
givenGate(1);
assertTrue(syncService.syncIfStale(USER_ID, STEAM_ID));
}
@Test
void syncIfStale_GateLoses_DoesNotRun() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofHours(21), null);
givenGate(0);
assertFalse(syncService.syncIfStale(USER_ID, STEAM_ID));
verify(worker, never()).runSync(anyLong(), anyString(), any()); verify(worker, never()).runSync(anyLong(), anyString(), any());
} }
@Test @Test
void syncIfStale_FreshSyncForOtherSteamId_Runs() { void syncIfStale_FreshSuccessForOtherSteamId_AsksTheGate() {
givenSync("76561198005679790", UserSteamSync.Status.OK, Duration.ofMinutes(10)); givenSync("76561198005679790", UserSteamSync.Status.OK, Duration.ofHours(1), Duration.ofMinutes(5));
givenGate(1);
syncService.syncIfStale(USER_ID, STEAM_ID); assertTrue(syncService.syncIfStale(USER_ID, STEAM_ID));
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO); verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.LOGIN);
} }
@Test @Test
void syncIfStale_FailedSyncAfterRetryInterval_Runs() { void syncIfStale_RunningForOtherSteamId_SkipsTheGateAndReportsTheRunningSync() {
givenSync(STEAM_ID, UserSteamSync.Status.FAILED, Duration.ofMinutes(16)); givenSync("76561198005679790", UserSteamSync.Status.RUNNING, Duration.ofHours(30), null);
syncService.syncIfStale(USER_ID, STEAM_ID); assertTrue(syncService.syncIfStale(USER_ID, STEAM_ID));
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
}
@Test
void syncIfStale_FailedSyncWithinRetryInterval_Skips() {
givenSync(STEAM_ID, UserSteamSync.Status.FAILED, Duration.ofMinutes(5));
syncService.syncIfStale(USER_ID, STEAM_ID);
verify(worker, never()).runSync(anyLong(), anyString(), any());
}
@Test
void syncIfStale_RunningForOtherSteamId_Skips() {
givenSync("76561198005679790", UserSteamSync.Status.RUNNING, Duration.ofSeconds(10));
syncService.syncIfStale(USER_ID, STEAM_ID);
verifyGateNotCalled();
verify(worker, never()).runSync(anyLong(), anyString(), any()); verify(worker, never()).runSync(anyLong(), anyString(), any());
} }
@@ -136,6 +173,54 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
order.verify(finishedEstimateService).fillMissing(); order.verify(finishedEstimateService).fillMissing();
} }
@Test
void syncAllNightly_SkipsOnlyAccountsWithAnOkSyncOfTodayAfter23() {
ZoneId berlin = ZoneId.of("Europe/Berlin");
LocalDate today = LocalDate.now(berlin);
UserSteamSync late = new UserSteamSync(1L, "76561198000000001");
late.setLastSuccessAt(today.atTime(23, 0).atZone(berlin).toInstant());
UserSteamSync evening = new UserSteamSync(2L, "76561198005679790");
evening.setLastSuccessAt(today.atTime(22, 59).atZone(berlin).toInstant());
UserSteamSync yesterdayLate = new UserSteamSync(3L, "76561198005679791");
yesterdayLate.setLastSuccessAt(today.minusDays(1).atTime(23, 30).atZone(berlin).toInstant());
UserSteamSync never = new UserSteamSync(4L, "76561198005679792");
when(userSteamSyncRepository.findByUserSeenAtAfter(any()))
.thenReturn(List.of(late, evening, yesterdayLate, never));
syncService.syncAllNightly();
verify(worker, never()).runSyncNow(eq(1L), anyString(), any());
verify(worker).runSyncNow(2L, "76561198005679790", SyncTrigger.AUTO);
verify(worker).runSyncNow(3L, "76561198005679791", SyncTrigger.AUTO);
verify(worker).runSyncNow(4L, "76561198005679792", SyncTrigger.AUTO);
}
@Test
void syncIfStale_GateLost_OtherRequestJustStartedTheSync_ReportsItAsStarted() {
UserSteamSync before = new UserSteamSync(USER_ID, STEAM_ID);
before.setLastSuccessAt(Instant.now().minus(Duration.ofHours(21)));
UserSteamSync after = new UserSteamSync(USER_ID, STEAM_ID);
after.setLastSuccessAt(before.getLastSuccessAt());
after.setLoginSyncTriedAt(Instant.now());
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(before), Optional.of(after));
givenGate(0);
assertTrue(syncService.syncIfStale(USER_ID, STEAM_ID));
verify(worker, never()).runSync(anyLong(), anyString(), any());
}
@Test
void syncIfStale_TriedJustNowAndWorkerNotClaimedYet_ReportsItAsStarted() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofHours(21), Duration.ofSeconds(1));
UserSteamSync sync = userSteamSyncRepository.findById(USER_ID).orElseThrow();
sync.setLastSyncedAt(Instant.now().minus(Duration.ofHours(21)));
assertTrue(syncService.syncIfStale(USER_ID, STEAM_ID));
verifyGateNotCalled();
}
@Test @Test
void syncAllNightly_SnapshotFails_TheFinishDatesAreStillEstimated() { void syncAllNightly_SnapshotFails_TheFinishDatesAreStillEstimated() {
when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of()); when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of());
@@ -174,11 +259,11 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
void syncIfStale_MarksTheUserAsSeenAtMostHourly() { void syncIfStale_MarksTheUserAsSeenAtMostHourly() {
UserSteamSync seenLongAgo = new UserSteamSync(USER_ID, STEAM_ID); UserSteamSync seenLongAgo = new UserSteamSync(USER_ID, STEAM_ID);
seenLongAgo.setLastSyncStatus(UserSteamSync.Status.OK); seenLongAgo.setLastSyncStatus(UserSteamSync.Status.OK);
seenLongAgo.setLastSyncedAt(Instant.now()); seenLongAgo.setLastSuccessAt(Instant.now());
seenLongAgo.setUserSeenAt(Instant.now().minus(Duration.ofDays(2))); seenLongAgo.setUserSeenAt(Instant.now().minus(Duration.ofDays(2)));
UserSteamSync seenJustNow = new UserSteamSync(USER_ID, STEAM_ID); UserSteamSync seenJustNow = new UserSteamSync(USER_ID, STEAM_ID);
seenJustNow.setLastSyncStatus(UserSteamSync.Status.OK); seenJustNow.setLastSyncStatus(UserSteamSync.Status.OK);
seenJustNow.setLastSyncedAt(Instant.now()); seenJustNow.setLastSuccessAt(Instant.now());
seenJustNow.setUserSeenAt(Instant.now()); seenJustNow.setUserSeenAt(Instant.now());
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(seenLongAgo), Optional.of(seenJustNow)); when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(seenLongAgo), Optional.of(seenJustNow));
@@ -192,6 +192,15 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("FAILED"), any()); verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("FAILED"), any());
} }
@Test
void runSync_LoginTrigger_WritesTheResultWithThatTrigger() {
steamReturns(game(100, 0), null);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.LOGIN);
assertThat(replacedChanges(SyncTrigger.LOGIN, 1)).isEmpty();
}
@Test @Test
void runSync_WithGames_MarksOk() { void runSync_WithGames_MarksOk() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1); when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
@@ -0,0 +1,41 @@
package core_service.shared.security;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LoginStateTest extends AbstractUnitTest {
private static final Instant CHANGE = Instant.parse("2026-10-04T12:00:00Z");
@Test
void none_AcceptsAnyToken() {
assertTrue(LoginState.NONE.accepts(CHANGE));
assertTrue(LoginState.NONE.accepts(null));
}
@Test
void blocked_RefusesEveryToken() {
assertFalse(new LoginState(true, null).accepts(CHANGE));
}
@Test
void tokenFromBeforeTheRoleChange_IsRefused() {
assertFalse(new LoginState(false, CHANGE).accepts(CHANGE.minusSeconds(1)));
}
@Test
void tokenFromTheChangeSecondOrLater_IsAccepted() {
assertTrue(new LoginState(false, CHANGE).accepts(CHANGE));
assertTrue(new LoginState(false, CHANGE).accepts(CHANGE.plusSeconds(1)));
}
@Test
void tokenWithoutIssueTime_IsRefusedAfterARoleChange() {
assertFalse(new LoginState(false, CHANGE).accepts(null));
}
}
@@ -9,6 +9,7 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.UncheckedIOException; import java.io.UncheckedIOException;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.time.Instant;
import java.util.Date; import java.util.Date;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
@@ -78,6 +79,15 @@ public final class ContractTokens {
return sign(login(userId, role, null)); return sign(login(userId, role, null));
} }
/**
* A login token with a set {@code iat}, as Auth signs it.
*/
public static String loginToken(long userId, Role role, Instant issuedAt) {
Map<String, Object> claims = login(userId, role, null);
claims.put("iat", issuedAt.getEpochSecond());
return sign(claims);
}
public static String loginToken(long userId) { public static String loginToken(long userId) {
return loginToken(userId, Role.USER); return loginToken(userId, Role.USER);
} }
@@ -37,12 +37,12 @@
"applyStates": { "applyStates": {
"method": "POST", "method": "POST",
"path": "/internal/accounts/states", "path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true}]} "request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true, "roleChangedAt": "2026-10-04T12:00:00Z"}]}
}, },
"replaceStates": { "replaceStates": {
"method": "PUT", "method": "PUT",
"path": "/internal/accounts/states", "path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false}]} "request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null}]}
}, },
"purge": { "purge": {
"method": "POST", "method": "POST",