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 --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
ENTRYPOINT ["java", "-jar", "app.jar"]
+33 -15
View File
@@ -76,13 +76,12 @@ To Be Played is split into three repositories:
## Authentication
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
until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. The token comes from Auth's
`tbp_access` cookie or a `Bearer` header, whichever verifies first. Every token names its kind in `aud`
JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Each load replaces
the key set: a key that left the JWKS stops working. `JWT_PUBLIC_KEY` is used only until the first JWKS load works.
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
`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 id in `userId`) and is still accepted; item 29 removes that path. The claims are pinned in
(`USER < AUDITOR < PLATFORM_ADMIN`). A token without `aud` is refused. 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.
`POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the
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):
`@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;
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
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.
**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
| Method | Path | Auth |
|---|---|---|
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | 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 |
| `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}/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 |
| `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 |
| `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 |
@@ -129,6 +135,7 @@ method calling another service's method that needs more.
| `GET` | `/api/public/profiles/library` | profile ticket |
| `GET` | `/api/stats/public` | public |
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
| `POST`, `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 |
| `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"]
Speedrun --> SpeedrunApi["speedrun.com API"]
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
Account -->|"per-user rows, deleted_user"| Db
Account -->|"per-user rows, deleted_user, blocked_user, user_seen_at"| Db
```
| Module | Content |
@@ -183,15 +190,16 @@ flowchart LR
| `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data |
| `stats` | Public numbers for the landing page |
| `version` | `GET /api/version`: commit and build time of the running image |
| `account` | Deleted accounts, driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows |
| `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 |
| `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 |
**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
the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes into
its list); `library` reads `deleted_user`. A module that moves to its own service takes these reads along as calls.
SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game`, `deleted_user` and `blocked_user`; `account`
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
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
`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
(`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
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
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
@@ -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
`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
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
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats", "steamPlayerSummary",
CaffeineCacheManager cacheManager = new CaffeineCacheManager("publicStats", "steamPlayerSummary",
"igdbSearch");
cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
@@ -10,7 +10,6 @@ import io.jsonwebtoken.Jwts;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
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.
* <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
* from an Auth from before item 26: its kind is in {@code typ} and the user id in {@code userId}. Item 29 removes
* that path.
* A token names its kind in {@code aud}, and {@code sub} is the user id of a login. A token without {@code aud} is
* refused.
*/
@Service
public class JwtService implements ProfileTicketVerifier {
@@ -29,10 +27,6 @@ public class JwtService implements ProfileTicketVerifier {
static final String LOGIN_AUDIENCE = "tbp-login";
static final String PROFILE_TICKET_AUDIENCE = "tbp-public-profile";
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 final JwtParser parser;
@@ -45,18 +39,9 @@ public class JwtService implements ProfileTicketVerifier {
return parser.parseSignedClaims(token).getPayload();
}
private static boolean hasAudience(Claims claims) {
return claims.getAudience() != null && !claims.getAudience().isEmpty();
}
/**
* @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));
private static boolean isKind(Claims claims, String audience) {
return claims.getAudience() != null && claims.getAudience().contains(audience)
&& ISSUER.equals(claims.getIssuer());
}
/**
@@ -66,11 +51,10 @@ public class JwtService implements ProfileTicketVerifier {
public Optional<UserPrincipal> parseLoginToken(String token) {
try {
Claims claims = claims(token);
if (!isKind(claims, LOGIN_AUDIENCE, null)) {
if (!isKind(claims, LOGIN_AUDIENCE)) {
return Optional.empty();
}
String userId = hasAudience(claims) ? claims.getSubject()
: claims.getSubject() == null ? null : claims.get("userId", String.class);
String userId = claims.getSubject();
if (userId == null) {
return Optional.empty();
}
@@ -87,7 +71,7 @@ public class JwtService implements ProfileTicketVerifier {
public boolean isServiceToken(String token) {
try {
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) {
return false;
}
@@ -101,7 +85,7 @@ public class JwtService implements ProfileTicketVerifier {
public Optional<ProfileTicket> verifyProfileTicket(String ticket) {
try {
Claims claims = claims(ticket);
if (!isKind(claims, PROFILE_TICKET_AUDIENCE, PROFILE_TICKET_TYPE)) {
if (!isKind(claims, PROFILE_TICKET_AUDIENCE)) {
return Optional.empty();
}
long userId = Long.parseLong(claims.get("profileUserId", String.class));
@@ -1,11 +1,14 @@
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.KnownUsersRequest;
import core_service.modules.account.api.dto.PurgeRequest;
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.service.AccountCleanupService;
import core_service.modules.account.service.AccountStateService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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.RestController;
import java.util.List;
@RestController
@RequestMapping("/internal/accounts")
@RequiredArgsConstructor
public class InternalAccountController {
private final AccountCleanupService accountCleanupService;
private final AccountStateService accountStateService;
@PostMapping("/deleted")
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())));
}
@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) {
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 java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
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.
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"user_steam_sync", "user_game", "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.
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;
@@ -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
*/
public List<Long> findOrphans(Collection<Long> knownUserIds) {
String union = String.join(" union ", ORPHAN_SOURCES.stream().map(table -> "select user_id from " + table).toList());
return jdbc.queryForList("select user_id from (" + union + ") u where user_id not in (:ids) order by user_id",
ids(knownUserIds), Long.class);
return jdbc.queryForList("select user_id from (" + union + ") u where user_id not in (:ids) "
+ "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.modules.library.api.dto.AchievementSummaryDto;
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.BrokenImageReport;
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.List;
import java.util.Map;
import java.util.Optional;
import java.util.Objects;
import java.util.Set;
@@ -87,11 +87,8 @@ public class LibraryController {
@GetMapping("/games-count")
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
Long userId = principal.getIdAsLong();
if (principal.steamId() != null) {
syncService.syncIfStale(userId, String.valueOf(principal.steamId()));
} else {
syncService.stopNightlySync(userId);
}
principal.steamIdText().ifPresentOrElse(steamId -> syncService.syncIfStale(userId, steamId),
() -> syncService.stopNightlySync(userId));
long count = queryService.countOwnedGames(userId);
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null));
}
@@ -108,9 +105,7 @@ public class LibraryController {
@RequestParam(defaultValue = "false") boolean hidden
) {
Long userId = principal.getIdAsLong();
if (principal.steamId() != null) {
syncService.syncIfStale(userId, String.valueOf(principal.steamId()));
}
principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
int safePage = Math.max(0, page);
@@ -132,11 +127,12 @@ public class LibraryController {
@PostMapping("/sync")
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"));
}
try {
syncService.forceSync(principal.getIdAsLong(), String.valueOf(principal.steamId()));
syncService.forceSync(principal.getIdAsLong(), steamId.get());
return ResponseEntity.accepted().body(Map.of("status", "started"));
} catch (SyncCooldownException e) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
@@ -152,8 +148,7 @@ public class LibraryController {
public ResponseEntity<?> getGameDetail(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid) {
Long userId = principal.getIdAsLong();
String steamId = principal.steamId() != null ? String.valueOf(principal.steamId()) : null;
return gameDetailService.getGameDetail(userId, steamId, appid)
return gameDetailService.getGameDetail(userId, principal.steamIdText().orElse(null), appid)
.<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(GAME_NOT_FOUND));
@@ -223,16 +218,6 @@ public class LibraryController {
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")
public ResponseEntity<?> patchBacklog(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid,
@@ -310,9 +295,7 @@ public class LibraryController {
int safeCount = Math.clamp(count, 1, MAX_PICKER_GAMES);
Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null;
Long userId = principal.getIdAsLong();
if (principal.steamId() != null) {
syncService.syncIfStale(userId, String.valueOf(principal.steamId()));
}
principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
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,
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 }
@@ -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 org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
public interface UserGameAchievementRepository extends JpaRepository<UserGameAchievement, UserGameAchievementId> {
List<UserGameAchievement> findByUserIdAndAppid(Long userId, Long appid);
List<UserGameAchievement> findByAppidAndApiNameAndUserIdInAndUnlockedTrueAndUnlockedAtNotNull(Long appid,
String apiName, Collection<Long> userIds);
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
* (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
*/
@@ -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)
SELECT :userId, :steamId, 'RUNNING', :startedAt
WHERE NOT EXISTS (SELECT 1 FROM deleted_user WHERE user_id = :userId)
AND NOT EXISTS (SELECT 1 FROM blocked_user WHERE user_id = :userId)
ON CONFLICT (user_id) DO UPDATE
SET steam_id = EXCLUDED.steam_id,
last_sync_status = 'RUNNING',
@@ -24,23 +24,6 @@ public class BacklogService {
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
* 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 org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -39,15 +40,17 @@ public class FinishedEstimateService {
for (Map.Entry<Long, List<FinishedWithoutDate>> app : byApp.entrySet()) {
Optional<EndingAchievement> ending = EndingAchievement.of(schemaRepository.findByAppid(app.getKey()));
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()) {
Optional<UserGameAchievement> unlock = userAchievementRepository
.findByUserIdAndAppid(game.userId(), game.appid()).stream()
.filter(a -> a.isUnlocked() && a.getUnlockedAt() != null && a.getApiName().equals(apiName))
.findFirst();
if (unlock.isPresent()) {
dated += userGameRepository.setFinishedAtEstimate(game.userId(), game.appid(),
unlock.get().getUnlockedAt());
Instant unlockedAt = unlockByUser.get(game.userId());
if (unlockedAt != null) {
dated += userGameRepository.setFinishedAtEstimate(game.userId(), game.appid(), unlockedAt);
}
}
}
@@ -1,5 +1,6 @@
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.IgdbGame;
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.
*/
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
*/
@Public
/**
* @param sectionNames see {@link ProfileSection#shown}; a hidden section runs no query
*/
@Public
public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats, Collection<String> sectionNames) {
Set<ProfileSection> sections = ProfileSection.shown(sectionNames);
boolean summary = sections.contains(ProfileSection.SUMMARY);
@@ -1,12 +1,11 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.GameNames;
import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
import java.text.Normalizer;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -21,7 +20,6 @@ final class SpeedrunMatch {
static final String WEBLINK_PREFIX = "https://www.speedrun.com/";
// 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})\\)$");
// The column sizes of game_speedrun.
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™".
*/
static String searchText(String name) {
return name.replaceAll(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();
return name.replaceAll(GameNames.TRADEMARK_SIGNS, "").trim();
}
private static NameAndYear split(String name) {
Matcher matcher = YEAR_SUFFIX.matcher(name.trim());
return matcher.matches()
? new NameAndYear(normalize(matcher.group(1)), Integer.parseInt(matcher.group(2)))
: new NameAndYear(normalize(name), null);
? new NameAndYear(GameNames.normalize(matcher.group(1)), Integer.parseInt(matcher.group(2)))
: new NameAndYear(GameNames.normalize(name), null);
}
private record NameAndYear(String name, Integer year) {}
@@ -10,7 +10,8 @@ import java.util.Map;
@Repository
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;
@@ -21,31 +22,31 @@ public class StatsRepository {
public long countDistinctGames() {
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;
}
public long countLibraryEntries() {
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;
}
public long countGamesPlayed() {
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;
}
public long sumPlaytimeMinutes() {
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;
}
public long countUsersWithLibrary() {
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;
}
@@ -55,7 +56,7 @@ public class StatsRepository {
SELECT COUNT(*) AS c FROM listed_user_game
WHERE %s GROUP BY user_id
) t
""".formatted(NOT_DELETED), Long.class);
""".formatted(NOT_DELETED_OR_BLOCKED), Long.class);
return v != null ? v : 0L;
}
@@ -65,7 +66,7 @@ public class StatsRepository {
SELECT COUNT(*) AS c FROM listed_user_game
WHERE %s AND playtime_minutes = 0 GROUP BY user_id
) t
""".formatted(NOT_DELETED), Long.class);
""".formatted(NOT_DELETED_OR_BLOCKED), Long.class);
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
ORDER BY value DESC
LIMIT ?
""".formatted(NOT_DELETED), limit);
""".formatted(NOT_DELETED_OR_BLOCKED), 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
ORDER BY value DESC
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)
public record OwnedGamesResponse(
@JsonProperty("game_count") Integer gameCount,
@JsonProperty("games") List<Game> games
) {}
@@ -51,51 +51,6 @@ public class SteamApiService {
@Value("${app.steam.store-country:DE}")
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.
@Cacheable(value = "steamPlayerSummary", key = "#steamId", unless = "#result == null")
@RateLimiter(name = "steamApi")
@@ -1,6 +1,7 @@
package core_service.shared.security;
import java.security.Principal;
import java.util.Optional;
/**
* @param role the active role of this login ("view as"), set by Auth
@@ -20,6 +21,13 @@ public record UserPrincipal(
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() {
return Long.parseLong(id);
}
+1 -2
View File
@@ -14,8 +14,7 @@ security:
jwks-url: "http://localhost:8080/.well-known/jwks.json"
app:
cors:
allowed-origins: "http://localhost:5173"
frontend-url: "http://localhost:5173"
steam:
api-key: local
base-url: "http://localhost:8089/steam"
+5 -8
View File
@@ -7,7 +7,7 @@ spring:
deserialization:
accept-float-as-int: false
jpa:
open-in-view: true
open-in-view: false
flyway:
enabled: true
baseline-on-migrate: true
@@ -15,12 +15,6 @@ spring:
threads:
virtual:
enabled: true
mvc:
apiversion:
use:
query-parameter: version
default: "1.0"
supported: 1.0, 2,0
server:
port: 8090
@@ -42,8 +36,11 @@ app:
# Filled from the CI run by the Dockerfile.
commit: ${APP_BUILD_COMMIT:dev}
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:
allowed-origins: "https://tbp.andreas-dahm.eu"
allowed-origins: ${app.frontend-url}
steam:
api-key: ${STEAM_API_KEY}
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.test.AbstractUnitTest;
import core_service.shared.test.ContractTokens;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -48,10 +49,10 @@ class JwtServiceTest extends AbstractUnitTest {
jwtService = new JwtService(new JwksKeyLocator(jwtConfig, RestClient.create()));
}
private String signToken(String userId, String email, Long steamId) {
var builder = Jwts.builder()
.subject(email)
.claim("userId", userId)
private String signToken(String userId, Long steamId) {
JwtBuilder builder = Jwts.builder()
.issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
.subject(userId)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 60_000));
if (steamId != null) {
@@ -62,7 +63,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void parseLoginToken_ReturnsTheUser() {
String token = signToken("0815", "test@example.com", 123456789L);
String token = signToken("0815", 123456789L);
assertEquals(Optional.of(new UserPrincipal("0815", 123456789L)),
jwtService.parseLoginToken(token));
@@ -77,8 +78,8 @@ class JwtServiceTest extends AbstractUnitTest {
void parseLoginToken_RejectsAForeignKey() throws Exception {
KeyPair other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
String token = Jwts.builder()
.subject("test@example.com")
.claim("userId", "0815")
.issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
.subject("0815")
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(other.getPrivate(), Jwts.SIG.EdDSA)
.compact();
@@ -89,7 +90,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void parseLoginToken_RejectsATokenWithoutUserId() {
String token = Jwts.builder()
.subject("test@example.com")
.issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(signingKey, Jwts.SIG.EdDSA)
.compact();
@@ -97,10 +98,10 @@ class JwtServiceTest extends AbstractUnitTest {
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()
.issuer(JwtService.ISSUER).audience().add(audience).and()
.subject("public-profile")
.claim("typ", type)
.claim("profileUserId", profileUserId)
.expiration(new Date(System.currentTimeMillis() + expiresInMs))
.signWith(signingKey, Jwts.SIG.EdDSA).compact();
@@ -109,12 +110,12 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void verifyProfileTicket_ReturnsTheUser() {
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
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"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
@@ -123,7 +124,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
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"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
@@ -132,25 +133,26 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
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("public-profile", "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, "42", -1_000)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "not-a-number", 60_000)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket("garbage"));
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();
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(foreign));
}
@Test
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) {
io.jsonwebtoken.JwtBuilder builder = Jwts.builder().subject("tbp-auth").claim("typ", type).issuedAt(new Date());
private String signServiceToken(PrivateKey key, Long expiresInMs) {
JwtBuilder builder = Jwts.builder().issuer(JwtService.ISSUER).audience().add(JwtService.SERVICE_AUDIENCE).and()
.subject("tbp-auth").issuedAt(new Date());
if (expiresInMs != null) {
builder.expiration(new Date(System.currentTimeMillis() + expiresInMs));
}
@@ -159,30 +161,30 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void isServiceToken_AcceptsAServiceTokenOfAuth() {
assertTrue(jwtService.isServiceToken(signServiceToken(signingKey, "service", 60_000L)));
assertTrue(jwtService.isServiceToken(signServiceToken(signingKey, 60_000L)));
}
@Test
void isServiceToken_RejectsEverythingElse() throws Exception {
PrivateKey otherKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPrivate();
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, "service", null)), "no exp");
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, "service", -1_000L)), "expired");
assertFalse(jwtService.isServiceToken(signServiceToken(otherKey, "service", 60_000L)), "foreign key");
assertFalse(jwtService.isServiceToken(signToken("42", "a@b.de", null)), "login token");
assertFalse(jwtService.isServiceToken(signTicket("public-profile", "42", 60_000)), "profile ticket");
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, null)), "no exp");
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, -1_000L)), "expired");
assertFalse(jwtService.isServiceToken(signServiceToken(otherKey, 60_000L)), "foreign key");
assertFalse(jwtService.isServiceToken(signToken("42", null)), "login token");
assertFalse(jwtService.isServiceToken(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "42", 60_000)), "profile ticket");
assertFalse(jwtService.isServiceToken("garbage"));
}
@Test
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) {
return Jwts.builder()
.subject("test@example.com")
.claim("userId", "0815")
.issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
.subject("0815")
.claim("role", role)
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(signingKey, Jwts.SIG.EdDSA)
@@ -196,13 +198,14 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
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());
}
@Test
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();
assertEquals(Role.USER, jwtService.parseLoginToken(token).orElseThrow().role());
@@ -212,14 +215,6 @@ class JwtServiceTest extends AbstractUnitTest {
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
void contractTokens_OfEveryKind_AreAccepted() {
assertEquals("42", jwtService.parseLoginToken(sign(ContractTokens.claims(ContractTokens.LOGIN))).orElseThrow().id());
@@ -229,20 +224,20 @@ class JwtServiceTest extends AbstractUnitTest {
}
@Test
void legacyTokens_OfAnAuthFromBeforeItem26_AreAccepted() {
assertEquals("42", jwtService.parseLoginToken(sign(ContractTokens.legacyClaims(ContractTokens.LOGIN))).orElseThrow().id());
assertEquals(42L, jwtService.verifyProfileTicket(sign(ContractTokens.legacyClaims(ContractTokens.PUBLIC_PROFILE)))
.orElseThrow().userId());
assertTrue(jwtService.isServiceToken(sign(ContractTokens.legacyClaims(ContractTokens.SERVICE))));
void tokensWithoutAud_AreRefused() {
assertTrue(jwtService.parseLoginToken(sign(withoutAudience(ContractTokens.LOGIN))).isEmpty());
assertTrue(jwtService.verifyProfileTicket(sign(withoutAudience(ContractTokens.PUBLIC_PROFILE))).isEmpty());
assertFalse(jwtService.isServiceToken(sign(withoutAudience(ContractTokens.SERVICE))));
}
@Test
void tokensWithoutTheOldClaims_AreAccepted() {
assertEquals("42", jwtService.parseLoginToken(sign(without(ContractTokens.claims(ContractTokens.LOGIN), "userId")))
.orElseThrow().id());
assertEquals(42L, jwtService.verifyProfileTicket(sign(without(ContractTokens.claims(ContractTokens.PUBLIC_PROFILE), "typ")))
.orElseThrow().userId());
assertTrue(jwtService.isServiceToken(sign(without(ContractTokens.claims(ContractTokens.SERVICE), "typ"))));
// The shape of an Auth from before item 26, with the old claims it wrote.
private static Map<String, Object> withoutAudience(String kind) {
Map<String, Object> claims = ContractTokens.claims(kind);
claims.remove("aud");
claims.remove("iss");
claims.put("userId", "42");
claims.put("typ", kind.equals(ContractTokens.SERVICE) ? "service" : "public-profile");
return claims;
}
@Test
@@ -4,8 +4,6 @@ import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.HttpMethod;
@@ -45,6 +43,7 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
@AfterEach
void cleanUp() {
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() {
@@ -116,12 +115,10 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
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.
@ParameterizedTest
@ValueSource(booleans = {false, true})
void contractCalls_AreAnswered(boolean legacyToken) throws Exception {
String token = legacyToken ? ContractTokens.sign(ContractTokens.legacyClaims(ContractTokens.SERVICE))
: ContractTokens.serviceToken();
// The calls as Auth's CoreAccountService sends them.
@Test
void contractCalls_AreAnswered() throws Exception {
String token = ContractTokens.serviceToken();
// 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);
try {
@@ -111,10 +111,16 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
void findOrphans_ReturnsUsersNotInTheList() {
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();
}
// 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.
@Test
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 org.junit.jupiter.api.BeforeEach;
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.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
@@ -195,6 +197,14 @@ class GameImportControllerTest extends AbstractMockedAdaptersTest {
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
void create_TooManyRowsOrNoTitle_Is400() throws Exception {
String row = "{\"title\": \"Game\"}";
@@ -60,88 +60,23 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
}
@Test
void updateBacklog_StoresAllFields() throws Exception {
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.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());
void patchBacklog_FractionalRating_Is400() throws Exception {
patchBacklog(APPID, "{\"rating\":4.7}").andExpect(status().isBadRequest());
assertNull(stored().getRating());
}
@Test
void updateBacklog_NotesTooLong_Is400() 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 {
void patchBacklog_RemovedGame_Is404() throws Exception {
UserGame game = stored();
game.setRemovedAt(Instant.now());
userGameRepository.save(game);
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}"))
patchBacklog(APPID, "{\"notes\":\"x\"}")
.andExpect(status().isNotFound())
.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 {
return mockMvc.perform(patch("/api/library/games/" + appid + "/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.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.nullValue;
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.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.
@Test
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\"}"))
.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();
}
@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
void tryClaimRun_RunningRowStartedLongAgo_IsClaimedEvenWhenTheLastSyncEndedRecently() {
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_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
void candidates_YearSuffixTellsTheRemakeApart() {
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_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
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))));
when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamOwnedGamesResponse.class))
.thenAnswer(invocation -> {
@@ -45,27 +45,6 @@ class SteamApiServiceTest {
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
void getPlayerSummary_shouldReturnPlayer() {
String steamId = "76561197960435530";
@@ -135,15 +114,6 @@ class SteamApiServiceTest {
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
void getOwnedGames_shouldReturnGamesEvenWithNullValues() {
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));
}
/**
* 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) {
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": {
"login": {
"iss": "tbp-auth",
"aud": ["tbp-login"],
"sub": "42",
"userId": "42",
"role": "USER",
"steamId": 76561198000000001
},
@@ -13,7 +12,6 @@
"iss": "tbp-auth",
"aud": ["tbp-public-profile"],
"sub": "public-profile",
"typ": "public-profile",
"profileUserId": "42",
"profileStats": ["OVERVIEW"],
"sections": ["SUMMARY"]
@@ -21,27 +19,7 @@
"service": {
"iss": "tbp-auth",
"aud": ["tbp-internal"],
"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"
"sub": "tbp-auth"
}
},
"internal": {
@@ -55,6 +33,16 @@
"path": "/internal/accounts/deleted",
"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": {
"method": "POST",
"path": "/internal/accounts/purge",