Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10b7dc2272 | ||
|
|
35b1db45e6 |
No files matched your search
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+4
-1
@@ -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",
|
||||||
|
|||||||
Reference in new issue
Block a user