Public Access
Compare commits
5
Commits
3512159b41
...
216c1af62f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
216c1af62f | ||
|
|
e347b7aac8 | ||
|
|
23bf5c41b4 | ||
|
|
34d64e7215 | ||
|
|
504c935b27 |
No files matched your search
@@ -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"]
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user