Compare commits

..
5 Commits
Author SHA1 Message Date
lobo 216c1af62f feat(import): source values for exports and bookmarklet
Build, Test & Push Core Service / docker (push) Successful in 1m33s
Build, Test & Push Core Service / quality-check (push) Successful in 1m9s
2026-09-30 15:59:05 +02:00
lobo e347b7aac8 chore(security)!: refuse tokens without aud 2026-09-30 15:00:47 +02:00
lobo 23bf5c41b4 chore(library)!: drop the full-replace PUT of a backlog entry
The Frontend sends PATCH since item 27 batch 2.
2026-09-30 15:00:47 +02:00
lobo 34d64e7215 refactor: item 29 cleanup
C14 C16 C17 C18 C20 A11 A15 X10 X12 X15: dead Steam count code, GameNames, steamIdText, one query per game for the finished estimate, no API versioning config, open-in-view off, CORS from APP_FRONTEND_URL, non-root image, prune only up to the highest known id.
2026-09-30 15:00:47 +02:00
lobo 504c935b27 feat(account): Steam ID and blocked state from Auth (V25)
A blocked user gets no sync and leaves the landing stats. A new or removed Steam ID stops the nightly sync of the old one. No token check in Core: the token lives until it expires.
2026-09-30 15:00:47 +02:00
44 changed files with 512 additions and 428 deletions

No files matched your search

+4
View File
@@ -36,5 +36,9 @@ EXPOSE 8090
# Copy the compiled jar file from the builder stage # Copy the compiled jar file from the builder stage
COPY --from=builder /app/target/*.jar app.jar COPY --from=builder /app/target/*.jar app.jar
# No root in the container
RUN addgroup -S app && adduser -S app -G app
USER app
# Run the application # Run the application
ENTRYPOINT ["java", "-jar", "app.jar"] ENTRYPOINT ["java", "-jar", "app.jar"]
+33 -15
View File
@@ -76,13 +76,12 @@ To Be Played is split into three repositories:
## Authentication ## Authentication
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's
JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Each load replaces
until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. The token comes from Auth's the key set: a key that left the JWKS stops working. `JWT_PUBLIC_KEY` is used only until the first JWKS load works.
`tbp_access` cookie or a `Bearer` header, whichever verifies first. Every token names its kind in `aud` The token comes from Auth's `tbp_access` cookie or a `Bearer` header, whichever verifies first. Every token names its kind in `aud`
(`tbp-login`, `tbp-public-profile`, `tbp-internal`, with `iss=tbp-auth`); a login's `sub` is the user id. Core reads (`tbp-login`, `tbp-public-profile`, `tbp-internal`, with `iss=tbp-auth`); a login's `sub` is the user id. Core reads
`steamId` and the active `role` (missing or unknown = USER) from the claims and grants that role and every lower one `steamId` and the active `role` (missing or unknown = USER) from the claims and grants that role and every lower one
(`USER < AUDITOR < PLATFORM_ADMIN`). A token without `aud` comes from an Auth from before item 26 (kind in `typ`, (`USER < AUDITOR < PLATFORM_ADMIN`). A token without `aud` is refused. The claims are pinned in
user id in `userId`) and is still accepted; item 29 removes that path. The claims are pinned in
`src/test/resources/contract/auth-core-contract.json`, the same file as in Auth; the tests build tokens only from it. `src/test/resources/contract/auth-core-contract.json`, the same file as in Auth; the tests build tokens only from it.
`POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the
frontend origin. frontend origin.
@@ -98,24 +97,31 @@ only guard.
Every service method a controller calls carries one role marker, on the method or on its class (the method wins): Every service method a controller calls carries one role marker, on the method or on its class (the method wins):
`@RequiresRole(Role.X)`, `@Public` (no login, e.g. landing stats; the public profile checks its ticket) or `@RequiresRole(Role.X)`, `@Public` (no login, e.g. landing stats; the public profile checks its ticket) or
`@Internal` (only Auth's service token). A Spring Security method interceptor checks it while a request is served; `@Internal` (only Auth's service token). A Spring Security method interceptor checks it while a request is served;
scheduled jobs and background work run without a user and are not checked. A denied call answers `401` without a scheduled jobs and background work run without a user and are not checked. That stays on purpose: the sync
worker calls `SteamApiService` and the import worker `ManualGameService`, both `@RequiresRole(USER)`, without a
request. Auth denies instead, because it has no such caller. A denied call answers `401` without a
login, else `403`. The URL rules in `SecurityConfig` stay as the outer guard. ArchUnit fails the build for an login, else `403`. The URL rules in `SecurityConfig` stay as the outer guard. ArchUnit fails the build for an
unmarked method, two markers, a controller calling an `@Async` method (the check would never run), and a service unmarked method, two markers, a controller calling an `@Async` method (the check would never run), and a service
method calling another service's method that needs more. method calling another service's method that needs more.
**Mirrored in Auth.** These files exist in both services with small differences; a change in one needs the same
change in the other: `ClientHeaderFilter`, `RoleAuthorizationManager`, `MethodSecurityConfig`, `Role`, `RequiresRole`,
`Public` (only Core has `Internal`), `UserPrincipal`, the login token check in `JwtService.parseLoginToken`, the
ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/auth-core-contract.json`
(`ToBePlayed-Deployment/scripts/check-contract.sh` compares the two).
## API ## API
| Method | Path | Auth | | Method | Path | Auth |
|---|---|---| |---|---|---|
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT | | `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT | | `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
| `PUT` | `/api/library/games/{appid}/backlog` (replaces all four; removed in item 29) | JWT |
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR | | `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
| `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR | | `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT | | `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
| `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT | | `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT |
| `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; `409 MANUAL_GAME` for a game added by hand) | JWT | | `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; `409 MANUAL_GAME` for a game added by hand) | JWT |
| `POST` | `/api/library/imports` (`{source: TEXT\|CSV\|SEARCH, rows: [{title, platform, playtimeMinutes, status, igdbId}]}`, 1–1000 rows; `202 {id}` in `QUEUED`, `409 IMPORT_RUNNING`, `503 IGDB_OFF`) | JWT | | `POST` | `/api/library/imports` (`{source: TEXT\|CSV\|SEARCH\|GALAXY\|GROUVEE\|HLTB\|PLAYNITE\|BOOKMARKLET, rows: [{title, platform, playtimeMinutes, status, igdbId}]}`, 1–1000 rows; `202 {id}` in `QUEUED`, `409 IMPORT_RUNNING`, `503 IGDB_OFF`) | JWT |
| `GET` | `/api/library/imports` (`{imports}`, the last 20), `/api/library/imports/{id}` (status, progress counts, `ahead` while it waits in a queue, `attempts`; the rows with their candidates while `READY`) | JWT | | `GET` | `/api/library/imports` (`{imports}`, the last 20), `/api/library/imports/{id}` (status, progress counts, `ahead` while it waits in a queue, `attempts`; the rows with their candidates while `READY`) | JWT |
| `POST` | `/api/library/imports/{id}/save` (`{rows: [{lineNo, igdbId, platform, status, playtimeMinutes}]}`; `202`, then poll until `SAVED`; `409 IMPORT_NOT_READY`) | JWT | | `POST` | `/api/library/imports/{id}/save` (`{rows: [{lineNo, igdbId, platform, status, playtimeMinutes}]}`; `202`, then poll until `SAVED`; `409 IMPORT_NOT_READY`) | JWT |
| `POST` | `/api/library/imports/{id}/retry` (a `FAILED` import; `202`, `409 IMPORT_NOT_FAILED` / `IMPORT_RUNNING`) | JWT | | `POST` | `/api/library/imports/{id}/retry` (a `FAILED` import; `202`, `409 IMPORT_NOT_FAILED` / `IMPORT_RUNNING`) | JWT |
@@ -129,6 +135,7 @@ method calling another service's method that needs more.
| `GET` | `/api/public/profiles/library` | profile ticket | | `GET` | `/api/public/profiles/library` | profile ticket |
| `GET` | `/api/stats/public` | public | | `GET` | `/api/stats/public` | 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, blocked}]}`; 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 |
@@ -173,9 +180,9 @@ flowchart LR
Igdb -->|"OAuth token"| Twitch["Twitch"] Igdb -->|"OAuth token"| Twitch["Twitch"]
Speedrun --> SpeedrunApi["speedrun.com API"] Speedrun --> SpeedrunApi["speedrun.com API"]
LibRepo --> Db[("PostgreSQL tbp_core")] LibRepo --> Db[("PostgreSQL tbp_core")]
Stats -->|"listed_user_game, deleted_user"| Db Stats -->|"listed_user_game, deleted_user, blocked_user"| Db
Auth -->|"service token<br/>/internal/accounts"| Account Auth -->|"service token<br/>/internal/accounts"| Account
Account -->|"per-user rows, deleted_user"| Db Account -->|"per-user rows, deleted_user, blocked_user, user_seen_at"| Db
``` ```
| Module | Content | | Module | Content |
@@ -183,15 +190,16 @@ 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` | Deleted accounts, driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows | | `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, 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 |
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in **One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game` and `deleted_user`; `account` deletes SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game`, `deleted_user` and `blocked_user`; `account`
the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes into deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
its list); `library` reads `deleted_user`. A module that moves to its own service takes these reads along as calls. into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library`
reads `deleted_user` and `blocked_user`. A module that moves to its own service takes these reads along as calls.
The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view
`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. `listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view.
@@ -222,7 +230,7 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
dates finished games by the unlock of their ending achievement dates finished games by the unlock of their ending achievement
(`FinishedEstimateService`, `user_game.finished_at_estimate`). (`FinishedEstimateService`, `user_game.finished_at_estimate`).
3. `LibrarySyncWorker` runs on the background pool. `user_steam_sync` lets only one run per user start, and none for 3. `LibrarySyncWorker` runs on the background pool. `user_steam_sync` lets only one run per user start, and none for
a user in `deleted_user`. a user in `deleted_user` or `blocked_user`.
4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds 4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds
the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
owns. The growth counts at most Steam's playtime of the last two weeks, so a game seen for the first time brings owns. The growth counts at most Steam's playtime of the last two weeks, so a game seen for the first time brings
@@ -310,6 +318,16 @@ flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned
user ids, which removes the rows of users Auth does not know (refused for more than user ids, which removes the rows of users Auth does not know (refused for more than
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows. `app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
### Blocked users and Steam ID changes
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 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
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
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
nightly sync (23:55) runs with the old ID. Unblock and the nightly `PUT` lift the block.
### Manual games next to Steam games ### Manual games next to Steam games
A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games and manual games share A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games and manual games share
@@ -17,7 +17,7 @@ public class CacheConfig {
@Bean @Bean
public CacheManager cacheManager() { public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats", "steamPlayerSummary", CaffeineCacheManager cacheManager = new CaffeineCacheManager("publicStats", "steamPlayerSummary",
"igdbSearch"); "igdbSearch");
cacheManager.setCaffeine(Caffeine.newBuilder() cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES) .expireAfterWrite(10, TimeUnit.MINUTES)
@@ -10,7 +10,6 @@ import io.jsonwebtoken.Jwts;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -18,9 +17,8 @@ import java.util.stream.Collectors;
/** /**
* Verifies JWTs issued by tbp-auth-service. Verify-only: {@link JwksKeyLocator} finds the Ed25519 public key. * Verifies JWTs issued by tbp-auth-service. Verify-only: {@link JwksKeyLocator} finds the Ed25519 public key.
* <p> * <p>
* A token names its kind in {@code aud}, and {@code sub} is the user id of a login. A token without {@code aud} comes * A token names its kind in {@code aud}, and {@code sub} is the user id of a login. A token without {@code aud} is
* from an Auth from before item 26: its kind is in {@code typ} and the user id in {@code userId}. Item 29 removes * refused.
* that path.
*/ */
@Service @Service
public class JwtService implements ProfileTicketVerifier { public class JwtService implements ProfileTicketVerifier {
@@ -29,10 +27,6 @@ public class JwtService implements ProfileTicketVerifier {
static final String LOGIN_AUDIENCE = "tbp-login"; static final String LOGIN_AUDIENCE = "tbp-login";
static final String PROFILE_TICKET_AUDIENCE = "tbp-public-profile"; static final String PROFILE_TICKET_AUDIENCE = "tbp-public-profile";
static final String SERVICE_AUDIENCE = "tbp-internal"; static final String SERVICE_AUDIENCE = "tbp-internal";
// The kind of a token before item 26; item 29 removes it.
private static final String TYPE_CLAIM = "typ";
private static final String PROFILE_TICKET_TYPE = "public-profile";
private static final String SERVICE_TOKEN_TYPE = "service";
private static final String ROLE_CLAIM = "role"; private static final String ROLE_CLAIM = "role";
private final JwtParser parser; private final JwtParser parser;
@@ -45,18 +39,9 @@ public class JwtService implements ProfileTicketVerifier {
return parser.parseSignedClaims(token).getPayload(); return parser.parseSignedClaims(token).getPayload();
} }
private static boolean hasAudience(Claims claims) { private static boolean isKind(Claims claims, String audience) {
return claims.getAudience() != null && !claims.getAudience().isEmpty(); return claims.getAudience() != null && claims.getAudience().contains(audience)
} && ISSUER.equals(claims.getIssuer());
/**
* @param legacyType the {@code typ} of this kind before item 26, null for a login
*/
private static boolean isKind(Claims claims, String audience, String legacyType) {
if (hasAudience(claims)) {
return claims.getAudience().contains(audience) && ISSUER.equals(claims.getIssuer());
}
return Objects.equals(legacyType, claims.get(TYPE_CLAIM, String.class));
} }
/** /**
@@ -66,11 +51,10 @@ public class JwtService implements ProfileTicketVerifier {
public Optional<UserPrincipal> parseLoginToken(String token) { public Optional<UserPrincipal> parseLoginToken(String token) {
try { try {
Claims claims = claims(token); Claims claims = claims(token);
if (!isKind(claims, LOGIN_AUDIENCE, null)) { if (!isKind(claims, LOGIN_AUDIENCE)) {
return Optional.empty(); return Optional.empty();
} }
String userId = hasAudience(claims) ? claims.getSubject() String userId = claims.getSubject();
: claims.getSubject() == null ? null : claims.get("userId", String.class);
if (userId == null) { if (userId == null) {
return Optional.empty(); return Optional.empty();
} }
@@ -87,7 +71,7 @@ public class JwtService implements ProfileTicketVerifier {
public boolean isServiceToken(String token) { public boolean isServiceToken(String token) {
try { try {
Claims claims = claims(token); Claims claims = claims(token);
return isKind(claims, SERVICE_AUDIENCE, SERVICE_TOKEN_TYPE) && claims.getExpiration() != null; return isKind(claims, SERVICE_AUDIENCE) && claims.getExpiration() != null;
} catch (Exception e) { } catch (Exception e) {
return false; return false;
} }
@@ -101,7 +85,7 @@ public class JwtService implements ProfileTicketVerifier {
public Optional<ProfileTicket> verifyProfileTicket(String ticket) { public Optional<ProfileTicket> verifyProfileTicket(String ticket) {
try { try {
Claims claims = claims(ticket); Claims claims = claims(ticket);
if (!isKind(claims, PROFILE_TICKET_AUDIENCE, PROFILE_TICKET_TYPE)) { if (!isKind(claims, PROFILE_TICKET_AUDIENCE)) {
return Optional.empty(); return Optional.empty();
} }
long userId = Long.parseLong(claims.get("profileUserId", String.class)); long userId = Long.parseLong(claims.get("profileUserId", String.class));
@@ -1,11 +1,14 @@
package core_service.modules.account.api; package core_service.modules.account.api;
import core_service.modules.account.api.dto.AccountStatesRequest;
import core_service.modules.account.api.dto.CleanupResultDto; import core_service.modules.account.api.dto.CleanupResultDto;
import core_service.modules.account.api.dto.KnownUsersRequest; import core_service.modules.account.api.dto.KnownUsersRequest;
import core_service.modules.account.api.dto.PurgeRequest; import core_service.modules.account.api.dto.PurgeRequest;
import core_service.modules.account.api.dto.UserIdsRequest; import core_service.modules.account.api.dto.UserIdsRequest;
import core_service.modules.account.domain.AccountState;
import core_service.modules.account.domain.CleanupResult; import core_service.modules.account.domain.CleanupResult;
import core_service.modules.account.service.AccountCleanupService; import core_service.modules.account.service.AccountCleanupService;
import core_service.modules.account.service.AccountStateService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -15,12 +18,15 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController @RestController
@RequestMapping("/internal/accounts") @RequestMapping("/internal/accounts")
@RequiredArgsConstructor @RequiredArgsConstructor
public class InternalAccountController { public class InternalAccountController {
private final AccountCleanupService accountCleanupService; private final AccountCleanupService accountCleanupService;
private final AccountStateService accountStateService;
@PostMapping("/deleted") @PostMapping("/deleted")
public ResponseEntity<Void> markDeleted(@Valid @RequestBody UserIdsRequest request) { public ResponseEntity<Void> markDeleted(@Valid @RequestBody UserIdsRequest request) {
@@ -44,6 +50,24 @@ public class InternalAccountController {
return ResponseEntity.ok(toDto(accountCleanupService.prune(request.knownUserIds(), request.dryRun()))); return ResponseEntity.ok(toDto(accountCleanupService.prune(request.knownUserIds(), request.dryRun())));
} }
@PostMapping("/states")
public ResponseEntity<Void> applyStates(@Valid @RequestBody AccountStatesRequest request) {
accountStateService.apply(toStates(request));
return ResponseEntity.noContent().build();
}
@PutMapping("/states")
public ResponseEntity<Void> replaceStates(@Valid @RequestBody AccountStatesRequest request) {
accountStateService.replaceAll(toStates(request));
return ResponseEntity.noContent().build();
}
private static List<AccountState> toStates(AccountStatesRequest request) {
return request.accounts().stream()
.map(account -> new AccountState(account.userId(), account.steamId(), account.blocked()))
.toList();
}
private static CleanupResultDto toDto(CleanupResult result) { private static CleanupResultDto toDto(CleanupResult result) {
return new CleanupResultDto(result.dryRun(), result.users(), result.rows(), result.refused()); return new CleanupResultDto(result.dryRun(), result.users(), result.rows(), result.refused());
} }
@@ -0,0 +1,12 @@
package core_service.modules.account.api.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.util.List;
public record AccountStatesRequest(@NotNull List<@Valid AccountStateDto> accounts) {
public record AccountStateDto(@NotNull Long userId, Long steamId, boolean blocked) {
}
}
@@ -0,0 +1,9 @@
package core_service.modules.account.domain;
/**
* 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)
*/
public record AccountState(long userId, Long steamId, boolean blocked) {
}
@@ -6,6 +6,7 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Collection; import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -21,11 +22,11 @@ public class AccountCleanupRepository {
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last. // Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
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",
"user_steam_sync", "user_game", "game_import", "deleted_user"); "user_steam_sync", "user_game", "game_import", "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", "deleted_user"); "game_import", "blocked_user", "deleted_user");
private final NamedParameterJdbcTemplate jdbc; private final NamedParameterJdbcTemplate jdbc;
@@ -90,13 +91,15 @@ public class AccountCleanupRepository {
} }
/** /**
* Users that have rows in Core but are not in the list. * Users that have rows in Core but are not in the list. Ids above the highest known one are left out: Auth's
* ids never come back, so such a user registered after Auth read the list.
* *
* @param knownUserIds not empty * @param knownUserIds not empty
*/ */
public List<Long> findOrphans(Collection<Long> knownUserIds) { public List<Long> findOrphans(Collection<Long> knownUserIds) {
String union = String.join(" union ", ORPHAN_SOURCES.stream().map(table -> "select user_id from " + table).toList()); String union = String.join(" union ", ORPHAN_SOURCES.stream().map(table -> "select user_id from " + table).toList());
return jdbc.queryForList("select user_id from (" + union + ") u where user_id not in (:ids) order by user_id", return jdbc.queryForList("select user_id from (" + union + ") u where user_id not in (:ids) "
ids(knownUserIds), Long.class); + "and user_id < :highest order by user_id",
ids(knownUserIds).addValue("highest", Collections.max(knownUserIds)), Long.class);
} }
} }
@@ -0,0 +1,54 @@
package core_service.modules.account.repo;
import core_service.modules.account.domain.AccountState;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
/**
* Blocks in blocked_user, and what a Steam ID change means for user_steam_sync. Plain SQL, because the library
* module owns the entities.
*/
@Repository
@RequiredArgsConstructor
public class AccountStateRepository {
private final NamedParameterJdbcTemplate jdbc;
public void apply(AccountState state) {
MapSqlParameterSource params = new MapSqlParameterSource("userId", state.userId())
.addValue("steamId", state.steamId() == null ? null : String.valueOf(state.steamId()));
if (state.blocked()) {
jdbc.update("insert into blocked_user (user_id) values (:userId) on conflict do nothing", params);
} else {
jdbc.update("delete from blocked_user where user_id = :userId", params);
}
if (state.blocked() || state.steamId() == null) {
jdbc.update("update user_steam_sync set user_seen_at = null where user_id = :userId and user_seen_at is not null",
params);
} else {
// A new ID: no nightly sync with the old one, and the next visit syncs at once. steam_id stays, so that
// sync sees the change and drops the old account's games.
jdbc.update("""
update user_steam_sync set user_seen_at = null, last_synced_at = null
where user_id = :userId and steam_id is distinct from :steamId""", params);
}
}
/**
* After this, exactly the blocked users of the list are blocked.
*/
public void replaceAll(Collection<AccountState> states) {
states.forEach(this::apply);
List<Long> blocked = states.stream().filter(AccountState::blocked).map(AccountState::userId).toList();
if (blocked.isEmpty()) {
jdbc.update("delete from blocked_user", new MapSqlParameterSource());
} else {
jdbc.update("delete from blocked_user where user_id not in (:ids)", new MapSqlParameterSource("ids", blocked));
}
}
}
@@ -0,0 +1,39 @@
package core_service.modules.account.service;
import core_service.modules.account.domain.AccountState;
import core_service.modules.account.repo.AccountStateRepository;
import core_service.shared.security.Internal;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* Steam ID and block of each user, driven by Auth. Auth sends a change at once and the whole set nightly, so a
* missed call heals by the next night.
*/
@Service
@Internal
@RequiredArgsConstructor
@Slf4j
public class AccountStateService {
private final AccountStateRepository repository;
@Transactional
@CacheEvict(value = "publicStats", allEntries = true)
public void apply(List<AccountState> states) {
states.forEach(repository::apply);
log.info("Account state: {} users updated", states.size());
}
@Transactional
@CacheEvict(value = "publicStats", allEntries = true)
public void replaceAll(List<AccountState> states) {
repository.replaceAll(states);
log.info("Account state: {} users replaced", states.size());
}
}
@@ -3,7 +3,6 @@ package core_service.modules.library.api;
import core_service.shared.api.ErrorResponse; import core_service.shared.api.ErrorResponse;
import core_service.modules.library.api.dto.AchievementSummaryDto; import core_service.modules.library.api.dto.AchievementSummaryDto;
import core_service.modules.library.api.dto.ActivityDto; import core_service.modules.library.api.dto.ActivityDto;
import core_service.modules.library.api.dto.BacklogEntryDto;
import core_service.modules.library.api.dto.BacklogPatch; import core_service.modules.library.api.dto.BacklogPatch;
import core_service.modules.library.api.dto.BrokenImageReport; import core_service.modules.library.api.dto.BrokenImageReport;
import core_service.modules.library.api.dto.CorrectionDto; import core_service.modules.library.api.dto.CorrectionDto;
@@ -57,6 +56,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
@@ -87,11 +87,8 @@ 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();
if (principal.steamId() != null) { principal.steamIdText().ifPresentOrElse(steamId -> syncService.syncIfStale(userId, steamId),
syncService.syncIfStale(userId, String.valueOf(principal.steamId())); () -> syncService.stopNightlySync(userId));
} else {
syncService.stopNightlySync(userId);
}
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));
} }
@@ -108,9 +105,7 @@ public class LibraryController {
@RequestParam(defaultValue = "false") boolean hidden @RequestParam(defaultValue = "false") boolean hidden
) { ) {
Long userId = principal.getIdAsLong(); Long userId = principal.getIdAsLong();
if (principal.steamId() != null) { principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
syncService.syncIfStale(userId, String.valueOf(principal.steamId()));
}
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE; int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
int safePage = Math.max(0, page); int safePage = Math.max(0, page);
@@ -132,11 +127,12 @@ public class LibraryController {
@PostMapping("/sync") @PostMapping("/sync")
public ResponseEntity<?> forceSync(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<?> forceSync(@AuthenticationPrincipal UserPrincipal principal) {
if (principal.steamId() == null) { Optional<String> steamId = principal.steamIdText();
if (steamId.isEmpty()) {
return ResponseEntity.badRequest().body(new ErrorResponse("NO_STEAM_ID", "User has no Steam ID")); return ResponseEntity.badRequest().body(new ErrorResponse("NO_STEAM_ID", "User has no Steam ID"));
} }
try { try {
syncService.forceSync(principal.getIdAsLong(), String.valueOf(principal.steamId())); syncService.forceSync(principal.getIdAsLong(), steamId.get());
return ResponseEntity.accepted().body(Map.of("status", "started")); return ResponseEntity.accepted().body(Map.of("status", "started"));
} catch (SyncCooldownException e) { } catch (SyncCooldownException e) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
@@ -152,8 +148,7 @@ public class LibraryController {
public ResponseEntity<?> getGameDetail(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<?> getGameDetail(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid) { @PathVariable("appid") Long appid) {
Long userId = principal.getIdAsLong(); Long userId = principal.getIdAsLong();
String steamId = principal.steamId() != null ? String.valueOf(principal.steamId()) : null; return gameDetailService.getGameDetail(userId, principal.steamIdText().orElse(null), appid)
return gameDetailService.getGameDetail(userId, steamId, appid)
.<ResponseEntity<?>>map(ResponseEntity::ok) .<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(GAME_NOT_FOUND)); .body(GAME_NOT_FOUND));
@@ -223,16 +218,6 @@ public class LibraryController {
return ResponseEntity.badRequest().body(new ErrorResponse("INVALID_CORRECTION", message)); return ResponseEntity.badRequest().body(new ErrorResponse("INVALID_CORRECTION", message));
} }
@PutMapping("/games/{appid}/backlog")
public ResponseEntity<?> updateBacklog(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid,
@Valid @RequestBody BacklogEntryDto entry) {
return backlogService.update(principal.getIdAsLong(), appid, entry)
.<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(GAME_NOT_FOUND));
}
@PatchMapping("/games/{appid}/backlog") @PatchMapping("/games/{appid}/backlog")
public ResponseEntity<?> patchBacklog(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<?> patchBacklog(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid, @PathVariable("appid") Long appid,
@@ -310,9 +295,7 @@ public class LibraryController {
int safeCount = Math.clamp(count, 1, MAX_PICKER_GAMES); int safeCount = Math.clamp(count, 1, MAX_PICKER_GAMES);
Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null; Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null;
Long userId = principal.getIdAsLong(); Long userId = principal.getIdAsLong();
if (principal.steamId() != null) { principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
syncService.syncIfStale(userId, String.valueOf(principal.steamId()));
}
return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, safeCount)); return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, safeCount));
} }
@@ -18,7 +18,11 @@ public record GameImport(long id, long userId, Source source, Status status, int
int saved, int added, boolean running, int attempts, Step step, Instant createdAt, int saved, int added, boolean running, int attempts, Step step, Instant createdAt,
Instant queuedAt, Instant savedAt) { Instant queuedAt, Instant savedAt) {
public enum Source { TEXT, CSV, SEARCH } /**
* Where the lines came from. GALAXY, GROUVEE, HLTB and PLAYNITE are exports the Frontend reads; BOOKMARKLET is
* the list read on a store page. Core treats them all alike.
*/
public enum Source { TEXT, CSV, SEARCH, GALAXY, GROUVEE, HLTB, PLAYNITE, BOOKMARKLET }
public enum Status { QUEUED, MATCHING, READY, SAVING, SAVED, UNDONE, FAILED } public enum Status { QUEUED, MATCHING, READY, SAVING, SAVED, UNDONE, FAILED }
@@ -0,0 +1,25 @@
package core_service.modules.library.domain;
import java.text.Normalizer;
import java.util.Locale;
/**
* Game names as the matches compare them (import lines, speedrun.com hits).
*/
public final class GameNames {
public static final String TRADEMARK_SIGNS = "[™®©]";
private GameNames() {
}
/**
* Lower case, without accents, ™ ® © and punctuation, words split by one space.
*/
public static String normalize(String name) {
// Before NFKD, which turns ™ into "TM".
String withoutMarks = name.replaceAll(TRADEMARK_SIGNS, "");
String plain = Normalizer.normalize(withoutMarks, Normalizer.Form.NFKD).replaceAll("\\p{M}", "");
return plain.toLowerCase(Locale.ROOT).replaceAll("[^\\p{L}\\p{N}]+", " ").trim();
}
}
@@ -4,11 +4,15 @@ import core_service.modules.library.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameAchievementId; import core_service.modules.library.domain.UserGameAchievementId;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List; import java.util.List;
public interface UserGameAchievementRepository extends JpaRepository<UserGameAchievement, UserGameAchievementId> { public interface UserGameAchievementRepository extends JpaRepository<UserGameAchievement, UserGameAchievementId> {
List<UserGameAchievement> findByUserIdAndAppid(Long userId, Long appid); List<UserGameAchievement> findByUserIdAndAppid(Long userId, Long appid);
List<UserGameAchievement> findByAppidAndApiNameAndUserIdInAndUnlockedTrueAndUnlockedAtNotNull(Long appid,
String apiName, Collection<Long> userIds);
void deleteByUserIdAndAppid(Long userId, Long appid); void deleteByUserIdAndAppid(Long userId, Long appid);
} }
@@ -26,7 +26,8 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
/** /**
* Atomic claim-run upsert. Returns 1 if this caller successfully claimed the run * Atomic claim-run upsert. Returns 1 if this caller successfully claimed the run
* (row created, or existing row was not RUNNING / RUNNING marker stale). * (row created, or existing row was not RUNNING / RUNNING marker stale).
* Returns 0 if another sync is already in flight, or the user is deleted (table deleted_user) — caller must skip. * Returns 0 if another sync is already in flight, or the user is deleted or blocked (deleted_user,
* blocked_user) — caller must skip.
* *
* @param startedAt the run's key for {@link #markFinished}, in microseconds as Postgres stores it * @param startedAt the run's key for {@link #markFinished}, in microseconds as Postgres stores it
*/ */
@@ -35,6 +36,7 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
INSERT INTO user_steam_sync (user_id, steam_id, last_sync_status, sync_started_at) INSERT INTO user_steam_sync (user_id, steam_id, last_sync_status, sync_started_at)
SELECT :userId, :steamId, 'RUNNING', :startedAt SELECT :userId, :steamId, 'RUNNING', :startedAt
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)
ON CONFLICT (user_id) DO UPDATE ON CONFLICT (user_id) DO UPDATE
SET steam_id = EXCLUDED.steam_id, SET steam_id = EXCLUDED.steam_id,
last_sync_status = 'RUNNING', last_sync_status = 'RUNNING',
@@ -24,23 +24,6 @@ public class BacklogService {
private final UserGameRepository userGameRepository; private final UserGameRepository userGameRepository;
/**
* @return empty when the game is not in the user's active library
*/
@Transactional
public Optional<BacklogEntryDto> update(Long userId, Long appid, BacklogEntryDto entry) {
return userGameRepository.findById(new UserGameId(userId, appid))
.filter(game -> game.getRemovedAt() == null)
.map(game -> {
game.setStatus(entry.status());
game.setStatusSetByUser(true);
game.setRating(entry.rating());
game.setNotes(entry.notes() == null || entry.notes().isBlank() ? null : entry.notes());
game.setPriority(entry.priority());
return toDto(game);
});
}
/** /**
* Changes only the fields the patch sends. Only a sent status counts as set by the user, so a save of the notes * Changes only the fields the patch sends. Only a sent status counts as set by the user, so a save of the notes
* alone still lets the sync move the game from BACKLOG to PLAYING. * alone still lets the sync move the game from BACKLOG to PLAYING.
@@ -10,6 +10,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -39,15 +40,17 @@ public class FinishedEstimateService {
for (Map.Entry<Long, List<FinishedWithoutDate>> app : byApp.entrySet()) { for (Map.Entry<Long, List<FinishedWithoutDate>> app : byApp.entrySet()) {
Optional<EndingAchievement> ending = EndingAchievement.of(schemaRepository.findByAppid(app.getKey())); Optional<EndingAchievement> ending = EndingAchievement.of(schemaRepository.findByAppid(app.getKey()));
if (ending.isEmpty()) continue; if (ending.isEmpty()) continue;
String apiName = ending.get().achievement().getApiName(); // One query per game, not per user: most candidates never unlocked the ending and come back every night.
// With the user ids, the primary key (user_id, appid, api_name) serves it.
List<Long> userIds = app.getValue().stream().map(FinishedWithoutDate::userId).toList();
Map<Long, Instant> unlockByUser = userAchievementRepository
.findByAppidAndApiNameAndUserIdInAndUnlockedTrueAndUnlockedAtNotNull(app.getKey(),
ending.get().achievement().getApiName(), userIds).stream()
.collect(Collectors.toMap(UserGameAchievement::getUserId, UserGameAchievement::getUnlockedAt));
for (FinishedWithoutDate game : app.getValue()) { for (FinishedWithoutDate game : app.getValue()) {
Optional<UserGameAchievement> unlock = userAchievementRepository Instant unlockedAt = unlockByUser.get(game.userId());
.findByUserIdAndAppid(game.userId(), game.appid()).stream() if (unlockedAt != null) {
.filter(a -> a.isUnlocked() && a.getUnlockedAt() != null && a.getApiName().equals(apiName)) dated += userGameRepository.setFinishedAtEstimate(game.userId(), game.appid(), unlockedAt);
.findFirst();
if (unlock.isPresent()) {
dated += userGameRepository.setFinishedAtEstimate(game.userId(), game.appid(),
unlock.get().getUnlockedAt());
} }
} }
} }
@@ -1,5 +1,6 @@
package core_service.modules.library.service; package core_service.modules.library.service;
import core_service.modules.library.domain.GameNames;
import core_service.modules.library.domain.GameImport; import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.IgdbGame; import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.repo.GameImportRepository; import core_service.modules.library.repo.GameImportRepository;
@@ -195,7 +196,7 @@ public class GameImportWorker {
* Case, accents, punctuation and marks like ™ ignored, as in the speedrun.com match. * Case, accents, punctuation and marks like ™ ignored, as in the speedrun.com match.
*/ */
static boolean sameName(String a, String b) { static boolean sameName(String a, String b) {
return SpeedrunMatch.normalize(a).equals(SpeedrunMatch.normalize(b)); return GameNames.normalize(a).equals(GameNames.normalize(b));
} }
/** /**
@@ -212,11 +212,9 @@ public class LibraryQueryService {
/** /**
* @param profileStats the stat group names of the profile ticket * @param profileStats the stat group names of the profile ticket
*/
@Public
/**
* @param sectionNames see {@link ProfileSection#shown}; a hidden section runs no query * @param sectionNames see {@link ProfileSection#shown}; a hidden section runs no query
*/ */
@Public
public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats, Collection<String> sectionNames) { public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats, Collection<String> sectionNames) {
Set<ProfileSection> sections = ProfileSection.shown(sectionNames); Set<ProfileSection> sections = ProfileSection.shown(sectionNames);
boolean summary = sections.contains(ProfileSection.SUMMARY); boolean summary = sections.contains(ProfileSection.SUMMARY);
@@ -1,12 +1,11 @@
package core_service.modules.library.service; package core_service.modules.library.service;
import core_service.modules.library.domain.GameNames;
import core_service.modules.speedrun_adapter.service.SpeedrunApiService; import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
import java.text.Normalizer;
import java.util.Collection; import java.util.Collection;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Set; import java.util.Set;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -21,7 +20,6 @@ final class SpeedrunMatch {
static final String WEBLINK_PREFIX = "https://www.speedrun.com/"; static final String WEBLINK_PREFIX = "https://www.speedrun.com/";
// speedrun.com tells remakes apart by the year: "Prey (2017)" next to "Prey". // speedrun.com tells remakes apart by the year: "Prey (2017)" next to "Prey".
private static final String TRADEMARK_SIGNS = "[™®©]";
private static final Pattern YEAR_SUFFIX = Pattern.compile("^(.*?)\\s*\\((\\d{4})\\)$"); private static final Pattern YEAR_SUFFIX = Pattern.compile("^(.*?)\\s*\\((\\d{4})\\)$");
// The column sizes of game_speedrun. // The column sizes of game_speedrun.
private static final int MAX_GAME_ID_LENGTH = 16; private static final int MAX_GAME_ID_LENGTH = 16;
@@ -64,24 +62,14 @@ final class SpeedrunMatch {
* The name for speedrun.com's search, which does not find "DOOM Eternal" for "DOOM Eternal™". * The name for speedrun.com's search, which does not find "DOOM Eternal" for "DOOM Eternal™".
*/ */
static String searchText(String name) { static String searchText(String name) {
return name.replaceAll(TRADEMARK_SIGNS, "").trim(); return name.replaceAll(GameNames.TRADEMARK_SIGNS, "").trim();
}
/**
* Lower case, without accents, ™ ® © and punctuation, words split by one space.
*/
static String normalize(String name) {
// Before NFKD, which turns ™ into "TM".
String withoutMarks = name.replaceAll(TRADEMARK_SIGNS, "");
String plain = Normalizer.normalize(withoutMarks, Normalizer.Form.NFKD).replaceAll("\\p{M}", "");
return plain.toLowerCase(Locale.ROOT).replaceAll("[^\\p{L}\\p{N}]+", " ").trim();
} }
private static NameAndYear split(String name) { private static NameAndYear split(String name) {
Matcher matcher = YEAR_SUFFIX.matcher(name.trim()); Matcher matcher = YEAR_SUFFIX.matcher(name.trim());
return matcher.matches() return matcher.matches()
? new NameAndYear(normalize(matcher.group(1)), Integer.parseInt(matcher.group(2))) ? new NameAndYear(GameNames.normalize(matcher.group(1)), Integer.parseInt(matcher.group(2)))
: new NameAndYear(normalize(name), null); : new NameAndYear(GameNames.normalize(name), null);
} }
private record NameAndYear(String name, Integer year) {} private record NameAndYear(String name, Integer year) {}
@@ -10,7 +10,8 @@ import java.util.Map;
@Repository @Repository
public class StatsRepository { public class StatsRepository {
private static final String NOT_DELETED = "user_id NOT IN (SELECT user_id FROM deleted_user)"; private static final String NOT_DELETED_OR_BLOCKED =
"user_id NOT IN (SELECT user_id FROM deleted_user UNION SELECT user_id FROM blocked_user)";
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
@@ -21,31 +22,31 @@ public class StatsRepository {
public long countDistinctGames() { public long countDistinctGames() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject(
"SELECT COUNT(DISTINCT appid) FROM listed_user_game WHERE " + NOT_DELETED, Long.class); "SELECT COUNT(DISTINCT appid) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public long countLibraryEntries() { public long countLibraryEntries() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject(
"SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED, Long.class); "SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public long countGamesPlayed() { public long countGamesPlayed() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject(
"SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED + " AND playtime_minutes > 0", Long.class); "SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED + " AND playtime_minutes > 0", Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public long sumPlaytimeMinutes() { public long sumPlaytimeMinutes() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject(
"SELECT COALESCE(SUM(playtime_minutes), 0) FROM listed_user_game WHERE " + NOT_DELETED, Long.class); "SELECT COALESCE(SUM(playtime_minutes), 0) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public long countUsersWithLibrary() { public long countUsersWithLibrary() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject(
"SELECT COUNT(DISTINCT user_id) FROM listed_user_game WHERE " + NOT_DELETED, Long.class); "SELECT COUNT(DISTINCT user_id) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
@@ -55,7 +56,7 @@ public class StatsRepository {
SELECT COUNT(*) AS c FROM listed_user_game SELECT COUNT(*) AS c FROM listed_user_game
WHERE %s GROUP BY user_id WHERE %s GROUP BY user_id
) t ) t
""".formatted(NOT_DELETED), Long.class); """.formatted(NOT_DELETED_OR_BLOCKED), Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
@@ -65,7 +66,7 @@ public class StatsRepository {
SELECT COUNT(*) AS c FROM listed_user_game SELECT COUNT(*) AS c FROM listed_user_game
WHERE %s AND playtime_minutes = 0 GROUP BY user_id WHERE %s AND playtime_minutes = 0 GROUP BY user_id
) t ) t
""".formatted(NOT_DELETED), Long.class); """.formatted(NOT_DELETED_OR_BLOCKED), Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
@@ -78,7 +79,7 @@ public class StatsRepository {
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
ORDER BY value DESC ORDER BY value DESC
LIMIT ? LIMIT ?
""".formatted(NOT_DELETED), limit); """.formatted(NOT_DELETED_OR_BLOCKED), limit);
} }
public List<Map<String, Object>> topPlayedGames(int limit) { public List<Map<String, Object>> topPlayedGames(int limit) {
@@ -90,6 +91,6 @@ public class StatsRepository {
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
ORDER BY value DESC ORDER BY value DESC
LIMIT ? LIMIT ?
""".formatted(NOT_DELETED), limit); """.formatted(NOT_DELETED_OR_BLOCKED), limit);
} }
} }
@@ -1,30 +0,0 @@
package core_service.modules.steam_adapter.api;
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.security.UserPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/steam")
@RequiredArgsConstructor
public class SteamController {
private final SteamApiService steamApiService;
@GetMapping("/games-count")
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
if (principal.steamId() == null) {
return ResponseEntity.ok(Map.of("count", 0, "hasSteamId", false));
}
int count = steamApiService.getOwnedGamesCount(String.valueOf(principal.steamId()));
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", true));
}
}
@@ -11,7 +11,6 @@ public record SteamOwnedGamesResponse(
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public record OwnedGamesResponse( public record OwnedGamesResponse(
@JsonProperty("game_count") Integer gameCount,
@JsonProperty("games") List<Game> games @JsonProperty("games") List<Game> games
) {} ) {}
@@ -51,51 +51,6 @@ public class SteamApiService {
@Value("${app.steam.store-country:DE}") @Value("${app.steam.store-country:DE}")
private String storeCountry; private String storeCountry;
@Cacheable(value = "steamGameCount", key = "#steamId")
@RateLimiter(name = "steamApi")
@Retry(name = "steamApiOnce", fallbackMethod = "noGamesCount")
public int getOwnedGamesCount(String steamId) {
if (apiKey == null || apiKey.isEmpty() || apiKey.equals("${STEAM_API_KEY}")) {
log.error("Steam API Key is not configured correctly!");
return 0;
}
SteamOwnedGamesResponse response = steamRestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/IPlayerService/GetOwnedGames/v1/")
.queryParam("key", apiKey)
.queryParam("steamid", steamId)
.queryParam("include_appinfo", "true")
.queryParam("include_played_free_games", "true")
.queryParam("skip_unvetted_apps", 0)
.queryParam("format", "json")
.build())
.retrieve()
.body(SteamOwnedGamesResponse.class);
if (response != null && response.response() != null) {
Integer count = response.response().gameCount();
if (count == null) {
if (response.response().games() != null) {
int size = response.response().games().size();
log.info("game_count was null, but games list has {} entries for SteamID: {}", size, steamId);
return size;
}
log.warn("Steam API returned an empty response object for SteamID: {}. Check if the ID is correct and 'Game details' are Public.", steamId);
return 0;
}
log.info("Successfully fetched {} games for SteamID: {}", count, steamId);
return count;
}
return 0;
}
// The fallbacks run after the last try; the callers keep their "Steam failed" rule.
private int noGamesCount(String steamId, Exception e) {
log.error("Error communicating with Steam API for game count of SteamID: {}", steamId, e);
return 0;
}
// Asked on every page load for the avatar in the header. // Asked on every page load for the avatar in the header.
@Cacheable(value = "steamPlayerSummary", key = "#steamId", unless = "#result == null") @Cacheable(value = "steamPlayerSummary", key = "#steamId", unless = "#result == null")
@RateLimiter(name = "steamApi") @RateLimiter(name = "steamApi")
@@ -1,6 +1,7 @@
package core_service.shared.security; package core_service.shared.security;
import java.security.Principal; import java.security.Principal;
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
@@ -20,6 +21,13 @@ public record UserPrincipal(
return id; return id;
} }
/**
* The Steam ID as the Steam API and user_steam_sync take it; empty, never "null", without one.
*/
public Optional<String> steamIdText() {
return Optional.ofNullable(steamId).map(String::valueOf);
}
public long getIdAsLong() { public long getIdAsLong() {
return Long.parseLong(id); return Long.parseLong(id);
} }
+1 -2
View File
@@ -14,8 +14,7 @@ security:
jwks-url: "http://localhost:8080/.well-known/jwks.json" jwks-url: "http://localhost:8080/.well-known/jwks.json"
app: app:
cors: frontend-url: "http://localhost:5173"
allowed-origins: "http://localhost:5173"
steam: steam:
api-key: local api-key: local
base-url: "http://localhost:8089/steam" base-url: "http://localhost:8089/steam"
+5 -8
View File
@@ -7,7 +7,7 @@ spring:
deserialization: deserialization:
accept-float-as-int: false accept-float-as-int: false
jpa: jpa:
open-in-view: true open-in-view: false
flyway: flyway:
enabled: true enabled: true
baseline-on-migrate: true baseline-on-migrate: true
@@ -15,12 +15,6 @@ spring:
threads: threads:
virtual: virtual:
enabled: true enabled: true
mvc:
apiversion:
use:
query-parameter: version
default: "1.0"
supported: 1.0, 2,0
server: server:
port: 8090 port: 8090
@@ -42,8 +36,11 @@ app:
# Filled from the CI run by the Dockerfile. # Filled from the CI run by the Dockerfile.
commit: ${APP_BUILD_COMMIT:dev} commit: ${APP_BUILD_COMMIT:dev}
time: ${APP_BUILD_TIME:} time: ${APP_BUILD_TIME:}
# The frontend; FRONTEND_HOST in the Deployment compose file. The prod host as default: an image that starts before
# the compose file sets the env var keeps working.
frontend-url: ${APP_FRONTEND_URL:https://tbp.andreas-dahm.eu}
cors: cors:
allowed-origins: "https://tbp.andreas-dahm.eu" allowed-origins: ${app.frontend-url}
steam: steam:
api-key: ${STEAM_API_KEY} api-key: ${STEAM_API_KEY}
base-url: https://api.steampowered.com base-url: https://api.steampowered.com
@@ -0,0 +1,6 @@
-- Users Auth has blocked (item 28 X4). Like deleted_user, but a block can be lifted: no nightly sync, not in the
-- stats, no new sync run. The rows stay; unblock deletes the marker.
CREATE TABLE blocked_user (
user_id BIGINT PRIMARY KEY,
blocked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -5,6 +5,7 @@ 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 core_service.shared.test.ContractTokens; import core_service.shared.test.ContractTokens;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -48,10 +49,10 @@ class JwtServiceTest extends AbstractUnitTest {
jwtService = new JwtService(new JwksKeyLocator(jwtConfig, RestClient.create())); jwtService = new JwtService(new JwksKeyLocator(jwtConfig, RestClient.create()));
} }
private String signToken(String userId, String email, Long steamId) { private String signToken(String userId, Long steamId) {
var builder = Jwts.builder() JwtBuilder builder = Jwts.builder()
.subject(email) .issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
.claim("userId", userId) .subject(userId)
.issuedAt(new Date()) .issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 60_000)); .expiration(new Date(System.currentTimeMillis() + 60_000));
if (steamId != null) { if (steamId != null) {
@@ -62,7 +63,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test @Test
void parseLoginToken_ReturnsTheUser() { void parseLoginToken_ReturnsTheUser() {
String token = signToken("0815", "test@example.com", 123456789L); String token = signToken("0815", 123456789L);
assertEquals(Optional.of(new UserPrincipal("0815", 123456789L)), assertEquals(Optional.of(new UserPrincipal("0815", 123456789L)),
jwtService.parseLoginToken(token)); jwtService.parseLoginToken(token));
@@ -77,8 +78,8 @@ class JwtServiceTest extends AbstractUnitTest {
void parseLoginToken_RejectsAForeignKey() throws Exception { void parseLoginToken_RejectsAForeignKey() throws Exception {
KeyPair other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); KeyPair other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
String token = Jwts.builder() String token = Jwts.builder()
.subject("test@example.com") .issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
.claim("userId", "0815") .subject("0815")
.expiration(new Date(System.currentTimeMillis() + 60_000)) .expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(other.getPrivate(), Jwts.SIG.EdDSA) .signWith(other.getPrivate(), Jwts.SIG.EdDSA)
.compact(); .compact();
@@ -89,7 +90,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test @Test
void parseLoginToken_RejectsATokenWithoutUserId() { void parseLoginToken_RejectsATokenWithoutUserId() {
String token = Jwts.builder() String token = Jwts.builder()
.subject("test@example.com") .issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
.expiration(new Date(System.currentTimeMillis() + 60_000)) .expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(signingKey, Jwts.SIG.EdDSA) .signWith(signingKey, Jwts.SIG.EdDSA)
.compact(); .compact();
@@ -97,10 +98,10 @@ class JwtServiceTest extends AbstractUnitTest {
assertEquals(Optional.empty(), jwtService.parseLoginToken(token)); assertEquals(Optional.empty(), jwtService.parseLoginToken(token));
} }
private String signTicket(String type, String profileUserId, long expiresInMs) { private String signTicket(String audience, String profileUserId, long expiresInMs) {
return Jwts.builder() return Jwts.builder()
.issuer(JwtService.ISSUER).audience().add(audience).and()
.subject("public-profile") .subject("public-profile")
.claim("typ", type)
.claim("profileUserId", profileUserId) .claim("profileUserId", profileUserId)
.expiration(new Date(System.currentTimeMillis() + expiresInMs)) .expiration(new Date(System.currentTimeMillis() + expiresInMs))
.signWith(signingKey, Jwts.SIG.EdDSA).compact(); .signWith(signingKey, Jwts.SIG.EdDSA).compact();
@@ -109,12 +110,12 @@ class JwtServiceTest extends AbstractUnitTest {
@Test @Test
void verifyProfileTicket_ReturnsTheUser() { void verifyProfileTicket_ReturnsTheUser() {
assertEquals(Optional.of(new ProfileTicket(42L, Set.of(), null)), assertEquals(Optional.of(new ProfileTicket(42L, Set.of(), null)),
jwtService.verifyProfileTicket(signTicket("public-profile", "42", 60_000))); jwtService.verifyProfileTicket(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "42", 60_000)));
} }
@Test @Test
void verifyProfileTicket_ReadsTheSharedStatGroups() { void verifyProfileTicket_ReadsTheSharedStatGroups() {
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "42") String ticket = Jwts.builder().subject("public-profile").issuer(JwtService.ISSUER).audience().add(JwtService.PROFILE_TICKET_AUDIENCE).and().claim("profileUserId", "42")
.claim("profileStats", List.of("PILE", "TREND")) .claim("profileStats", List.of("PILE", "TREND"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact(); .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
@@ -123,7 +124,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test @Test
void verifyProfileTicket_ReadsTheShownSections() { void verifyProfileTicket_ReadsTheShownSections() {
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "42") String ticket = Jwts.builder().subject("public-profile").issuer(JwtService.ISSUER).audience().add(JwtService.PROFILE_TICKET_AUDIENCE).and().claim("profileUserId", "42")
.claim("sections", List.of("PLAYING", "STATS")) .claim("sections", List.of("PLAYING", "STATS"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact(); .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
@@ -132,25 +133,26 @@ class JwtServiceTest extends AbstractUnitTest {
@Test @Test
void verifyProfileTicket_RejectsLoginTokensOtherTypesExpiredAndForeignTickets() throws Exception { void verifyProfileTicket_RejectsLoginTokensOtherTypesExpiredAndForeignTickets() throws Exception {
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signToken("42", "a@b.de", null))); assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signToken("42", null)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("something-else", "42", 60_000))); assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("something-else", "42", 60_000)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("public-profile", "42", -1_000))); assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "42", -1_000)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("public-profile", "not-a-number", 60_000))); assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "not-a-number", 60_000)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket("garbage")); assertEquals(Optional.empty(), jwtService.verifyProfileTicket("garbage"));
PrivateKey otherKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPrivate(); PrivateKey otherKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPrivate();
String foreign = Jwts.builder().claim("typ", "public-profile").claim("profileUserId", "42") String foreign = Jwts.builder().issuer(JwtService.ISSUER).audience().add(JwtService.PROFILE_TICKET_AUDIENCE).and().claim("profileUserId", "42")
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(otherKey, Jwts.SIG.EdDSA).compact(); .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(otherKey, Jwts.SIG.EdDSA).compact();
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(foreign)); assertEquals(Optional.empty(), jwtService.verifyProfileTicket(foreign));
} }
@Test @Test
void parseLoginToken_ProfileTicketIsNoLogin() { void parseLoginToken_ProfileTicketIsNoLogin() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(signTicket("public-profile", "42", 60_000))); assertEquals(Optional.empty(), jwtService.parseLoginToken(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "42", 60_000)));
} }
private String signServiceToken(PrivateKey key, String type, Long expiresInMs) { private String signServiceToken(PrivateKey key, Long expiresInMs) {
io.jsonwebtoken.JwtBuilder builder = Jwts.builder().subject("tbp-auth").claim("typ", type).issuedAt(new Date()); JwtBuilder builder = Jwts.builder().issuer(JwtService.ISSUER).audience().add(JwtService.SERVICE_AUDIENCE).and()
.subject("tbp-auth").issuedAt(new Date());
if (expiresInMs != null) { if (expiresInMs != null) {
builder.expiration(new Date(System.currentTimeMillis() + expiresInMs)); builder.expiration(new Date(System.currentTimeMillis() + expiresInMs));
} }
@@ -159,30 +161,30 @@ class JwtServiceTest extends AbstractUnitTest {
@Test @Test
void isServiceToken_AcceptsAServiceTokenOfAuth() { void isServiceToken_AcceptsAServiceTokenOfAuth() {
assertTrue(jwtService.isServiceToken(signServiceToken(signingKey, "service", 60_000L))); assertTrue(jwtService.isServiceToken(signServiceToken(signingKey, 60_000L)));
} }
@Test @Test
void isServiceToken_RejectsEverythingElse() throws Exception { void isServiceToken_RejectsEverythingElse() throws Exception {
PrivateKey otherKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPrivate(); PrivateKey otherKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPrivate();
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, "service", null)), "no exp"); assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, null)), "no exp");
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, "service", -1_000L)), "expired"); assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, -1_000L)), "expired");
assertFalse(jwtService.isServiceToken(signServiceToken(otherKey, "service", 60_000L)), "foreign key"); assertFalse(jwtService.isServiceToken(signServiceToken(otherKey, 60_000L)), "foreign key");
assertFalse(jwtService.isServiceToken(signToken("42", "a@b.de", null)), "login token"); assertFalse(jwtService.isServiceToken(signToken("42", null)), "login token");
assertFalse(jwtService.isServiceToken(signTicket("public-profile", "42", 60_000)), "profile ticket"); assertFalse(jwtService.isServiceToken(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "42", 60_000)), "profile ticket");
assertFalse(jwtService.isServiceToken("garbage")); assertFalse(jwtService.isServiceToken("garbage"));
} }
@Test @Test
void parseLoginToken_ServiceTokenIsNoLogin() { void parseLoginToken_ServiceTokenIsNoLogin() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(signServiceToken(signingKey, "service", 60_000L))); assertEquals(Optional.empty(), jwtService.parseLoginToken(signServiceToken(signingKey, 60_000L)));
} }
private String signTokenWithRole(String role) { private String signTokenWithRole(String role) {
return Jwts.builder() return Jwts.builder()
.subject("test@example.com") .issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
.claim("userId", "0815") .subject("0815")
.claim("role", role) .claim("role", role)
.expiration(new Date(System.currentTimeMillis() + 60_000)) .expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(signingKey, Jwts.SIG.EdDSA) .signWith(signingKey, Jwts.SIG.EdDSA)
@@ -196,13 +198,14 @@ class JwtServiceTest extends AbstractUnitTest {
@Test @Test
void parseLoginToken_WithoutOrWithAnUnknownRole_IsUser() { void parseLoginToken_WithoutOrWithAnUnknownRole_IsUser() {
assertEquals(Role.USER, jwtService.parseLoginToken(signToken("0815", "test@example.com", null)).orElseThrow().role()); assertEquals(Role.USER, jwtService.parseLoginToken(signToken("0815", null)).orElseThrow().role());
assertEquals(Role.USER, jwtService.parseLoginToken(signTokenWithRole("GOD")).orElseThrow().role()); assertEquals(Role.USER, jwtService.parseLoginToken(signTokenWithRole("GOD")).orElseThrow().role());
} }
@Test @Test
void parseLoginToken_WithARoleThatIsNoString_IsStillALoginAsUser() { void parseLoginToken_WithARoleThatIsNoString_IsStillALoginAsUser() {
String token = Jwts.builder().subject("test@example.com").claim("userId", "0815").claim("role", List.of("AUDITOR")) String token = Jwts.builder().issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
.subject("0815").claim("role", List.of("AUDITOR"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact(); .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
assertEquals(Role.USER, jwtService.parseLoginToken(token).orElseThrow().role()); assertEquals(Role.USER, jwtService.parseLoginToken(token).orElseThrow().role());
@@ -212,14 +215,6 @@ class JwtServiceTest extends AbstractUnitTest {
return ContractTokens.sign(claims, signingKey, 60_000); return ContractTokens.sign(claims, signingKey, 60_000);
} }
// The shapes of item 29: without the claims an Auth from before item 26 needs.
private static Map<String, Object> without(Map<String, Object> claims, String... names) {
for (String name : names) {
claims.remove(name);
}
return claims;
}
@Test @Test
void contractTokens_OfEveryKind_AreAccepted() { void contractTokens_OfEveryKind_AreAccepted() {
assertEquals("42", jwtService.parseLoginToken(sign(ContractTokens.claims(ContractTokens.LOGIN))).orElseThrow().id()); assertEquals("42", jwtService.parseLoginToken(sign(ContractTokens.claims(ContractTokens.LOGIN))).orElseThrow().id());
@@ -229,20 +224,20 @@ class JwtServiceTest extends AbstractUnitTest {
} }
@Test @Test
void legacyTokens_OfAnAuthFromBeforeItem26_AreAccepted() { void tokensWithoutAud_AreRefused() {
assertEquals("42", jwtService.parseLoginToken(sign(ContractTokens.legacyClaims(ContractTokens.LOGIN))).orElseThrow().id()); assertTrue(jwtService.parseLoginToken(sign(withoutAudience(ContractTokens.LOGIN))).isEmpty());
assertEquals(42L, jwtService.verifyProfileTicket(sign(ContractTokens.legacyClaims(ContractTokens.PUBLIC_PROFILE))) assertTrue(jwtService.verifyProfileTicket(sign(withoutAudience(ContractTokens.PUBLIC_PROFILE))).isEmpty());
.orElseThrow().userId()); assertFalse(jwtService.isServiceToken(sign(withoutAudience(ContractTokens.SERVICE))));
assertTrue(jwtService.isServiceToken(sign(ContractTokens.legacyClaims(ContractTokens.SERVICE))));
} }
@Test // The shape of an Auth from before item 26, with the old claims it wrote.
void tokensWithoutTheOldClaims_AreAccepted() { private static Map<String, Object> withoutAudience(String kind) {
assertEquals("42", jwtService.parseLoginToken(sign(without(ContractTokens.claims(ContractTokens.LOGIN), "userId"))) Map<String, Object> claims = ContractTokens.claims(kind);
.orElseThrow().id()); claims.remove("aud");
assertEquals(42L, jwtService.verifyProfileTicket(sign(without(ContractTokens.claims(ContractTokens.PUBLIC_PROFILE), "typ"))) claims.remove("iss");
.orElseThrow().userId()); claims.put("userId", "42");
assertTrue(jwtService.isServiceToken(sign(without(ContractTokens.claims(ContractTokens.SERVICE), "typ")))); claims.put("typ", kind.equals(ContractTokens.SERVICE) ? "service" : "public-profile");
return claims;
} }
@Test @Test
@@ -4,8 +4,6 @@ import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens; import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
@@ -45,6 +43,7 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
@AfterEach @AfterEach
void cleanUp() { void cleanUp() {
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);
} }
private static String serviceToken() { private static String serviceToken() {
@@ -116,12 +115,10 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
return request(HttpMethod.valueOf(call.path("method").asString()), call.path("path").asString()); return request(HttpMethod.valueOf(call.path("method").asString()), call.path("path").asString());
} }
// The calls as Auth's CoreAccountService sends them, with the service tokens of an old and a new Auth. // The calls as Auth's CoreAccountService sends them.
@ParameterizedTest @Test
@ValueSource(booleans = {false, true}) void contractCalls_AreAnswered() throws Exception {
void contractCalls_AreAnswered(boolean legacyToken) throws Exception { String token = ContractTokens.serviceToken();
String token = legacyToken ? ContractTokens.sign(ContractTokens.legacyClaims(ContractTokens.SERVICE))
: ContractTokens.serviceToken();
// replaceDeleted with [] removes every tombstone, also those of other tests in this database. // replaceDeleted with [] removes every tombstone, also those of other tests in this database.
List<Long> tombstones = jdbc.queryForList("select user_id from deleted_user", Long.class); List<Long> tombstones = jdbc.queryForList("select user_id from deleted_user", Long.class);
try { try {
@@ -111,10 +111,16 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
void findOrphans_ReturnsUsersNotInTheList() { void findOrphans_ReturnsUsersNotInTheList() {
repository.addDeleted(List.of(7L)); repository.addDeleted(List.of(7L));
assertThat(repository.findOrphans(List.of(2L))).containsExactly(1L, 7L); assertThat(repository.findOrphans(List.of(2L, 8L))).containsExactly(1L, 7L);
assertThat(repository.findOrphans(List.of(1L, 2L, 7L))).isEmpty(); assertThat(repository.findOrphans(List.of(1L, 2L, 7L))).isEmpty();
} }
// Auth reads its ids before the call: a user who registers in between has a higher id and is no orphan.
@Test
void findOrphans_SkipsUsersAboveTheHighestKnownId() {
assertThat(repository.findOrphans(List.of(1L))).isEmpty();
}
// A new table with user rows must be in the purge list, or "Delete my account" leaves its rows behind. // A new table with user rows must be in the purge list, or "Delete my account" leaves its rows behind.
@Test @Test
void tables_CoverEveryTableWithAUserId() { void tables_CoverEveryTableWithAUserId() {
@@ -0,0 +1,103 @@
package core_service.modules.account.repo;
import core_service.modules.account.domain.AccountState;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Import(AccountStateRepository.class)
class AccountStateRepositoryTest extends AbstractPersistenceTest {
private static final long OLD_STEAM_ID = 76561198000000001L;
private static final long NEW_STEAM_ID = 76561198000000002L;
@Autowired
private AccountStateRepository repository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("delete from blocked_user");
for (long userId : List.of(1L, 2L)) {
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));
}
}
private Map<String, Object> sync(long userId) {
return jdbc.queryForMap("select steam_id, last_synced_at, user_seen_at from user_steam_sync where user_id = ?",
userId);
}
private List<Long> blocked() {
return jdbc.queryForList("select user_id from blocked_user order by user_id", Long.class);
}
@Test
void block_StopsTheNightlySyncAndUnblockLiftsTheMarker() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, true));
assertThat(blocked()).containsExactly(1L);
assertThat(sync(1L).get("user_seen_at")).isNull();
assertThat(sync(2L).get("user_seen_at")).isNotNull();
repository.apply(new AccountState(1L, OLD_STEAM_ID, false));
assertThat(blocked()).isEmpty();
}
// steam_id stays: the next sync with the new ID compares with it and drops the old account's games.
@Test
void newSteamId_StopsTheNightlySyncOfTheOldIdAndSyncsOnTheNextVisit() {
repository.apply(new AccountState(1L, NEW_STEAM_ID, false));
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
assertThat(sync(1L).get("last_synced_at")).isNull();
assertThat(sync(1L).get("user_seen_at")).isNull();
}
@Test
void sameSteamId_KeepsTheLastSync() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, false));
assertThat(sync(1L).get("last_synced_at")).isNotNull();
}
@Test
void noSteamId_StopsTheNightlySync() {
repository.apply(new AccountState(1L, null, false));
assertThat(sync(1L).get("user_seen_at")).isNull();
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
}
@Test
void userWithoutASyncRow_GetsOnlyTheBlock() {
repository.apply(new AccountState(3L, NEW_STEAM_ID, true));
assertThat(blocked()).containsExactly(3L);
assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty();
}
@Test
void replaceAll_LiftsTheBlocksAuthNoLongerSends() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, true));
repository.apply(new AccountState(2L, OLD_STEAM_ID, true));
repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, true)));
assertThat(blocked()).containsExactly(2L);
repository.replaceAll(List.of());
assertThat(blocked()).isEmpty();
}
}
@@ -12,6 +12,8 @@ import core_service.shared.test.AbstractMockedAdaptersTest;
import com.jayway.jsonpath.JsonPath; import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.params.ParameterizedTest;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -195,6 +197,14 @@ class GameImportControllerTest extends AbstractMockedAdaptersTest {
verify(igdbApiService, never()).searchGames(any()); verify(igdbApiService, never()).searchGames(any());
} }
@ParameterizedTest
@ValueSource(strings = {"GALAXY", "GROUVEE", "HLTB", "PLAYNITE", "BOOKMARKLET"})
void create_ExportAndBookmarkletSources_AreStored(String source) throws Exception {
long id = createReady("{\"source\": \"" + source + "\", \"rows\": [{\"title\": \"Elden Ring\"}]}");
assertEquals(source, jdbc.queryForObject("select source from game_import where id = ?", String.class, id));
}
@Test @Test
void create_TooManyRowsOrNoTitle_Is400() throws Exception { void create_TooManyRowsOrNoTitle_Is400() throws Exception {
String row = "{\"title\": \"Game\"}"; String row = "{\"title\": \"Game\"}";
@@ -60,88 +60,23 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
} }
@Test @Test
void updateBacklog_StoresAllFields() throws Exception { void patchBacklog_FractionalRating_Is400() throws Exception {
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON) patchBacklog(APPID, "{\"rating\":4.7}").andExpect(status().isBadRequest());
.content("{\"status\":\"FINISHED\",\"rating\":4,\"notes\":\"Great ending\",\"priority\":\"HIGH\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("FINISHED"))
.andExpect(jsonPath("$.rating").value(4));
UserGame game = stored();
assertEquals(GameStatus.FINISHED, game.getStatus());
assertEquals(4, game.getRating());
assertEquals("Great ending", game.getNotes());
assertEquals("HIGH", game.getPriority().name());
}
@Test
void updateBacklog_NullsClearOptionalFieldsAndBlankNotesAreNull() throws Exception {
UserGame game = stored();
game.setRating(5);
game.setNotes("old");
userGameRepository.save(game);
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"DROPPED\",\"notes\":\" \"}"))
.andExpect(status().isOk());
game = stored();
assertEquals(GameStatus.DROPPED, game.getStatus());
assertNull(game.getRating());
assertNull(game.getNotes());
assertNull(game.getPriority());
}
@ParameterizedTest
@ValueSource(strings = {
"{}",
"{\"status\":\"WISHLIST\"}",
"{\"status\":\"PLAYING\",\"rating\":0}",
"{\"status\":\"PLAYING\",\"rating\":6}",
"{\"status\":\"PLAYING\",\"priority\":\"URGENT\"}"})
void updateBacklog_InvalidBody_Is400AndStoresNothing(String body) throws Exception {
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isBadRequest());
assertEquals(GameStatus.BACKLOG, stored().getStatus());
}
@Test
void updateBacklog_FractionalRating_Is400() throws Exception {
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\",\"rating\":4.7}"))
.andExpect(status().isBadRequest());
assertNull(stored().getRating()); assertNull(stored().getRating());
} }
@Test @Test
void updateBacklog_NotesTooLong_Is400() throws Exception { void patchBacklog_RemovedGame_Is404() throws Exception {
String notes = "x".repeat(2001);
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\",\"notes\":\"" + notes + "\"}"))
.andExpect(status().isBadRequest());
}
@Test
void updateBacklog_RemovedGame_Is404() throws Exception {
UserGame game = stored(); UserGame game = stored();
game.setRemovedAt(Instant.now()); game.setRemovedAt(Instant.now());
userGameRepository.save(game); userGameRepository.save(game);
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON) patchBacklog(APPID, "{\"notes\":\"x\"}")
.content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isNotFound()) .andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND")); .andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
} }
@Test
void updateBacklog_GameNotOwned_Is404() throws Exception {
mockMvc.perform(put("/api/library/games/999/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isNotFound());
}
private ResultActions patchBacklog(long appid, String body) throws Exception { private ResultActions patchBacklog(long appid, String body) throws Exception {
return mockMvc.perform(patch("/api/library/games/" + appid + "/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test") return mockMvc.perform(patch("/api/library/games/" + appid + "/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content(body)); .contentType(MediaType.APPLICATION_JSON).content(body));
@@ -24,7 +24,7 @@ import java.util.List;
import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.Matchers.nullValue;
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.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 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;
@@ -189,7 +189,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
// The dev filter falls back to the dummy user 1 for a token it rejects; user 7's game would answer 200. // The dev filter falls back to the dummy user 1 for a token it rejects; user 7's game would answer 200.
@Test @Test
void profileTicket_IsNoLoginForTheLibraryApi() throws Exception { void profileTicket_IsNoLoginForTheLibraryApi() throws Exception {
mockMvc.perform(put("/api/library/games/220/backlog").header("Authorization", "Bearer " + ticket(USER_ID)) mockMvc.perform(patch("/api/library/games/220/backlog").header("Authorization", "Bearer " + ticket(USER_ID))
.contentType("application/json").content("{\"status\":\"DROPPED\"}")) .contentType("application/json").content("{\"status\":\"DROPPED\"}"))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
} }
@@ -0,0 +1,15 @@
package core_service.modules.library.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class GameNamesTest extends AbstractUnitTest {
@Test
void normalize_DropsCaseAccentsTrademarksAndPunctuation() {
assertThat(GameNames.normalize(" Pokémon™: Let's Go, Pikachu!® ")).isEqualTo("pokemon let s go pikachu");
assertThat(GameNames.normalize("The Witcher 3: Wild Hunt")).isEqualTo(GameNames.normalize("The Witcher® 3 - Wild Hunt"));
}
}
@@ -45,6 +45,14 @@ class UserSteamSyncRepositoryTest extends AbstractPersistenceTest {
assertThat(claim(3L)).isZero(); assertThat(claim(3L)).isZero();
} }
@Test
void tryClaimRun_RefusesABlockedUser() {
repository.saveAndFlush(new UserSteamSync(3L, STEAM_ID));
jdbc.update("insert into blocked_user (user_id) values (3)");
assertThat(claim(3L)).isZero();
}
@Test @Test
void tryClaimRun_RunningRowStartedLongAgo_IsClaimedEvenWhenTheLastSyncEndedRecently() { void tryClaimRun_RunningRowStartedLongAgo_IsClaimedEvenWhenTheLastSyncEndedRecently() {
jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, last_sync_status, sync_started_at) " + jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, last_sync_status, sync_started_at) " +
@@ -18,12 +18,6 @@ class SpeedrunMatchTest extends AbstractUnitTest {
private static final SpeedrunApiService.Game PREY_2006 = hit("prey", "Prey", 2006); private static final SpeedrunApiService.Game PREY_2006 = hit("prey", "Prey", 2006);
private static final SpeedrunApiService.Game PREY_2017 = hit("prey2017", "Prey (2017)", 2017); private static final SpeedrunApiService.Game PREY_2017 = hit("prey2017", "Prey (2017)", 2017);
@Test
void normalize_DropsCaseAccentsTrademarksAndPunctuation() {
assertThat(SpeedrunMatch.normalize(" Pokémon™: Let's Go, Pikachu!® ")).isEqualTo("pokemon let s go pikachu");
assertThat(SpeedrunMatch.normalize("The Witcher 3: Wild Hunt")).isEqualTo(SpeedrunMatch.normalize("The Witcher® 3 - Wild Hunt"));
}
@Test @Test
void candidates_YearSuffixTellsTheRemakeApart() { void candidates_YearSuffixTellsTheRemakeApart() {
assertThat(SpeedrunMatch.candidates("Prey", Set.of(2017), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2017); assertThat(SpeedrunMatch.candidates("Prey", Set.of(2017), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2017);
@@ -76,4 +76,15 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
hiddenGames_LeaveEveryNumber(); hiddenGames_LeaveEveryNumber();
hiddenGames_LeaveTheTopLists(); hiddenGames_LeaveTheTopLists();
} }
@Test
void blockedUsers_LeaveEveryNumberAndTheTopLists() {
steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now()));
userGameRepository.saveAllAndFlush(List.of(new UserGame(3L, 620L, 900, Instant.now(), Instant.now()),
new UserGame(3L, 220L, 0, Instant.now(), Instant.now())));
jdbc.update("INSERT INTO blocked_user (user_id) VALUES (3)");
hiddenGames_LeaveEveryNumber();
hiddenGames_LeaveTheTopLists();
}
} }
@@ -108,7 +108,7 @@ class SteamApiServiceCacheTest extends AbstractIntegrationTest {
@Test @Test
void getOwnedGames_TransientSteamError_IsTriedAgain() { void getOwnedGames_TransientSteamError_IsTriedAgain() {
SteamOwnedGamesResponse games = new SteamOwnedGamesResponse(new SteamOwnedGamesResponse.OwnedGamesResponse(1, SteamOwnedGamesResponse games = new SteamOwnedGamesResponse(new SteamOwnedGamesResponse.OwnedGamesResponse(
List.of(new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 60, null, true, 0)))); List.of(new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 60, null, true, 0))));
when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamOwnedGamesResponse.class)) when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamOwnedGamesResponse.class))
.thenAnswer(invocation -> { .thenAnswer(invocation -> {
@@ -45,27 +45,6 @@ class SteamApiServiceTest {
ReflectionTestUtils.setField(steamApiService, "apiKey", "test-api-key"); ReflectionTestUtils.setField(steamApiService, "apiKey", "test-api-key");
} }
@Test
void getOwnedGamesCount_shouldReturnCount() {
String steamId = "76561197960435530";
String jsonResponse = """
{
"response": {
"game_count": 42
}
}
""";
server.expect(requestTo(containsString("/IPlayerService/GetOwnedGames/v1/")))
.andExpect(queryParam("steamid", steamId))
.andExpect(queryParam("key", "test-api-key"))
.andRespond(withSuccess(jsonResponse, MediaType.APPLICATION_JSON));
int result = steamApiService.getOwnedGamesCount(steamId);
assertThat(result).isEqualTo(42);
}
@Test @Test
void getPlayerSummary_shouldReturnPlayer() { void getPlayerSummary_shouldReturnPlayer() {
String steamId = "76561197960435530"; String steamId = "76561197960435530";
@@ -135,15 +114,6 @@ class SteamApiServiceTest {
assertThat(steamApiService.getOwnedGames("76561197960435530")).hasSize(1); assertThat(steamApiService.getOwnedGames("76561197960435530")).hasSize(1);
} }
@Test
void getOwnedGamesCount_shouldCountUnvettedApps() {
server.expect(requestTo(containsString("/IPlayerService/GetOwnedGames/v1/")))
.andExpect(queryParam("skip_unvetted_apps", "0"))
.andRespond(withSuccess("{\"response\":{\"game_count\":1}}", MediaType.APPLICATION_JSON));
assertThat(steamApiService.getOwnedGamesCount("76561197960435530")).isEqualTo(1);
}
@Test @Test
void getOwnedGames_shouldReturnGamesEvenWithNullValues() { void getOwnedGames_shouldReturnGamesEvenWithNullValues() {
String steamId = "76561197960435530"; String steamId = "76561197960435530";
@@ -1,13 +0,0 @@
package core_service.shared.test;
import org.junit.jupiter.api.Tag;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
/**
* Base class for WebMvc tests.
* Slices the web layer.
*/
@Tag("WebMvcTest")
@WebMvcTest
public abstract class AbstractWebMvcTest {
}
@@ -46,14 +46,6 @@ public final class ContractTokens {
return new LinkedHashMap<>(MAPPER.treeToValue(CONTRACT.path("tokens").required(kind), Map.class)); return new LinkedHashMap<>(MAPPER.treeToValue(CONTRACT.path("tokens").required(kind), Map.class));
} }
/**
* The claims of an Auth from before item 26.
*/
@SuppressWarnings("unchecked")
public static Map<String, Object> legacyClaims(String kind) {
return new LinkedHashMap<>(MAPPER.treeToValue(CONTRACT.path("legacyTokens").required(kind), Map.class));
}
public static JsonNode internalCall(String name) { public static JsonNode internalCall(String name) {
return CONTRACT.path("internal").required(name); return CONTRACT.path("internal").required(name);
} }
@@ -1,11 +1,10 @@
{ {
"_about": "The token and internal-call contract between Auth and Core. The same file is in both repos; ToBePlayed-Deployment/scripts/check-contract.sh compares them. iat and exp are left out. legacyTokens are the shapes of an Auth from before item 26: Core and Auth accept them until item 29.", "_about": "The token and internal-call contract between Auth and Core. The same file is in both repos; ToBePlayed-Deployment/scripts/check-contract.sh compares them. iat and exp are left out.",
"tokens": { "tokens": {
"login": { "login": {
"iss": "tbp-auth", "iss": "tbp-auth",
"aud": ["tbp-login"], "aud": ["tbp-login"],
"sub": "42", "sub": "42",
"userId": "42",
"role": "USER", "role": "USER",
"steamId": 76561198000000001 "steamId": 76561198000000001
}, },
@@ -13,7 +12,6 @@
"iss": "tbp-auth", "iss": "tbp-auth",
"aud": ["tbp-public-profile"], "aud": ["tbp-public-profile"],
"sub": "public-profile", "sub": "public-profile",
"typ": "public-profile",
"profileUserId": "42", "profileUserId": "42",
"profileStats": ["OVERVIEW"], "profileStats": ["OVERVIEW"],
"sections": ["SUMMARY"] "sections": ["SUMMARY"]
@@ -21,27 +19,7 @@
"service": { "service": {
"iss": "tbp-auth", "iss": "tbp-auth",
"aud": ["tbp-internal"], "aud": ["tbp-internal"],
"sub": "tbp-auth", "sub": "tbp-auth"
"typ": "service"
}
},
"legacyTokens": {
"login": {
"sub": "user@example.com",
"userId": "42",
"role": "USER",
"steamId": 76561198000000001
},
"publicProfile": {
"sub": "public-profile",
"typ": "public-profile",
"profileUserId": "42",
"profileStats": ["OVERVIEW"],
"sections": ["SUMMARY"]
},
"service": {
"sub": "tbp-auth",
"typ": "service"
} }
}, },
"internal": { "internal": {
@@ -55,6 +33,16 @@
"path": "/internal/accounts/deleted", "path": "/internal/accounts/deleted",
"request": {"userIds": []} "request": {"userIds": []}
}, },
"applyStates": {
"method": "POST",
"path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "blocked": true}]}
},
"replaceStates": {
"method": "PUT",
"path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": null, "blocked": false}]}
},
"purge": { "purge": {
"method": "POST", "method": "POST",
"path": "/internal/accounts/purge", "path": "/internal/accounts/purge",