feat(psn): link PlayStation account and import played games
Build, Test & Push Core Service / quality-check (push) Successful in 1m22s
Build, Test & Push Core Service / docker (push) Successful in 1m27s

The NPSSO is used once to get a refresh token; only that token is
stored, sealed with AES-GCM (SecretBox, key APP_PSN_TOKEN_KEY; no key
= feature off). One-time import of played PS4/PS5 titles through the
import pipeline as source PSN. Unlink keeps the games. One PSN call
per user at a time. IGDB search now sends UTF-8, so non-ASCII names
like "Yōtei" match again.
This commit is contained in:
lobo committed 2026-10-07 13:30:48 +02:00
1 parent e87f40bba0
commit 7ac887ed5a
72 files changed
+2407 -78

No files matched your search

@@ -0,0 +1,17 @@
[
{
"id": 900003,
"name": "Mega Man Legacy Collection",
"first_release_date": 1451520000,
"cover": {
"image_id": "co1r7f"
},
"platforms": [
{
"name": "PlayStation 4",
"abbreviation": "PS4"
}
],
"total_rating_count": 300
}
]
@@ -0,0 +1,21 @@
[
{
"id": 900002,
"name": "Ghost of Tsushima",
"first_release_date": 1595548800,
"cover": {
"image_id": "co1r7f"
},
"platforms": [
{
"name": "PlayStation 4",
"abbreviation": "PS4"
},
{
"name": "PlayStation 5",
"abbreviation": "PS5"
}
],
"total_rating_count": 2000
}
]
@@ -0,0 +1,17 @@
[
{
"id": 900001,
"name": "Ghost of Yōtei",
"first_release_date": 1760054400,
"cover": {
"image_id": "co1r7f"
},
"platforms": [
{
"name": "PlayStation 5",
"abbreviation": "PS5"
}
],
"total_rating_count": 800
}
]
+81
View File
@@ -0,0 +1,81 @@
{
"titles": [
{
"titleId": "PPSA26344_00",
"name": "Ghost of Yōtei",
"category": "ps5_native_game",
"service": "none(purchased)",
"playCount": 31,
"playDuration": "PT65H9M15S",
"imageUrl": "https://image.api.playstation.com/local/ghost-of-yotei.png",
"firstPlayedDateTime": "2025-10-03T18:20:11.000Z",
"lastPlayedDateTime": "2026-10-01T20:41:07.000Z",
"concept": {
"id": 10003923,
"titleIds": ["PPSA26344_00"]
}
},
{
"titleId": "CUSA02521_00",
"name": "Mega Man Legacy Collection™",
"category": "ps4_game",
"service": "none(purchased)",
"playCount": 9,
"playDuration": "PT8H30M28S",
"imageUrl": "https://image.api.playstation.com/local/mega-man-legacy.png",
"firstPlayedDateTime": "2017-01-14T09:02:51.000Z",
"lastPlayedDateTime": "2024-03-02T16:10:30.000Z",
"concept": {
"id": 202697,
"titleIds": ["CUSA02521_00", "CUSA02522_00"]
}
},
{
"titleId": "PPSA03208_00",
"name": "Ghost of Tsushima DIRECTOR'S CUT (PS5)",
"category": "ps5_native_game",
"service": "none(purchased)",
"playCount": 22,
"playDuration": "PT64H3S",
"imageUrl": "https://image.api.playstation.com/local/ghost-of-tsushima.png",
"firstPlayedDateTime": "2021-08-20T19:44:02.000Z",
"lastPlayedDateTime": "2023-12-11T21:15:48.000Z",
"concept": {
"id": 235227,
"titleIds": ["PPSA03208_00", "CUSA17722_00", "CUSA17723_00"]
}
},
{
"titleId": "CUSA17722_00",
"name": "Ghost of Tsushima",
"category": "ps4_game",
"service": "none(purchased)",
"playCount": 3,
"playDuration": "PT1H30M",
"imageUrl": "https://image.api.playstation.com/local/ghost-of-tsushima.png",
"firstPlayedDateTime": "2020-07-17T10:00:00.000Z",
"lastPlayedDateTime": "2020-07-19T12:00:00.000Z",
"concept": {
"id": 235227,
"titleIds": ["PPSA03208_00", "CUSA17722_00", "CUSA17723_00"]
}
},
{
"titleId": "PPSA00000_00",
"name": "Monster Hunter Wilds",
"category": "ps5_native_game",
"service": "none(purchased)",
"playCount": 5,
"playDuration": "PT12H",
"imageUrl": "https://image.api.playstation.com/local/monster-hunter-wilds.png",
"firstPlayedDateTime": "2025-03-01T10:00:00.000Z",
"lastPlayedDateTime": "2025-03-09T22:00:00.000Z",
"concept": {
"id": 10000001,
"titleIds": ["PPSA00000_00"]
}
}
],
"totalItemCount": 5,
"nextOffset": null
}
+9
View File
@@ -0,0 +1,9 @@
{
"access_token": "local-access",
"token_type": "bearer",
"expires_in": 3599,
"scope": "psn:mobile.v2.core psn:clientapp",
"id_token": "local-id",
"refresh_token": "local-refresh",
"refresh_token_expires_in": 863999
}
@@ -0,0 +1,19 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games",
"bodyPatterns": [
{
"contains": "search \"Mega Man Legacy"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-game-search-psn-mega-man-legacy.json"
},
"priority": 4
}
@@ -0,0 +1,19 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games",
"bodyPatterns": [
{
"contains": "search \"Ghost of Tsushima"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-game-search-psn-tsushima.json"
},
"priority": 4
}
@@ -0,0 +1,19 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games",
"bodyPatterns": [
{
"contains": "search \"Ghost of Yōtei"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-game-search-psn-yotei.json"
},
"priority": 4
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/psn/auth/authorize"
},
"response": {
"status": 302,
"headers": {
"Location": "com.scee.psxandroid.scecompcall://redirect/?error=invalid_grant"
}
},
"priority": 10
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/psn/auth/authorize",
"headers": {
"Cookie": {
"contains": "npsso=local-npsso"
}
}
},
"response": {
"status": 302,
"headers": {
"Location": "com.scee.psxandroid.scecompcall://redirect/?code=local-code&cid=local"
}
},
"priority": 5
}
+13
View File
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/psn/gamelist/v2/users/me/titles"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "psn-gamelist.json"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"request": {
"method": "POST",
"urlPath": "/psn/auth/token"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "psn-token.json"
}
}
+44 -5
View File
@@ -136,6 +136,9 @@ To Be Played is split into three repositories:
then after 30 days. then after 30 days.
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the - **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the
feature is off and the rest works. feature is off and the rest works.
- **PlayStation link** (optional, unofficial API): the user pastes an NPSSO once, Core keeps only the encrypted refresh
token (about 10 days) and one click imports the played PS4/PS5 titles with playtime through the import pipeline. See
"PlayStation link". Off without `APP_PSN_TOKEN_KEY`.
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games - **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
and average completion (the numbers count a linked set once; the lists keep every edition). Readable only and average completion (the numbers count a linked set once; the lists keep every edition). Readable only
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat
@@ -208,6 +211,9 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/library/imports` (`{imports}`, the last 20), `/api/library/imports/{id}` (status, progress counts, `ahead` while it waits in a queue, `attempts`; the rows with their candidates while `READY`) | JWT | | `GET` | `/api/library/imports` (`{imports}`, the last 20), `/api/library/imports/{id}` (status, progress counts, `ahead` while it waits in a queue, `attempts`; the rows with their candidates while `READY`) | JWT |
| `POST` | `/api/library/imports/{id}/save` (`{rows: [{lineNo, igdbId, platform, status, playtimeMinutes}]}`; `202`, then poll until `SAVED`; `409 IMPORT_NOT_READY`) | JWT | | `POST` | `/api/library/imports/{id}/save` (`{rows: [{lineNo, igdbId, platform, status, playtimeMinutes}]}`; `202`, then poll until `SAVED`; `409 IMPORT_NOT_READY`) | JWT |
| `POST` | `/api/library/imports/{id}/retry` (a `FAILED` import; `202`, `409 IMPORT_NOT_FAILED` / `IMPORT_RUNNING`) | JWT | | `POST` | `/api/library/imports/{id}/retry` (a `FAILED` import; `202`, `409 IMPORT_NOT_FAILED` / `IMPORT_RUNNING`) | JWT |
| `POST` | `/api/library/psn-account` (`{npsso}`; `200 {enabled, linked, linkedAt, expiresAt}`, `400 NPSSO_INVALID`, `502 PSN_ERROR`, `503 PSN_OFF`) | JWT |
| `GET`, `DELETE` | `/api/library/psn-account` (`{enabled, linked, linkedAt, expiresAt}`), unlink (`204`, deletes the stored token) | JWT |
| `POST` | `/api/library/psn-account/imports` (reads the played titles; `202 {id}` like an import, `204` no titles, `404 PSN_NOT_LINKED`, `409 PSN_RELINK` / `IMPORT_RUNNING`, `502 PSN_ERROR`, `503 PSN_OFF` / `IGDB_OFF`) | JWT |
| `GET`, `DELETE` | `/api/library/imports/{id}/undo-check` (`{games, changed}`), `/api/library/imports/{id}/games` (undo; `204`, `409 IMPORT_NOT_SAVED`) | JWT | | `GET`, `DELETE` | `/api/library/imports/{id}/undo-check` (`{games, changed}`), `/api/library/imports/{id}/games` (undo; `204`, `409 IMPORT_NOT_SAVED`) | JWT |
| `POST` | `/api/library/sync` | JWT | | `POST` | `/api/library/sync` | JWT |
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT | | `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
@@ -266,9 +272,11 @@ flowchart LR
Steam["steam_adapter"] Steam["steam_adapter"]
Igdb["igdb_adapter"] Igdb["igdb_adapter"]
Speedrun["speedrun_adapter"] Speedrun["speedrun_adapter"]
Psn["psn_adapter"]
LibService --> Steam LibService --> Steam
LibService --> Igdb LibService --> Igdb
LibService --> Speedrun LibService --> Speedrun
LibService --> Psn
end end
Browser -->|"tbp_access cookie"| Security Browser -->|"tbp_access cookie"| Security
Rewards --> Db Rewards --> Db
@@ -278,6 +286,7 @@ flowchart LR
Igdb --> IgdbApi["IGDB API"] Igdb --> IgdbApi["IGDB API"]
Igdb -->|"OAuth token"| Twitch["Twitch"] Igdb -->|"OAuth token"| Twitch["Twitch"]
Speedrun --> SpeedrunApi["speedrun.com API"] Speedrun --> SpeedrunApi["speedrun.com API"]
Psn -->|"NPSSO sign-in, played titles"| PsnApi["PlayStation API (unofficial)"]
LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user"| Db[("PostgreSQL tbp_core")] LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user"| Db[("PostgreSQL tbp_core")]
Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user, listed_user, account_name, game_alias"| Db Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user, listed_user, account_name, game_alias"| Db
Follow -->|"follow, follow_digest, inbox_note, public_user, sync_change, user_game_status_change, account_name"| Db Follow -->|"follow, follow_digest, inbox_note, public_user, sync_change, user_game_status_change, account_name"| Db
@@ -288,7 +297,7 @@ flowchart LR
| Module | Content | | Module | Content |
|---|---| |---|---|
| `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data | | `library` | Sync, backlog, manual games, imports, the PlayStation link, playtime history, wishlist, achievements, public profile data |
| `stats` | Public numbers for the landing page | | `stats` | Public numbers for the landing page |
| `version` | `GET /api/version`: commit and build time of the running image | | `version` | `GET /api/version`: commit and build time of the running image |
| `follow` | Follows (`follow`), the digest cursor per followed user (`follow_digest`) and `FollowDigestService`: one note per follower and batch, from `sync_change` and the hand rows of `user_game_status_change` | | `follow` | Follows (`follow`), the digest cursor per followed user (`follow_digest`) and `FollowDigestService`: one note per follower and batch, from `sync_change` and the hand rows of `user_game_status_change` |
@@ -297,6 +306,7 @@ flowchart LR
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures | | `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search | | `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs | | `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
| `psn_adapter` | PlayStation client (unofficial): NPSSO to tokens, refresh, played titles |
`follow` and `inbox` read `account_name`, `public_user`, `blocked_user`, `deleted_user`, `sync_result`, `sync_change`, `follow` and `inbox` read `account_name`, `public_user`, `blocked_user`, `deleted_user`, `sync_result`, `sync_change`,
`user_game` and `user_game_status_change` in SQL; the sync tells `follow` through `shared.event.LibraryActivityEvent`. `user_game` and `user_game_status_change` in SQL; the sync tells `follow` through `shared.event.LibraryActivityEvent`.
@@ -314,8 +324,8 @@ a column of `user_game` drops this view first, then `listed_user_game`.
Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo` Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo`
(repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build: (repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build:
- Modules do not depend on each other. Only `library` may use `steam_adapter`, `igdb_adapter` and - Modules do not depend on each other. Only `library` may use `steam_adapter`, `igdb_adapter`,
`speedrun_adapter`. `speedrun_adapter` and `psn_adapter`.
- No cycles between `modules`, `shared` and `configuration`. - No cycles between `modules`, `shared` and `configuration`.
- `api` never uses `repo`, and `repo` never uses `api`. - `api` never uses `repo`, and `repo` never uses `api`.
- `domain` never uses `api`, `service`, `repo` or an adapter: the import stores IGDB hits as the own record - `domain` never uses `api`, `service`, `repo` or an adapter: the import stores IGDB hits as the own record
@@ -504,6 +514,7 @@ user ids, which removes the rows of users Auth does not know (refused for more t
The tombstone also ends the user's follows in both directions without a note and removes the user's `public_user` and The tombstone also ends the user's follows in both directions without a note and removes the user's `public_user` and
`follow_digest` rows; the purge removes the user's inbox notes and the notes about the user. `follow_digest` rows; the purge removes the user's inbox notes and the notes about the user.
The purge also removes the user's `account_name` and `listed_user` rows and link requests (`game_link_request`). The purge also removes the user's `account_name` and `listed_user` rows and link requests (`game_link_request`).
The tombstone also deletes the user's stored PlayStation token (`user_psn_account`) at once.
### Blocked users and Steam ID changes ### Blocked users and Steam ID changes
@@ -528,7 +539,7 @@ A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games
`user_game.source` (`GameSource`) says who owns the row. The sync never changes or removes a `MANUAL` row, with `user_game.source` (`GameSource`) says who owns the row. The sync never changes or removes a `MANUAL` row, with
one exception: when the user later owns the game on Steam under the same appid, the sync takes the row over: it becomes one exception: when the user later owns the game on Steam under the same appid, the sync takes the row over: it becomes
`STEAM`, and status, rating and notes stay. Only `MANUAL` rows can be edited (`PUT /api/library/games/{appid}/manual`) `STEAM`, and status, rating and notes stay. `PSN` rows (a PlayStation import, see below) live the same way. Only `MANUAL` and `PSN` rows can be edited (`PUT /api/library/games/{appid}/manual`)
or deleted (`DELETE /api/library/games/{appid}`). Time to beat uses the Steam appid for positive keys and the IGDB or deleted (`DELETE /api/library/games/{appid}`). Time to beat uses the Steam appid for positive keys and the IGDB
id for negative keys. Search and add check both keys of a game. A game added under minus its IGDB id can still get id for negative keys. Search and add check both keys of a game. A game added under minus its IGDB id can still get
a second row when IGDB learns its Steam appid later and the user owns it on Steam; an auditor then merges the two a second row when IGDB learns its Steam appid later and the user owns it on Steam; an auditor then merges the two
@@ -570,10 +581,34 @@ stateDiagram-v2
FAILED --> UNDONE: DELETE /games (the games added before it failed) FAILED --> UNDONE: DELETE /games (the games added before it failed)
``` ```
The undo deletes the games of the import that are still `MANUAL` (a game the Steam sync took over stays). A removed The undo deletes the games of the import that are still `MANUAL` or `PSN` (a game the Steam sync took over stays). A removed
row the import brings back gets no `import_id`: it holds rating, notes and history from before, so the undo keeps it; row the import brings back gets no `import_id`: it holds rating, notes and history from before, so the undo keeps it;
`undo-check` counts those with a rating, notes, priority or a status change after the save. `undo-check` counts those with a rating, notes, priority or a status change after the save.
### PlayStation link
`modules/psn_adapter` calls PlayStation's unofficial API (the public client of the PlayStation app, as the `psn-api`
project ships it; all values in `app.psn.*`). `PsnAccountService` in `library` holds the use cases:
- **Link** (`POST /api/library/psn-account`): the NPSSO (the user's `npsso` cookie from playstation.com) goes to
`/authorize` (the redirect is not followed: `Redirect.NEVER`), the `code` in its `Location` header to `/token`.
Only the **refresh token** is stored, in `user_psn_account`, sealed with AES-256-GCM (`SecretBox`, key
`APP_PSN_TOKEN_KEY`, base64 of 32 bytes). The NPSSO, the code and the access token are never stored; no log line,
answer or `toString` of a token type holds a secret. No key = the feature is off (`enabled: false`); a key of the
wrong length stops Core at start. A changed key makes stored links unreadable: the import answers `PSN_RELINK`.
- **Expiry**: the refresh token lives about 10 days (`refresh_expires_at`). A refresh that returns the same
refresh token keeps the stored expiry; a new one stores its own. An expired link answers `409 PSN_RELINK`; the user
pastes a new NPSSO. The Settings page shows the date.
- **Import** (`POST /api/library/psn-account/imports`): one refresh call, then `gamelist/v2` (200 per page, at most
1000 titles). `PsnTitles` cleans the names (no `™`/`®`, no PS4/PS5 suffix) and joins the PS4 and PS5 versions of one
`concept.id` into one line (name and platform of the most played version, playtime summed). The lines go through
`GameImportService.createLines` as a `PSN` import: IGDB match, review, save, undo. Saved rows have `source = PSN` and
`platform` PS5 or PS4. A game also owned on Steam stays one row (the Steam row wins); its PSN playtime is not added.
The client cannot start a `PSN` import with its own rows.
- **Limits**: one limiter `psnApi` for the whole server (1 request / 4 s, taken per HTTP request). Titles bought but
never started are not listed by PlayStation. A `PSN` row pays no rewards (no Steam proof), and a merge undo does not
move it back. Repeating sync and trophies are not built.
## Run locally ## Run locally
### Local development without keys (profile `local`) ### Local development without keys (profile `local`)
@@ -607,6 +642,9 @@ What the `local` profile does:
- The browser still loads game pictures from the Steam and IGDB image servers; offline they stay gray. - The browser still loads game pictures from the Steam and IGDB image servers; offline they stay gray.
- "Sign in through Steam" still opens the real Steam page (no key needed). The dev user is already linked. - "Sign in through Steam" still opens the real Steam page (no key needed). The dev user is already linked.
- A request to WireMock that has no recording answers 404 and shows up in Core's log. - A request to WireMock that has no recording answers 404 and shows up in Core's log.
- PlayStation: paste `local-npsso` in the PlayStation card of the settings (any other value is refused). The import then
lists 4 games: Ghost of Yōtei, Mega Man Legacy Collection, Ghost of Tsushima (PS4 and PS5 version as one line) and Monster
Hunter Wilds, which IGDB's recording does not know: the review shows an unrelated guess, not checked. The local profile has a fixed token key.
- To see an IGDB link suggestion: add Hollow Knight by hand (game search, PS5). It shares IGDB's "same game" key with the - To see an IGDB link suggestion: add Hollow Knight by hand (game search, PS5). It shares IGDB's "same game" key with the
Steam copy, so the link queue (auditor) lists the pair with the IGDB badge. Steam copy, so the link queue (auditor) lists the pair with the IGDB badge.
@@ -637,6 +675,7 @@ Configuration comes from environment variables:
| `JWT_PUBLIC_KEY` | Ed25519 public key (Base64 of the X.509 DER bytes), used until the first JWKS load. Set this, `JWT_JWKS_URL`, or both | | `JWT_PUBLIC_KEY` | Ed25519 public key (Base64 of the X.509 DER bytes), used until the first JWKS load. Set this, `JWT_JWKS_URL`, or both |
| `STEAM_API_KEY` | Steam Web API key | | `STEAM_API_KEY` | Steam Web API key |
| `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) | | `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) |
| `APP_PSN_TOKEN_KEY` | Key for the stored PlayStation tokens, `openssl rand -base64 32` (optional; empty = PlayStation link off) |
| `DB_PASSWORD` | Local PostgreSQL password | | `DB_PASSWORD` | Local PostgreSQL password |
## Tests ## Tests
@@ -22,12 +22,12 @@ public class AccountCleanupRepository {
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last. // Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement", static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "backlog_order", "user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "backlog_order",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped", "sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "user_psn_account", "reward_ledger", "reward_equipped",
"account_role_change", "blocked_user", "listed_user", "follow", "follow_digest", "inbox_note", "public_user", "deleted_user"); "account_role_change", "blocked_user", "listed_user", "follow", "follow_digest", "inbox_note", "public_user", "deleted_user");
// Every user with data has a row in one of these; deleteRows then removes the child rows too. // Every user with data has a row in one of these; deleteRows then removes the child rows too.
private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game", private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
"game_import", "blocked_user", "listed_user", "account_role_change", "deleted_user", "account_name", "public_user", "follow", "inbox_note"); "game_import", "user_psn_account", "blocked_user", "listed_user", "account_role_change", "deleted_user", "account_name", "public_user", "follow", "inbox_note");
private final NamedParameterJdbcTemplate jdbc; private final NamedParameterJdbcTemplate jdbc;
@@ -36,7 +36,8 @@ public class AccountCleanupRepository {
} }
/** /**
* Also takes the users off the nightly sync list and ends their follows, both ways, without a note. * Also takes the users off the nightly sync list, ends their follows, both ways, without a note, and drops their
* stored PlayStation token at once.
*/ */
public void addDeleted(Collection<Long> userIds) { public void addDeleted(Collection<Long> userIds) {
if (userIds.isEmpty()) { if (userIds.isEmpty()) {
@@ -48,6 +49,7 @@ public class AccountCleanupRepository {
jdbc.update("delete from follow where user_id in (:ids) or followed_user_id in (:ids)", ids(userIds)); jdbc.update("delete from follow where user_id in (:ids) or followed_user_id in (:ids)", ids(userIds));
jdbc.update("delete from follow_digest where user_id in (:ids)", ids(userIds)); jdbc.update("delete from follow_digest where user_id in (:ids)", ids(userIds));
jdbc.update("delete from public_user where user_id in (:ids)", ids(userIds)); jdbc.update("delete from public_user where user_id in (:ids)", ids(userIds));
jdbc.update("delete from user_psn_account where user_id in (:ids)", ids(userIds));
} }
/** /**
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
@@ -673,7 +674,8 @@ public class IgdbApiService {
.uri(path) .uri(path)
.header("Client-ID", clientId) .header("Client-ID", clientId)
.header("Authorization", "Bearer " + token) .header("Authorization", "Bearer " + token)
.contentType(MediaType.TEXT_PLAIN) // Without the charset the body goes out in ISO-8859-1 and a name like "Yōtei" arrives as "Y?tei".
.contentType(new MediaType(MediaType.TEXT_PLAIN, StandardCharsets.UTF_8))
.accept(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)
.body(body) .body(body)
.retrieve() .retrieve()
@@ -0,0 +1,90 @@
package core_service.modules.library.api;
import core_service.modules.library.api.dto.PsnAccountDto;
import core_service.modules.library.api.dto.PsnLinkRequest;
import core_service.modules.library.service.PsnAccountService;
import core_service.shared.api.ErrorResponse;
import core_service.shared.security.UserPrincipal;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* The user's linked PlayStation account and its import. No answer repeats the request.
*/
@RestController
@RequestMapping("/api/library/psn-account")
@RequiredArgsConstructor
public class PsnAccountController {
private static final ResponseEntity<?> PSN_OFF = error(HttpStatus.SERVICE_UNAVAILABLE, "PSN_OFF",
"The PlayStation link is not set up on this server");
private static final ResponseEntity<?> PSN_BUSY = error(HttpStatus.TOO_MANY_REQUESTS, "PSN_BUSY",
"Another PlayStation request of yours is still running");
private static final ResponseEntity<?> PSN_ERROR = error(HttpStatus.BAD_GATEWAY, "PSN_ERROR",
"PlayStation did not answer; try again later");
private final PsnAccountService accountService;
@PostMapping
public ResponseEntity<?> link(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody PsnLinkRequest request) {
return switch (accountService.link(principal.getIdAsLong(), request.npsso())) {
case PsnAccountService.LinkResult.Linked linked -> ResponseEntity.ok(dto(linked.status()));
case PsnAccountService.LinkResult.Rejected ignored -> error(HttpStatus.BAD_REQUEST, "NPSSO_INVALID",
"PlayStation did not accept the NPSSO; it may have expired");
case PsnAccountService.LinkResult.Off ignored -> PSN_OFF;
case PsnAccountService.LinkResult.Unavailable ignored -> PSN_ERROR;
case PsnAccountService.LinkResult.Busy ignored -> PSN_BUSY;
};
}
@GetMapping
public PsnAccountDto get(@AuthenticationPrincipal UserPrincipal principal) {
return dto(accountService.get(principal.getIdAsLong()));
}
@DeleteMapping
public ResponseEntity<Void> unlink(@AuthenticationPrincipal UserPrincipal principal) {
accountService.unlink(principal.getIdAsLong());
return ResponseEntity.noContent().build();
}
@PostMapping("/imports")
public ResponseEntity<?> startImport(@AuthenticationPrincipal UserPrincipal principal) {
return switch (accountService.startImport(principal.getIdAsLong())) {
case PsnAccountService.ImportResult.Started started -> ResponseEntity.status(HttpStatus.ACCEPTED)
.body(Map.of("id", started.id()));
case PsnAccountService.ImportResult.NotLinked ignored -> error(HttpStatus.NOT_FOUND, "PSN_NOT_LINKED",
"No PlayStation account is linked");
case PsnAccountService.ImportResult.Relink ignored -> error(HttpStatus.CONFLICT, "PSN_RELINK",
"The PlayStation link has expired; link again with a new NPSSO");
case PsnAccountService.ImportResult.NoTitles ignored -> ResponseEntity.noContent().build();
case PsnAccountService.ImportResult.Off ignored -> PSN_OFF;
case PsnAccountService.ImportResult.Unavailable ignored -> PSN_ERROR;
case PsnAccountService.ImportResult.Busy ignored -> PSN_BUSY;
case PsnAccountService.ImportResult.AlreadyMatching ignored -> error(HttpStatus.CONFLICT, "IMPORT_RUNNING",
"Another import is still being matched");
case PsnAccountService.ImportResult.SearchUnavailable ignored -> error(HttpStatus.SERVICE_UNAVAILABLE,
"IGDB_OFF", "Importing games needs the IGDB app");
};
}
private static PsnAccountDto dto(PsnAccountService.Status status) {
return new PsnAccountDto(status.enabled(), status.linked(), status.linkedAt(), status.expiresAt());
}
private static ResponseEntity<?> error(HttpStatus status, String code, String message) {
return ResponseEntity.status(status).body(new ErrorResponse(code, message));
}
}
@@ -19,6 +19,11 @@ public record ImportRequest(
public static final int MAX_ROWS = 1000; public static final int MAX_ROWS = 1000;
@AssertTrue(message = "PSN imports start at /api/library/psn-account/imports")
public boolean isNotPsn() {
return source != GameImport.Source.PSN;
}
/** /**
* @param igdbId the game already picked in the search; then the title is not searched * @param igdbId the game already picked in the search; then the title is not searched
*/ */
@@ -0,0 +1,14 @@
package core_service.modules.library.api.dto;
import java.time.Instant;
/**
* @param enabled false when Core has no APP_PSN_TOKEN_KEY: the PlayStation link is off
* @param expiresAt when the link ends and a new NPSSO is needed; null when not linked
*/
public record PsnAccountDto(
boolean enabled,
boolean linked,
Instant linkedAt,
Instant expiresAt
) {}
@@ -0,0 +1,18 @@
package core_service.modules.library.api.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/**
* The NPSSO of the user's PlayStation session. Used once for the sign-in; never logged, never stored.
*/
public record PsnLinkRequest(
@NotBlank @Size(max = 128) @Pattern(regexp = "[A-Za-z0-9_-]+", message = "must be letters, digits, - or _") String npsso
) {
@Override
public String toString() {
return "PsnLinkRequest[]";
}
}
@@ -20,9 +20,10 @@ public record GameImport(long id, long userId, Source source, Status status, int
/** /**
* Where the lines came from. GALAXY, GROUVEE, HLTB and PLAYNITE are exports the Frontend reads; BOOKMARKLET is * 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. * the list read on a store page. Core treats them all alike. PSN: the played titles Core read from the linked
* PlayStation account.
*/ */
public enum Source { TEXT, CSV, SEARCH, GALAXY, GROUVEE, HLTB, PLAYNITE, BOOKMARKLET } public enum Source { TEXT, CSV, SEARCH, GALAXY, GROUVEE, HLTB, PLAYNITE, BOOKMARKLET, PSN }
public enum Status { QUEUED, MATCHING, READY, SAVING, SAVED, UNDONE, FAILED } public enum Status { QUEUED, MATCHING, READY, SAVING, SAVED, UNDONE, FAILED }
@@ -7,5 +7,10 @@ package core_service.modules.library.domain;
*/ */
public enum GameSource { public enum GameSource {
STEAM, STEAM,
MANUAL MANUAL,
/**
* Rows of a PlayStation import. In this slice the sync treats them like MANUAL: it takes them over on Steam and
* never removes them.
*/
PSN
} }
@@ -0,0 +1,16 @@
package core_service.modules.library.domain;
import java.time.Instant;
/**
* A linked PlayStation account (V38). Only the sealed refresh token is stored, never the NPSSO.
*
* @param refreshExpiresAt when the user must paste a new NPSSO
*/
public record PsnAccount(long userId, byte[] refreshTokenEnc, Instant refreshExpiresAt, Instant linkedAt) {
@Override
public String toString() {
return "PsnAccount[userId=" + userId + ", refreshExpiresAt=" + refreshExpiresAt + "]";
}
}
@@ -100,10 +100,10 @@ public class UserGame {
this.status = GameStatus.initialFor(playtimeMinutes); this.status = GameStatus.initialFor(playtimeMinutes);
} }
public static UserGame manual(Long userId, Long appid, String platform, GameStatus status, int playtimeMinutes, public static UserGame manual(Long userId, Long appid, GameSource source, String platform, GameStatus status,
Instant now) { int playtimeMinutes, Instant now) {
UserGame game = new UserGame(userId, appid, playtimeMinutes, now, now); UserGame game = new UserGame(userId, appid, playtimeMinutes, now, now);
game.source = GameSource.MANUAL; game.source = source;
game.platform = platform; game.platform = platform;
game.status = status; game.status = status;
game.statusSetByUser = true; game.statusSetByUser = true;
@@ -115,6 +115,11 @@ public class GameImportRepository {
Long.class); Long.class);
} }
public GameImport.Source findSource(long id) {
return GameImport.Source.valueOf(jdbc.queryForObject("select source from game_import where id = :id",
new MapSqlParameterSource("id", id), String.class));
}
/** /**
* Marks the import as run by a worker, from now on watched by the watchdog. * Marks the import as run by a worker, from now on watched by the watchdog.
*/ */
@@ -235,7 +240,7 @@ public class GameImportRepository {
*/ */
public int countGames(long userId, long importId) { public int countGames(long userId, long importId) {
return jdbc.queryForObject("select count(*) from user_game where user_id = :user and import_id = :import " return jdbc.queryForObject("select count(*) from user_game where user_id = :user and import_id = :import "
+ "and source = 'MANUAL'", new MapSqlParameterSource("user", userId).addValue("import", importId), + "and source in ('MANUAL', 'PSN')", new MapSqlParameterSource("user", userId).addValue("import", importId),
Integer.class); Integer.class);
} }
@@ -250,7 +255,7 @@ public class GameImportRepository {
public int countChangedGames(long userId, long importId) { public int countChangedGames(long userId, long importId) {
return jdbc.queryForObject(""" return jdbc.queryForObject("""
select count(*) from user_game g join game_import i on i.id = g.import_id select count(*) from user_game g join game_import i on i.id = g.import_id
where g.user_id = :user and g.import_id = :import and g.source = 'MANUAL' where g.user_id = :user and g.import_id = :import and g.source in ('MANUAL', 'PSN')
and (g.rating is not null or g.notes is not null or g.priority is not null and (g.rating is not null or g.notes is not null or g.priority is not null
or exists (select 1 from user_game_status_change c where c.user_id = g.user_id or exists (select 1 from user_game_status_change c where c.user_id = g.user_id
and c.appid = g.appid and c.changed_at > i.saved_at)) and c.appid = g.appid and c.changed_at > i.saved_at))
@@ -261,7 +266,7 @@ public class GameImportRepository {
* Deletes {@link #countGames} the way the manual remove does: with status, rating, notes and history. * Deletes {@link #countGames} the way the manual remove does: with status, rating, notes and history.
*/ */
public int deleteGames(long userId, long importId) { public int deleteGames(long userId, long importId) {
return jdbc.update("delete from user_game where user_id = :user and import_id = :import and source = 'MANUAL'", return jdbc.update("delete from user_game where user_id = :user and import_id = :import and source in ('MANUAL', 'PSN')",
new MapSqlParameterSource("user", userId).addValue("import", importId)); new MapSqlParameterSource("user", userId).addValue("import", importId));
} }
@@ -0,0 +1,58 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.PsnAccount;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Optional;
@Repository
@RequiredArgsConstructor
public class PsnAccountRepository {
private final NamedParameterJdbcTemplate jdbc;
public Optional<PsnAccount> find(long userId) {
return jdbc.query("select user_id, refresh_token_enc, refresh_expires_at, linked_at from user_psn_account "
+ "where user_id = :user", new MapSqlParameterSource("user", userId),
(rs, i) -> new PsnAccount(rs.getLong("user_id"), rs.getBytes("refresh_token_enc"),
rs.getTimestamp("refresh_expires_at").toInstant(), rs.getTimestamp("linked_at").toInstant()))
.stream().findFirst();
}
/**
* A new link replaces the old one and starts "linked since" again.
*/
public void upsert(long userId, byte[] refreshTokenEnc, Instant refreshExpiresAt) {
jdbc.update("""
insert into user_psn_account (user_id, refresh_token_enc, refresh_expires_at) values (:user, :token, :expires)
on conflict (user_id) do update set refresh_token_enc = :token, refresh_expires_at = :expires,
linked_at = now()""", params(userId, refreshTokenEnc, refreshExpiresAt));
}
public void updateToken(long userId, byte[] refreshTokenEnc, Instant refreshExpiresAt) {
jdbc.update("update user_psn_account set refresh_token_enc = :token, refresh_expires_at = :expires "
+ "where user_id = :user", params(userId, refreshTokenEnc, refreshExpiresAt));
}
/**
* PlayStation refused the refresh token: the link counts as expired.
*/
public void expireNow(long userId) {
jdbc.update("update user_psn_account set refresh_expires_at = now() where user_id = :user",
new MapSqlParameterSource("user", userId));
}
public int delete(long userId) {
return jdbc.update("delete from user_psn_account where user_id = :user", new MapSqlParameterSource("user", userId));
}
private static MapSqlParameterSource params(long userId, byte[] token, Instant expires) {
return new MapSqlParameterSource("user", userId).addValue("token", token)
.addValue("expires", Timestamp.from(expires));
}
}
@@ -49,7 +49,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
List<Long> findActiveAppids(@Param("userId") Long userId, @Param("appids") Collection<Long> appids); List<Long> findActiveAppids(@Param("userId") Long userId, @Param("appids") Collection<Long> appids);
@Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null " + @Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null " +
"and ug.source = core_service.modules.library.domain.GameSource.MANUAL") "and ug.source <> core_service.modules.library.domain.GameSource.STEAM")
List<Long> findActiveManualAppids(@Param("userId") Long userId); List<Long> findActiveManualAppids(@Param("userId") Long userId);
@Query("select ug.appid from UserGame ug where " + LISTED + @Query("select ug.appid from UserGame ug where " + LISTED +
@@ -119,7 +119,7 @@ public class GameDetailService {
gameEditionService.refreshInBackground(List.of(appid)); gameEditionService.refreshInBackground(List.of(appid));
gameCreditService.refreshInBackground(List.of(appid)); gameCreditService.refreshInBackground(List.of(appid));
} }
if (userGame.getSource() == GameSource.MANUAL) { if (userGame.getSource() != GameSource.STEAM) {
// Users without a Steam ID never sync, so the time to beat of their games is asked again here. // Users without a Steam ID never sync, so the time to beat of their games is asked again here.
timeToBeatService.refreshInBackground(List.of(appid)); timeToBeatService.refreshInBackground(List.of(appid));
moodService.refreshInBackground(List.of(appid)); moodService.refreshInBackground(List.of(appid));
@@ -56,14 +56,21 @@ public class GameImportService {
} }
public CreateResult create(Long userId, GameImport.Source source, List<ImportRequest.Row> rows) { public CreateResult create(Long userId, GameImport.Source source, List<ImportRequest.Row> rows) {
if (!igdbApiService.isEnabled()) {
return new CreateResult.SearchUnavailable();
}
List<GameImport.Line> lines = IntStream.range(0, rows.size()).mapToObj(index -> { List<GameImport.Line> lines = IntStream.range(0, rows.size()).mapToObj(index -> {
ImportRequest.Row row = rows.get(index); ImportRequest.Row row = rows.get(index);
return new GameImport.Line(index + 1, strippedOrNull(row.title()), strippedOrNull(row.platform()), return new GameImport.Line(index + 1, strippedOrNull(row.title()), strippedOrNull(row.platform()),
row.playtimeMinutes(), row.status(), row.igdbId()); row.playtimeMinutes(), row.status(), row.igdbId());
}).toList(); }).toList();
return createLines(userId, source, lines);
}
/**
* {@link #create} for lines Core built itself, e.g. from a PlayStation account.
*/
public CreateResult createLines(Long userId, GameImport.Source source, List<GameImport.Line> lines) {
if (!igdbApiService.isEnabled()) {
return new CreateResult.SearchUnavailable();
}
long id; long id;
try { try {
id = transaction.execute(tx -> importRepository.create(userId, source, lines)); id = transaction.execute(tx -> importRepository.create(userId, source, lines));
@@ -2,6 +2,7 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.GameNames; import core_service.modules.library.domain.GameNames;
import core_service.modules.library.domain.GameImport; import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.IgdbGame; import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.repo.GameImportRepository; import core_service.modules.library.repo.GameImportRepository;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
@@ -141,6 +142,8 @@ public class GameImportWorker {
List<GameImport.Choice> choices = importRepository.findUnsavedChoices(importId); List<GameImport.Choice> choices = importRepository.findUnsavedChoices(importId);
if (!choices.isEmpty()) { if (!choices.isEmpty()) {
long userId = importRepository.findUserId(importId); long userId = importRepository.findUserId(importId);
GameSource source = importRepository.findSource(importId) == GameImport.Source.PSN ? GameSource.PSN
: GameSource.MANUAL;
Map<Integer, GameImport.Row> rows = importRepository.findRows(importId).stream() Map<Integer, GameImport.Row> rows = importRepository.findRows(importId).stream()
.collect(Collectors.toMap(row -> row.line().lineNo(), row -> row)); .collect(Collectors.toMap(row -> row.line().lineNo(), row -> row));
for (GameImport.Choice choice : choices) { for (GameImport.Choice choice : choices) {
@@ -154,7 +157,7 @@ public class GameImportWorker {
return; return;
} }
boolean added = manualGameService.addGame(userId, game, choice.platform(), choice.status(), boolean added = manualGameService.addGame(userId, game, choice.platform(), choice.status(),
choice.playtimeMinutes(), importId) instanceof ManualGameService.AddResult.Added; choice.playtimeMinutes(), source, importId) instanceof ManualGameService.AddResult.Added;
importRepository.saveResult(importId, choice.lineNo(), importRepository.saveResult(importId, choice.lineNo(),
added ? GameImport.SaveResult.ADDED : GameImport.SaveResult.SKIPPED); added ? GameImport.SaveResult.ADDED : GameImport.SaveResult.SKIPPED);
} }
@@ -276,7 +276,7 @@ public class LibrarySyncWorker {
Long appid = g.appid().longValue(); Long appid = g.appid().longValue();
int playtime = g.playtimeForever() != null ? g.playtimeForever() : 0; int playtime = g.playtimeForever() != null ? g.playtimeForever() : 0;
// Hours typed by hand are no Steam playtime, so the first Steam value of a game added by hand is a baseline. // Hours typed by hand are no Steam playtime, so the first Steam value of a game added by hand is a baseline.
boolean takesOverManualGame = existing != null && existing.getSource() == GameSource.MANUAL; boolean takesOverManualGame = existing != null && existing.getSource() != GameSource.STEAM;
int previousPlaytime = existing != null ? existing.getPlaytimeMinutes() : 0; int previousPlaytime = existing != null ? existing.getPlaytimeMinutes() : 0;
int playtime2Weeks = g.playtime2Weeks() != null ? g.playtime2Weeks() : 0; int playtime2Weeks = g.playtime2Weeks() != null ? g.playtime2Weeks() : 0;
if (existing == null || existing.getRemovedAt() != null) { if (existing == null || existing.getRemovedAt() != null) {
@@ -173,7 +173,7 @@ public class ManualGameService {
if (found.isEmpty()) { if (found.isEmpty()) {
return new AddResult.UnknownGame(); return new AddResult.UnknownGame();
} }
AddResult result = addGame(userId, found.get(), platform, status, playtimeMinutes, null); AddResult result = addGame(userId, found.get(), platform, status, playtimeMinutes, GameSource.MANUAL, null);
if (result instanceof AddResult.Added added) { if (result instanceof AddResult.Added added) {
timeToBeatService.refreshInBackground(List.of(added.appid())); timeToBeatService.refreshInBackground(List.of(added.appid()));
moodService.refreshInBackground(List.of(added.appid())); moodService.refreshInBackground(List.of(added.appid()));
@@ -188,19 +188,20 @@ public class ManualGameService {
* {@link #add} of a game already loaded from IGDB, without the background refresh; an import adds many games * {@link #add} of a game already loaded from IGDB, without the background refresh; an import adds many games
* and loads their time to beat and moods once. * and loads their time to beat and moods once.
* *
* @param source MANUAL, or PSN for a PlayStation import
* @param importId the import that adds the game, so its undo can remove it; null for a single add. A removed * @param importId the import that adds the game, so its undo can remove it; null for a single add. A removed
* row that comes back keeps no import id. * row that comes back keeps no import id.
*/ */
public AddResult addGame(Long userId, IgdbGame game, String platform, GameStatus status, public AddResult addGame(Long userId, IgdbGame game, String platform, GameStatus status,
int playtimeMinutes, Long importId) { int playtimeMinutes, GameSource source, Long importId) {
AddResult result; AddResult result;
try { try {
result = addInTransaction(userId, game, platform.strip(), status, playtimeMinutes, importId); result = addInTransaction(userId, game, platform.strip(), status, playtimeMinutes, source, importId);
} catch (DataIntegrityViolationException e) { } catch (DataIntegrityViolationException e) {
// A parallel add, sync or merge changed the rows after the check. The second try sees it. // A parallel add, sync or merge changed the rows after the check. The second try sees it.
log.info("Add of igdbId={} for user={} raced another write, trying again", game.igdbId(), userId); log.info("Add of igdbId={} for user={} raced another write, trying again", game.igdbId(), userId);
try { try {
result = addInTransaction(userId, game, platform.strip(), status, playtimeMinutes, importId); result = addInTransaction(userId, game, platform.strip(), status, playtimeMinutes, source, importId);
} catch (DataIntegrityViolationException again) { } catch (DataIntegrityViolationException again) {
return new AddResult.AlreadyInLibrary(); return new AddResult.AlreadyInLibrary();
} }
@@ -233,7 +234,7 @@ public class ManualGameService {
} }
private AddResult addInTransaction(Long userId, IgdbGame game, String platform, GameStatus status, private AddResult addInTransaction(Long userId, IgdbGame game, String platform, GameStatus status,
int playtimeMinutes, Long importId) { int playtimeMinutes, GameSource source, Long importId) {
return transaction.execute(tx -> { return transaction.execute(tx -> {
Instant now = Instant.now(); Instant now = Instant.now();
Map<Long, Long> targets = mergeRepository.findTargets(ownKeys(game)); Map<Long, Long> targets = mergeRepository.findTargets(ownKeys(game));
@@ -245,7 +246,7 @@ public class ManualGameService {
saveCatalogEntry(appid, game, now); saveCatalogEntry(appid, game, now);
if (existing.isPresent()) { if (existing.isPresent()) {
UserGame revived = existing.get(); UserGame revived = existing.get();
revived.setSource(GameSource.MANUAL); revived.setSource(source);
revived.setPlatform(platform); revived.setPlatform(platform);
revived.setStatus(status); revived.setStatus(status);
revived.setStatusSetByUser(true); revived.setStatusSetByUser(true);
@@ -258,7 +259,7 @@ public class ManualGameService {
// The row holds rating, notes and history from before, so an undo of the import must not delete it. // The row holds rating, notes and history from before, so an undo of the import must not delete it.
revived.setImportId(null); revived.setImportId(null);
} else { } else {
UserGame added = UserGame.manual(userId, appid, platform, status, playtimeMinutes, now); UserGame added = UserGame.manual(userId, appid, source, platform, status, playtimeMinutes, now);
added.setImportId(importId); added.setImportId(importId);
userGameRepository.save(added); userGameRepository.save(added);
} }
@@ -287,7 +288,7 @@ public class ManualGameService {
if (game.isEmpty()) { if (game.isEmpty()) {
return ChangeResult.NOT_FOUND; return ChangeResult.NOT_FOUND;
} }
if (game.get().getSource() != GameSource.MANUAL) { if (game.get().getSource() == GameSource.STEAM) {
return ChangeResult.STEAM_GAME; return ChangeResult.STEAM_GAME;
} }
change.accept(game.get()); change.accept(game.get());
@@ -0,0 +1,206 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.PsnAccount;
import core_service.modules.library.repo.PsnAccountRepository;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.modules.psn_adapter.service.PsnAuthService;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import core_service.shared.security.SecretBox;
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestClientResponseException;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* The linked PlayStation account of a user: link with an NPSSO, one import of the played titles, unlink. The
* refresh token is stored sealed; the NPSSO, the codes and every token stay out of the logs, the database and the
* answers. PlayStation is called outside any transaction.
*/
@Service
@RequiresRole(Role.USER)
@Slf4j
public class PsnAccountService {
/**
* @param enabled false when Core has no key: the link is off
*/
public record Status(boolean enabled, boolean linked, Instant linkedAt, Instant expiresAt) {}
public sealed interface LinkResult {
record Linked(Status status) implements LinkResult {}
/** PlayStation did not accept the NPSSO. */
record Rejected() implements LinkResult {}
record Off() implements LinkResult {}
record Unavailable() implements LinkResult {}
/** The user has another PlayStation call running. */
record Busy() implements LinkResult {}
}
public sealed interface ImportResult {
record Started(long id) implements ImportResult {}
record NotLinked() implements ImportResult {}
/** The link ended: the user pastes a new NPSSO. */
record Relink() implements ImportResult {}
record NoTitles() implements ImportResult {}
record Off() implements ImportResult {}
record Unavailable() implements ImportResult {}
record AlreadyMatching() implements ImportResult {}
record SearchUnavailable() implements ImportResult {}
/** The user has another PlayStation call running. */
record Busy() implements ImportResult {}
}
private final PsnAccountRepository repository;
private final PsnAuthService authService;
private final PsnApiService apiService;
private final GameImportService importService;
private final SecretBox box;
// One PlayStation call per user at a time: parallel calls of one user must not hold the shared rate limiter.
private final Set<Long> inFlight = ConcurrentHashMap.newKeySet();
public PsnAccountService(PsnAccountRepository repository, PsnAuthService authService, PsnApiService apiService,
GameImportService importService, @Value("${app.psn.token-key:}") String tokenKey) {
this.repository = repository;
this.authService = authService;
this.apiService = apiService;
this.importService = importService;
// A wrong key stops Core at start; no key only switches the feature off.
this.box = tokenKey == null || tokenKey.isBlank() ? null : SecretBox.fromBase64Key(tokenKey);
}
public boolean isEnabled() {
return box != null;
}
public Status get(Long userId) {
Optional<PsnAccount> account = box != null ? repository.find(userId) : Optional.empty();
return new Status(isEnabled(), account.isPresent(), account.map(PsnAccount::linkedAt).orElse(null),
account.map(PsnAccount::refreshExpiresAt).orElse(null));
}
public LinkResult link(Long userId, String npsso) {
if (box == null) {
return new LinkResult.Off();
}
if (!inFlight.add(userId)) {
return new LinkResult.Busy();
}
try {
return doLink(userId, npsso);
} finally {
inFlight.remove(userId);
}
}
private LinkResult doLink(Long userId, String npsso) {
Optional<PsnAuthService.Tokens> tokens;
try {
tokens = authService.exchangeNpsso(npsso);
} catch (RestClientException | RequestNotPermitted e) {
logFailure("PSN link failed", e);
return new LinkResult.Unavailable();
}
if (tokens.isEmpty()) {
log.info("PSN link rejected for user={}", userId);
return new LinkResult.Rejected();
}
repository.upsert(userId, box.seal(tokens.get().refreshToken()), tokens.get().refreshExpiresAt());
log.info("PSN account linked for user={}", userId);
return new LinkResult.Linked(get(userId));
}
public void unlink(Long userId) {
if (repository.delete(userId) > 0) {
log.info("PSN account unlinked for user={}", userId);
}
}
/**
* Refreshes the access token, reads the played titles and starts an import with them. A refresh that returns
* the same refresh token keeps the stored expiry, a new one sets its own.
*/
public ImportResult startImport(Long userId) {
if (box == null) {
return new ImportResult.Off();
}
if (!inFlight.add(userId)) {
return new ImportResult.Busy();
}
try {
return doImport(userId);
} finally {
inFlight.remove(userId);
}
}
private ImportResult doImport(Long userId) {
Optional<PsnAccount> account = repository.find(userId);
if (account.isEmpty()) {
return new ImportResult.NotLinked();
}
if (!account.get().refreshExpiresAt().isAfter(Instant.now())) {
return new ImportResult.Relink();
}
String stored;
try {
stored = box.open(account.get().refreshTokenEnc());
} catch (IllegalStateException e) {
// Sealed with another key: the user links again.
log.warn("Stored PSN token of user={} cannot be opened", userId);
repository.expireNow(userId);
return new ImportResult.Relink();
}
List<PsnApiService.Title> titles;
try {
Optional<PsnAuthService.Tokens> tokens = authService.refresh(stored);
if (tokens.isEmpty()) {
repository.expireNow(userId);
return new ImportResult.Relink();
}
if (!tokens.get().refreshToken().equals(stored)) {
repository.updateToken(userId, box.seal(tokens.get().refreshToken()), tokens.get().refreshExpiresAt());
}
titles = apiService.getPlayedTitles(tokens.get().accessToken());
} catch (RestClientException | RequestNotPermitted e) {
logFailure("PSN import failed", e);
return new ImportResult.Unavailable();
}
List<GameImport.Line> lines = PsnTitles.toLines(titles);
if (lines.isEmpty()) {
return new ImportResult.NoTitles();
}
return switch (importService.createLines(userId, GameImport.Source.PSN, lines)) {
case GameImportService.CreateResult.Created created -> new ImportResult.Started(created.id());
case GameImportService.CreateResult.AlreadyMatching ignored -> new ImportResult.AlreadyMatching();
case GameImportService.CreateResult.SearchUnavailable ignored -> new ImportResult.SearchUnavailable();
};
}
// The class and the status only: the message of a RestClientException can hold the answer's body.
private static void logFailure(String what, RuntimeException e) {
log.warn("{}: {}{}", what, e.getClass().getSimpleName(),
e instanceof RestClientResponseException response ? " status " + response.getStatusCode().value() : "");
}
}
@@ -0,0 +1,65 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.ImportRequest;
import core_service.modules.library.api.dto.ManualGameUpdate;
import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.psn_adapter.service.PsnApiService;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Turns PlayStation titles into import lines: a clean name for the IGDB search, PS4 and PS5 versions of one game
* as one line.
*/
final class PsnTitles {
private static final int MAX_TITLE_LENGTH = 100;
private static final Pattern SUFFIX = Pattern.compile(
"(?i)(\\s*[-:]?\\s*(\\(ps[45]\\)|ps4\\s*&\\s*ps5|ps5\\s*&\\s*ps4|ps[45] (edition|version)))+\\s*$");
private static final Pattern DANGLING = Pattern.compile("\\s*[-:]\\s*$");
private PsnTitles() {
}
static String cleanName(String psnName) {
String name = psnName.replace("™", "").replace("®", "");
name = SUFFIX.matcher(name).replaceFirst("");
name = DANGLING.matcher(name).replaceFirst("");
name = name.strip().replaceAll("\\s+", " ");
if (name.isEmpty()) {
name = psnName.strip();
}
return name.length() > MAX_TITLE_LENGTH ? name.substring(0, MAX_TITLE_LENGTH).strip() : name;
}
static String platform(String category) {
return "ps5_native_game".equalsIgnoreCase(category) ? "PS5" : "PS4";
}
static List<GameImport.Line> toLines(List<PsnApiService.Title> titles) {
Map<String, List<PsnApiService.Title>> groups = new LinkedHashMap<>();
for (PsnApiService.Title title : titles) {
String key = title.conceptId() != null ? "c" + title.conceptId() : "t" + title.titleId();
groups.computeIfAbsent(key, k -> new ArrayList<>()).add(title);
}
List<GameImport.Line> lines = new ArrayList<>();
for (List<PsnApiService.Title> group : groups.values()) {
if (lines.size() >= ImportRequest.MAX_ROWS) {
break;
}
PsnApiService.Title mostPlayed = group.stream()
.max(Comparator.comparing(PsnApiService.Title::playDuration)).orElseThrow();
long minutes = Math.min(ManualGameUpdate.MAX_MINUTES,
group.stream().mapToLong(title -> title.playDuration().toMinutes()).sum());
lines.add(new GameImport.Line(lines.size() + 1, cleanName(mostPlayed.name()), platform(mostPlayed.category()),
(int) minutes, GameStatus.initialFor((int) minutes), null));
}
return lines;
}
}
@@ -0,0 +1,19 @@
package core_service.modules.psn_adapter.api.dto.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
/**
* One page of the played titles (gamelist v2). playDuration is ISO 8601, e.g. PT65H9M15S; category is
* ps5_native_game or ps4_game.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record PsnGameListResponse(List<Title> titles, Integer nextOffset) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record Title(String titleId, String name, String category, String playDuration, Concept concept) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Concept(Long id) {}
}
@@ -0,0 +1,21 @@
package core_service.modules.psn_adapter.api.dto.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* The answer of PlayStation's /token. The tokens never show in {@link #toString()}.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record PsnTokenResponse(
@JsonProperty("access_token") String accessToken,
@JsonProperty("expires_in") Integer expiresIn,
@JsonProperty("refresh_token") String refreshToken,
@JsonProperty("refresh_token_expires_in") Long refreshTokenExpiresIn
) {
@Override
public String toString() {
return "PsnTokenResponse[expiresIn=" + expiresIn + ", refreshTokenExpiresIn=" + refreshTokenExpiresIn + "]";
}
}
@@ -0,0 +1,45 @@
package core_service.modules.psn_adapter.config;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import java.net.http.HttpClient;
import java.time.Duration;
/**
* The PlayStation clients never follow a redirect: the authorize answer is a 302 to the app's own scheme, and the
* code in its Location header must not be fetched.
*/
@Configuration
public class PsnClientConfig {
@Bean
public HttpClient psnHttpClient() {
return HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NEVER)
.connectTimeout(Duration.ofSeconds(5))
.build();
}
@Bean
public RestClient psnAuthRestClient(@Qualifier("psnHttpClient") HttpClient psnHttpClient,
@Value("${app.psn.auth-base-url}") String baseUrl) {
return client(psnHttpClient, baseUrl);
}
@Bean
public RestClient psnApiRestClient(@Qualifier("psnHttpClient") HttpClient psnHttpClient,
@Value("${app.psn.api-base-url}") String baseUrl) {
return client(psnHttpClient, baseUrl);
}
private static RestClient client(HttpClient httpClient, String baseUrl) {
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient);
factory.setReadTimeout(Duration.ofSeconds(15));
return RestClient.builder().baseUrl(baseUrl).requestFactory(factory).build();
}
}
@@ -0,0 +1,86 @@
package core_service.modules.psn_adapter.service;
import core_service.modules.psn_adapter.api.dto.response.PsnGameListResponse;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.time.Duration;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
/**
* The titles a PlayStation account has played (gamelist v2, unofficial), PS4 and PS5.
*/
@Service
@RequiresRole(Role.USER)
public class PsnApiService {
/**
* @param conceptId PlayStation's id of the game across its PS4 and PS5 versions; may be null
*/
public record Title(String titleId, String name, String category, Duration playDuration, Long conceptId) {}
static final int PAGE_SIZE = 200;
static final int MAX_TITLES = 1000;
private final RestClient apiClient;
private final RateLimiter rateLimiter;
public PsnApiService(@Qualifier("psnApiRestClient") RestClient apiClient, RateLimiterRegistry rateLimiterRegistry) {
this.apiClient = apiClient;
this.rateLimiter = rateLimiterRegistry.rateLimiter("psnApi");
}
/**
* @throws org.springframework.web.client.RestClientException when PlayStation fails
*/
public List<Title> getPlayedTitles(String accessToken) {
List<Title> titles = new ArrayList<>();
int offset = 0;
while (titles.size() < MAX_TITLES) {
int from = offset;
RateLimiter.waitForPermission(rateLimiter);
PsnGameListResponse page = apiClient.get()
.uri(uri -> uri.path("/gamelist/v2/users/me/titles")
.queryParam("categories", "ps4_game,ps5_native_game")
.queryParam("limit", PAGE_SIZE)
.queryParam("offset", from)
.build())
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.retrieve()
.body(PsnGameListResponse.class);
if (page == null || page.titles() == null) {
break;
}
for (PsnGameListResponse.Title title : page.titles()) {
if (title.titleId() != null && title.name() != null && titles.size() < MAX_TITLES) {
titles.add(new Title(title.titleId(), title.name(), title.category(), duration(title.playDuration()),
title.concept() != null ? title.concept().id() : null));
}
}
if (page.nextOffset() == null || page.nextOffset() <= offset) {
break;
}
offset = page.nextOffset();
}
return titles;
}
private static Duration duration(String iso) {
if (iso == null) {
return Duration.ZERO;
}
try {
return Duration.parse(iso);
} catch (DateTimeParseException e) {
return Duration.ZERO;
}
}
}
@@ -0,0 +1,146 @@
package core_service.modules.psn_adapter.service;
import core_service.modules.psn_adapter.api.dto.response.PsnTokenResponse;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import org.springframework.web.util.UriComponentsBuilder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.Optional;
/**
* PlayStation sign-in (unofficial): the user's NPSSO becomes a one-time code and then tokens. Nothing of the
* exchange is logged: not the NPSSO, the code, the Location header, a body or a token.
*/
@Service
@RequiresRole(Role.USER)
@Slf4j
public class PsnAuthService {
private static final String SCOPE = "psn:mobile.v2.core psn:clientapp";
/**
* The tokens never show in {@link #toString()}.
*/
public record Tokens(String accessToken, Instant accessExpiresAt, String refreshToken, Instant refreshExpiresAt) {
@Override
public String toString() {
return "Tokens[accessExpiresAt=" + accessExpiresAt + ", refreshExpiresAt=" + refreshExpiresAt + "]";
}
}
private final RestClient authClient;
private final RateLimiter rateLimiter;
private final String clientId;
private final String basicAuth;
private final String redirectUri;
public PsnAuthService(@Qualifier("psnAuthRestClient") RestClient authClient,
RateLimiterRegistry rateLimiterRegistry,
@Value("${app.psn.client-id}") String clientId,
@Value("${app.psn.client-secret}") String clientSecret,
@Value("${app.psn.redirect-uri}") String redirectUri) {
this.authClient = authClient;
this.rateLimiter = rateLimiterRegistry.rateLimiter("psnApi");
this.clientId = clientId;
this.basicAuth = "Basic " + Base64.getEncoder()
.encodeToString((clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8));
this.redirectUri = redirectUri;
}
/**
* @return empty when PlayStation does not accept the NPSSO (invalid or expired); a failure of PlayStation itself
* throws a RestClientException
*/
public Optional<Tokens> exchangeNpsso(String npsso) {
RateLimiter.waitForPermission(rateLimiter);
String location;
try {
location = authClient.get()
.uri(uri -> uri.path("/authorize")
.queryParam("access_type", "offline")
.queryParam("client_id", clientId)
.queryParam("redirect_uri", "{redirect}")
.queryParam("response_type", "code")
.queryParam("scope", "{scope}")
.build(redirectUri, SCOPE))
.header(HttpHeaders.COOKIE, "npsso=" + npsso)
.retrieve()
.toBodilessEntity()
.getHeaders().getFirst(HttpHeaders.LOCATION);
} catch (RestClientException e) {
throw e;
} catch (RuntimeException e) {
// The JDK puts a refused header value, i.e. the NPSSO, into its message: a fixed text and no cause instead.
throw new RestClientException("PSN authorize request failed");
}
String code = location == null ? null
: UriComponentsBuilder.fromUriString(location).build().getQueryParams().getFirst("code");
if (code == null || code.isBlank()) {
return Optional.empty();
}
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("code", code);
form.add("redirect_uri", redirectUri);
form.add("grant_type", "authorization_code");
form.add("token_format", "jwt");
return token(form);
}
/**
* @return empty when PlayStation refuses the refresh token: the user must link again
*/
public Optional<Tokens> refresh(String refreshToken) {
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("refresh_token", refreshToken);
form.add("grant_type", "refresh_token");
form.add("token_format", "jwt");
form.add("scope", SCOPE);
return token(form);
}
private Optional<Tokens> token(MultiValueMap<String, String> form) {
RateLimiter.waitForPermission(rateLimiter);
PsnTokenResponse answer;
try {
answer = authClient.post()
.uri("/token")
.header(HttpHeaders.AUTHORIZATION, basicAuth)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(form)
.retrieve()
.body(PsnTokenResponse.class);
} catch (HttpClientErrorException e) {
int status = e.getStatusCode().value();
// Only these mean "refused". A rate limit (429) or another 4xx is a failure of PlayStation's side.
if (status != 400 && status != 401 && status != 403) {
throw e;
}
log.info("PSN token request refused with status {}", status);
return Optional.empty();
}
if (answer == null || answer.accessToken() == null || answer.refreshToken() == null
|| answer.expiresIn() == null || answer.refreshTokenExpiresIn() == null) {
return Optional.empty();
}
Instant now = Instant.now();
return Optional.of(new Tokens(answer.accessToken(), now.plusSeconds(answer.expiresIn()),
answer.refreshToken(), now.plusSeconds(answer.refreshTokenExpiresIn())));
}
}
@@ -0,0 +1,84 @@
package core_service.shared.security;
import javax.crypto.AEADBadTagException;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
/**
* Seals a secret string for the database: AES-256-GCM with a fresh nonce per value. The sealed bytes are
* {@code 0x01 || nonce || ciphertext}; the first byte is the format version, for a later key rotation.
*/
public final class SecretBox {
private static final int KEY_BYTES = 32;
private static final int NONCE_BYTES = 12;
private static final int TAG_BITS = 128;
private static final byte VERSION = 1;
private static final SecureRandom RANDOM = new SecureRandom();
private static final String BAD_KEY = "APP_PSN_TOKEN_KEY must be 32 bytes, base64";
private final SecretKeySpec key;
private SecretBox(byte[] keyBytes) {
this.key = new SecretKeySpec(keyBytes, "AES");
}
public static SecretBox fromBase64Key(String base64Key) {
byte[] keyBytes;
try {
keyBytes = Base64.getDecoder().decode(base64Key.strip());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(BAD_KEY);
}
if (keyBytes.length != KEY_BYTES) {
throw new IllegalArgumentException(BAD_KEY);
}
return new SecretBox(keyBytes);
}
public byte[] seal(String plain) {
byte[] nonce = new byte[NONCE_BYTES];
RANDOM.nextBytes(nonce);
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, nonce));
byte[] encrypted = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8));
byte[] sealed = new byte[1 + NONCE_BYTES + encrypted.length];
sealed[0] = VERSION;
System.arraycopy(nonce, 0, sealed, 1, NONCE_BYTES);
System.arraycopy(encrypted, 0, sealed, 1 + NONCE_BYTES, encrypted.length);
return sealed;
} catch (GeneralSecurityException e) {
throw new IllegalStateException("Sealing failed", e);
}
}
/**
* @throws IllegalStateException for a wrong key, changed bytes or an unknown version
*/
public String open(byte[] sealed) {
if (sealed == null || sealed.length < 1 + NONCE_BYTES || sealed[0] != VERSION) {
throw new IllegalStateException("Unknown sealed format");
}
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, Arrays.copyOfRange(sealed, 1, 1 + NONCE_BYTES)));
return new String(cipher.doFinal(sealed, 1 + NONCE_BYTES, sealed.length - 1 - NONCE_BYTES), StandardCharsets.UTF_8);
} catch (AEADBadTagException e) {
throw new IllegalStateException("Opening failed: wrong key or changed bytes");
} catch (GeneralSecurityException e) {
throw new IllegalStateException("Opening failed");
}
}
@Override
public String toString() {
return "SecretBox";
}
}
+5
View File
@@ -23,6 +23,11 @@ app:
client-secret: local client-secret: local
base-url: "http://localhost:8089/igdb" base-url: "http://localhost:8089/igdb"
token-base-url: "http://localhost:8089/twitch" token-base-url: "http://localhost:8089/twitch"
psn:
# Local only, not a secret.
token-key: "bG9jYWwtb25seS1wc24ta2V5LTMyLWJ5dGVzISEhISE="
auth-base-url: "http://localhost:8089/psn/auth"
api-base-url: "http://localhost:8089/psn"
speedrun: speedrun:
base-url: "http://localhost:8089/speedrun" base-url: "http://localhost:8089/speedrun"
library: library:
+16 -1
View File
@@ -51,6 +51,15 @@ app:
client-secret: ${IGDB_CLIENT_SECRET:} client-secret: ${IGDB_CLIENT_SECRET:}
base-url: https://api.igdb.com/v4 base-url: https://api.igdb.com/v4
token-base-url: https://id.twitch.tv token-base-url: https://id.twitch.tv
psn:
# Base64 of 32 random bytes (openssl rand -base64 32). Empty = the PlayStation link is off.
token-key: ${APP_PSN_TOKEN_KEY:}
auth-base-url: https://ca.account.sony.com/api/authz/v3/oauth
api-base-url: https://m.np.playstation.com/api
# The public client of the PlayStation app, as the psn-api project ships it. Not a secret of ours.
client-id: 09515159-7237-4370-9b40-3806e67c0891
client-secret: ucPjka5tntB2KqsP
redirect-uri: com.scee.psxandroid.scecompcall://redirect
speedrun: speedrun:
# No key needed. false stops every call to speedrun.com. # No key needed. false stops every call to speedrun.com.
enabled: true enabled: true
@@ -126,4 +135,10 @@ resilience4j:
# IGDB allows 4 requests/s. Taken per HTTP request. # IGDB allows 4 requests/s. Taken per HTTP request.
limit-for-period: 4 limit-for-period: 4
limit-refresh-period: 1s limit-refresh-period: 1s
timeout-duration: 10s timeout-duration: 10s
psnApi:
# Unofficial API; third-party tools limit themselves to about 300 requests / 15 min. One request every 4 s
# for the whole server, taken per HTTP request.
limit-for-period: 1
limit-refresh-period: 4s
timeout-duration: 30s
@@ -0,0 +1,8 @@
-- Linked PlayStation account (#25). Only the refresh token, AES-GCM sealed by SecretBox with APP_PSN_TOKEN_KEY;
-- the NPSSO is never stored. refresh_expires_at: when the user must paste a new NPSSO.
CREATE TABLE user_psn_account (
user_id BIGINT PRIMARY KEY,
refresh_token_enc BYTEA NOT NULL,
refresh_expires_at TIMESTAMPTZ NOT NULL,
linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -27,7 +27,7 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
* Notes on Core-specific structure (differs from auth-service): * Notes on Core-specific structure (differs from auth-service):
* - JPA entities live in `..domain..` (not `..persistence..`). * - JPA entities live in `..domain..` (not `..persistence..`).
* - Repository interfaces live in `..repo..`. * - Repository interfaces live in `..repo..`.
* - `steam_adapter`, `igdb_adapter` and `speedrun_adapter` are outbound ports consumed by feature modules * - `steam_adapter`, `igdb_adapter`, `speedrun_adapter` and `psn_adapter` are outbound ports consumed by feature modules
* (e.g. `library`), so strict slice independence is relaxed for it. * (e.g. `library`), so strict slice independence is relaxed for it.
*/ */
@AnalyzeClasses(packages = "core_service", importOptions = ImportOption.DoNotIncludeTests.class) @AnalyzeClasses(packages = "core_service", importOptions = ImportOption.DoNotIncludeTests.class)
@@ -44,8 +44,8 @@ class ArchitectureTest extends AbstractArchitectureTest {
.ignoreDependency( .ignoreDependency(
JavaClass.Predicates.resideInAPackage("core_service.modules.library.."), JavaClass.Predicates.resideInAPackage("core_service.modules.library.."),
JavaClass.Predicates.resideInAnyPackage("core_service.modules.steam_adapter..", "core_service.modules.igdb_adapter..", JavaClass.Predicates.resideInAnyPackage("core_service.modules.steam_adapter..", "core_service.modules.igdb_adapter..",
"core_service.modules.speedrun_adapter..")) "core_service.modules.speedrun_adapter..", "core_service.modules.psn_adapter.."))
.because("Feature modules should be isolated; steam_adapter, igdb_adapter and speedrun_adapter are explicit shared ports."); .because("Feature modules should be isolated; steam_adapter, igdb_adapter, speedrun_adapter and psn_adapter are explicit shared ports.");
@ArchTest @ArchTest
static final ArchRule no_cyclic_dependencies = SlicesRuleDefinition.slices() static final ArchRule no_cyclic_dependencies = SlicesRuleDefinition.slices()
@@ -74,7 +74,7 @@ class ArchitectureTest extends AbstractArchitectureTest {
.that().resideInAPackage("core_service.modules..domain..") .that().resideInAPackage("core_service.modules..domain..")
.should().dependOnClassesThat().resideInAnyPackage("..api..", "..service..", "..repo..", .should().dependOnClassesThat().resideInAnyPackage("..api..", "..service..", "..repo..",
"core_service.modules.steam_adapter..", "core_service.modules.igdb_adapter..", "core_service.modules.steam_adapter..", "core_service.modules.igdb_adapter..",
"core_service.modules.speedrun_adapter..") "core_service.modules.speedrun_adapter..", "core_service.modules.psn_adapter..")
.because("Entities and stored shapes must not change when a controller, service or external API changes."); .because("Entities and stored shapes must not change when a controller, service or external API changes.");
// The listed services build their module's response DTOs; the list only gets shorter. // The listed services build their module's response DTOs; the list only gets shorter.
@@ -93,7 +93,8 @@ class ArchitectureTest extends AbstractArchitectureTest {
.and(DescribedPredicate.not(JavaClass.Predicates.resideInAnyPackage( .and(DescribedPredicate.not(JavaClass.Predicates.resideInAnyPackage(
"core_service.modules.steam_adapter.api.dto.response..", "core_service.modules.steam_adapter.api.dto.response..",
"core_service.modules.igdb_adapter.api.dto.response..", "core_service.modules.igdb_adapter.api.dto.response..",
"core_service.modules.speedrun_adapter.api.dto.response..")))) "core_service.modules.speedrun_adapter.api.dto.response..",
"core_service.modules.psn_adapter.api.dto.response.."))))
.because("Services must not depend on the HTTP layer; the adapters' response DTOs are the external APIs' shapes."); .because("Services must not depend on the HTTP layer; the adapters' response DTOs are the external APIs' shapes.");
@ArchTest @ArchTest
@@ -1,6 +1,8 @@
package core_service.configuration; package core_service.configuration;
import core_service.modules.igdb_adapter.api.dto.response.IgdbResponses; import core_service.modules.igdb_adapter.api.dto.response.IgdbResponses;
import core_service.modules.psn_adapter.api.dto.response.PsnGameListResponse;
import core_service.modules.psn_adapter.api.dto.response.PsnTokenResponse;
import core_service.modules.speedrun_adapter.api.dto.response.SpeedrunResponses; import core_service.modules.speedrun_adapter.api.dto.response.SpeedrunResponses;
import core_service.modules.steam_adapter.api.dto.response.SteamGlobalAchievementPercentagesResponse; import core_service.modules.steam_adapter.api.dto.response.SteamGlobalAchievementPercentagesResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse; import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
@@ -20,6 +22,7 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.stream.Stream; import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -47,9 +50,14 @@ class LocalMocksParseTest extends AbstractUnitTest {
Map.entry("igdb-tags", IgdbResponses.GameTags[].class), Map.entry("igdb-tags", IgdbResponses.GameTags[].class),
Map.entry("igdb-editions", IgdbResponses.Edition[].class), Map.entry("igdb-editions", IgdbResponses.Edition[].class),
Map.entry("igdb-game-", IgdbResponses.Game[].class), Map.entry("igdb-game-", IgdbResponses.Game[].class),
Map.entry("psn-token", PsnTokenResponse.class),
Map.entry("psn-gamelist", PsnGameListResponse.class),
Map.entry("speedrun-games-", SpeedrunResponses.Games.class), Map.entry("speedrun-games-", SpeedrunResponses.Games.class),
Map.entry("speedrun-runs-", SpeedrunResponses.Runs.class)); Map.entry("speedrun-runs-", SpeedrunResponses.Runs.class));
// null is the answer's own meaning: PlayStation's last page has no nextOffset.
private static final Set<String> OPTIONAL_FIELDS = Set.of("nextOffset");
private final JsonMapper jsonMapper = JsonMapper.builder().build(); private final JsonMapper jsonMapper = JsonMapper.builder().build();
@Test @Test
@@ -62,7 +70,9 @@ class LocalMocksParseTest extends AbstractUnitTest {
for (Path mapping : mappings) { for (Path mapping : mappings) {
JsonNode response = jsonMapper.readTree(mapping.toFile()).path("response"); JsonNode response = jsonMapper.readTree(mapping.toFile()).path("response");
assertThat(response.has("bodyFileName") || response.has("jsonBody")).as(mapping.toString()).isTrue(); // The PlayStation authorize answer is a redirect with no body.
assertThat(response.has("bodyFileName") || response.has("jsonBody") || response.path("headers").has("Location"))
.as(mapping.toString()).isTrue();
if (response.has("bodyFileName")) { if (response.has("bodyFileName")) {
assertThat(FILES.resolve(response.get("bodyFileName").asString())).as(mapping.toString()).exists(); assertThat(FILES.resolve(response.get("bodyFileName").asString())).as(mapping.toString()).exists();
} }
@@ -80,6 +90,9 @@ class LocalMocksParseTest extends AbstractUnitTest {
return; return;
} }
for (RecordComponent component : parsed.getClass().getRecordComponents()) { for (RecordComponent component : parsed.getClass().getRecordComponents()) {
if (OPTIONAL_FIELDS.contains(component.getName())) {
continue;
}
assertThat(component.getAccessor().invoke(parsed)).as(name + " " + component.getName()).isNotNull(); assertThat(component.getAccessor().invoke(parsed)).as(name + " " + component.getName()).isNotNull();
} }
} }
@@ -18,7 +18,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement", private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
"reward_equipped", "listed_user", "public_user", "follow", "follow_digest", "inbox_note"); "reward_equipped", "user_psn_account", "listed_user", "public_user", "follow", "follow_digest", "inbox_note");
@Autowired @Autowired
private AccountCleanupRepository repository; private AccountCleanupRepository repository;
@@ -57,6 +57,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
jdbc.update("insert into inbox_note (user_id, kind, subject_user_id) values (?, 'DIGEST', ?)", userId, userId + 100); jdbc.update("insert into inbox_note (user_id, kind, subject_user_id) values (?, 'DIGEST', ?)", userId, userId + 100);
jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'SAVED', 0)", userId); jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'SAVED', 0)", userId);
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 20, now())", userId); jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 20, now())", userId);
jdbc.update("insert into user_psn_account (user_id, refresh_token_enc, refresh_expires_at) values (?, '\\x01'::bytea, now())", userId);
jdbc.update("insert into reward_equipped (user_id, slot, item_code) values (?, 'BADGE', 'BADGE_PILE_SLAYER')", userId); jdbc.update("insert into reward_equipped (user_id, slot, item_code) values (?, 'BADGE', 'BADGE_PILE_SLAYER')", userId);
} }
} }
@@ -93,6 +94,14 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
assertThat(jdbc.queryForObject("select count(*) from inbox_note where kind = 'VISIBILITY'", Integer.class)).isZero(); assertThat(jdbc.queryForObject("select count(*) from inbox_note where kind = 'VISIBILITY'", Integer.class)).isZero();
} }
@Test
void addDeleted_DropsTheStoredPsnTokenAtOnce() {
repository.addDeleted(List.of(1L));
assertThat(rows("user_psn_account", 1L)).isZero();
assertThat(rows("user_psn_account", 2L)).isEqualTo(1);
}
@Test @Test
void deleteRows_AlsoDropsTheNotesAboutTheUser() { void deleteRows_AlsoDropsTheNotesAboutTheUser() {
jdbc.update("insert into inbox_note (user_id, kind, subject_user_id) values (2, 'VISIBILITY', 1)"); jdbc.update("insert into inbox_note (user_id, kind, subject_user_id) values (2, 'VISIBILITY', 1)");
@@ -138,7 +147,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
Map<String, Integer> deletedRows = repository.deleteRows(List.of(1L)); Map<String, Integer> deletedRows = repository.deleteRows(List.of(1L));
// addDeleted already ended the follows and dropped the public profile. // addDeleted already ended the follows and dropped the public profile.
List<String> endedByAddDeleted = List.of("public_user", "follow", "follow_digest"); List<String> endedByAddDeleted = List.of("public_user", "follow", "follow_digest", "user_psn_account");
USER_TABLES.stream().filter(table -> !endedByAddDeleted.contains(table)) USER_TABLES.stream().filter(table -> !endedByAddDeleted.contains(table))
.forEach(table -> assertThat(deletedRows.get(table)).as(table).isEqualTo(1)); .forEach(table -> assertThat(deletedRows.get(table)).as(table).isEqualTo(1));
assertThat(deletedRows.get("deleted_user")).isEqualTo(1); assertThat(deletedRows.get("deleted_user")).isEqualTo(1);
@@ -496,6 +496,20 @@ class IgdbApiServiceTest extends AbstractUnitTest {
"https://images.igdb.com/igdb/image/upload/t_screenshot_med/ar1.jpg", List.of("PS5", "PC"), 1245620L)); "https://images.igdb.com/igdb/image/upload/t_screenshot_med/ar1.jpg", List.of("PS5", "PC"), 1245620L));
} }
@Test
void searchGames_SendsTheBodyAsUtf8() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(header("Content-Type", "text/plain;charset=UTF-8"))
.andExpect(request -> assertThat(((org.springframework.mock.http.client.MockClientHttpRequest) request)
.getBodyAsBytes()).containsSequence("Yōtei".getBytes(java.nio.charset.StandardCharsets.UTF_8)))
.andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
service.searchGames("Ghost of Yōtei");
igdb.verify();
}
@Test @Test
void searchGames_PutsTheExactNameFirstThenTheGamesWithMoreRatings() { void searchGames_PutsTheExactNameFirstThenTheGamesWithMoreRatings() {
expectToken(); expectToken();
@@ -169,7 +169,7 @@ class GameImportControllerTest extends AbstractMockedAdaptersTest {
@Test @Test
void create_MatchesInTheBackground_SameNameFirstAndMarksGamesInTheLibrary() throws Exception { void create_MatchesInTheBackground_SameNameFirstAndMarksGamesInTheLibrary() throws Exception {
jdbc.update("insert into steam_game (appid, name) values (1245620, 'ELDEN RING') on conflict do nothing"); jdbc.update("insert into steam_game (appid, name) values (1245620, 'ELDEN RING') on conflict do nothing");
userGameRepository.save(UserGame.manual(USER_ID, 1245620L, "PC", GameStatus.BACKLOG, 0, Instant.now())); userGameRepository.save(UserGame.manual(USER_ID, 1245620L, GameSource.MANUAL, "PC", GameStatus.BACKLOG, 0, Instant.now()));
long id = createReady("{\"source\": \"TEXT\", \"rows\": [{\"title\": \" Elden Ring \", \"platform\": \"PS5\"}, " long id = createReady("{\"source\": \"TEXT\", \"rows\": [{\"title\": \" Elden Ring \", \"platform\": \"PS5\"}, "
+ "{\"title\": \"Unknown Game\"}]}"); + "{\"title\": \"Unknown Game\"}]}");
@@ -218,6 +218,11 @@ class GameImportControllerTest extends AbstractMockedAdaptersTest {
create("{\"source\": \"TEXT\", \"rows\": [{\"platform\": \"PC\"}]}").andExpect(status().isBadRequest()); create("{\"source\": \"TEXT\", \"rows\": [{\"platform\": \"PC\"}]}").andExpect(status().isBadRequest());
} }
@Test
void create_PsnSourceFromTheClient_Is400() throws Exception {
create("{\"source\": \"PSN\", \"rows\": [{\"title\": \"Elden Ring\"}]}").andExpect(status().isBadRequest());
}
@Test @Test
void create_WhileAnotherImportIsMatching_Is409() throws Exception { void create_WhileAnotherImportIsMatching_Is409() throws Exception {
jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'MATCHING', 0)", USER_ID); jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'MATCHING', 0)", USER_ID);
@@ -1,6 +1,7 @@
package core_service.modules.library.api; package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter; import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
@@ -90,7 +91,7 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
} }
private void own(long userId, long appid) { private void own(long userId, long appid) {
userGameRepository.save(UserGame.manual(userId, appid, "Switch", GameStatus.BACKLOG, 0, Instant.now())); userGameRepository.save(UserGame.manual(userId, appid, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
} }
private MockHttpServletRequestBuilder as(MockHttpServletRequestBuilder request, long userId, Role role) { private MockHttpServletRequestBuilder as(MockHttpServletRequestBuilder request, long userId, Role role) {
@@ -105,7 +105,7 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
} }
private UserGame manualRow(long userId, long appid, GameStatus status) { private UserGame manualRow(long userId, long appid, GameStatus status) {
UserGame game = UserGame.manual(userId, appid, "Switch", status, 600, Instant.now()); UserGame game = UserGame.manual(userId, appid, GameSource.MANUAL, "Switch", status, 600, Instant.now());
return userGameRepository.save(game); return userGameRepository.save(game);
} }
@@ -136,7 +136,7 @@ class GameMergeUndoFlowTest extends AbstractIntegrationTest {
} }
private UserGame manualRow(long userId, long appid, GameStatus status) { private UserGame manualRow(long userId, long appid, GameStatus status) {
return userGameRepository.save(UserGame.manual(userId, appid, "Switch", status, 600, Instant.now())); return userGameRepository.save(UserGame.manual(userId, appid, GameSource.MANUAL, "Switch", status, 600, Instant.now()));
} }
private UserGame steamRow(long userId, long appid, GameStatus status) { private UserGame steamRow(long userId, long appid, GameStatus status) {
@@ -1,5 +1,6 @@
package core_service.modules.library.api; package core_service.modules.library.api;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
@@ -57,7 +58,7 @@ class LibraryControllerComingTest extends AbstractMockedAdaptersTest {
gameReleaseRepository.deleteAll(); gameReleaseRepository.deleteAll();
for (long appid : new long[]{COMING, COMING_SOON, RELEASED}) { for (long appid : new long[]{COMING, COMING_SOON, RELEASED}) {
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now())); steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, appid, "PC", GameStatus.BACKLOG, 0, Instant.now())); userGameRepository.save(UserGame.manual(USER_ID, appid, GameSource.MANUAL, "PC", GameStatus.BACKLOG, 0, Instant.now()));
} }
gameReleaseRepository.upsert(COMING, TOMORROW, false, Instant.now()); gameReleaseRepository.upsert(COMING, TOMORROW, false, Instant.now());
gameReleaseRepository.upsert(COMING_SOON, null, true, Instant.now()); gameReleaseRepository.upsert(COMING_SOON, null, true, Instant.now());
@@ -4,6 +4,7 @@ import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.domain.FinishedHintType; import core_service.modules.library.domain.FinishedHintType;
import core_service.modules.library.domain.GameAchievementSchema; import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta; import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
@@ -146,7 +147,7 @@ class LibraryControllerFinishedHintTest extends AbstractIntegrationTest {
@Test @Test
void detail_GameAddedByHand_GetsThePlaytimeHint() throws Exception { void detail_GameAddedByHand_GetsThePlaytimeHint() throws Exception {
userGameRepository.save(UserGame.manual(USER_ID, MANUAL_GAME, "Switch", GameStatus.PLAYING, 600, Instant.now())); userGameRepository.save(UserGame.manual(USER_ID, MANUAL_GAME, GameSource.MANUAL, "Switch", GameStatus.PLAYING, 600, Instant.now()));
mockMvc.perform(get("/api/library/games/" + MANUAL_GAME)) mockMvc.perform(get("/api/library/games/" + MANUAL_GAME))
.andExpect(jsonPath("$.finishedHint.type").value("PLAYTIME")) .andExpect(jsonPath("$.finishedHint.type").value("PLAYTIME"))
@@ -1,6 +1,7 @@
package core_service.modules.library.api; package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter; import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
@@ -56,7 +57,7 @@ class LibraryControllerHiddenTest extends AbstractMockedAdaptersTest {
steamGameRepository.save(new SteamGame(-1942L, "Zelda", null, Instant.now())); steamGameRepository.save(new SteamGame(-1942L, "Zelda", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now())); userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 1000L, 0, Instant.now(), Instant.now())); userGameRepository.save(new UserGame(USER_ID, 1000L, 0, Instant.now(), Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 60, Instant.now())); userGameRepository.save(UserGame.manual(USER_ID, -1942L, GameSource.MANUAL, "Switch", GameStatus.PLAYING, 60, Instant.now()));
} }
private ResultActions setHidden(long appid, String body) throws Exception { private ResultActions setHidden(long appid, String body) throws Exception {
@@ -241,7 +241,7 @@ class ManualGameControllerTest extends AbstractMockedAdaptersTest {
@Test @Test
void search_GameAddedBeforeIgdbKnewItOnSteam_IsInTheLibrary() throws Exception { void search_GameAddedBeforeIgdbKnewItOnSteam_IsInTheLibrary() throws Exception {
userGameRepository.save(UserGame.manual(USER_ID, -119388L, "Switch", GameStatus.BACKLOG, 0, Instant.now())); userGameRepository.save(UserGame.manual(USER_ID, -119388L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
when(igdbApiService.searchGames("zelda")).thenReturn(Optional.of(List.of(new IgdbApiService.Game(119388, when(igdbApiService.searchGames("zelda")).thenReturn(Optional.of(List.of(new IgdbApiService.Game(119388,
ZELDA.name(), 2023, null, List.of("Switch"), 2_000_000L)))); ZELDA.name(), 2023, null, List.of("Switch"), 2_000_000L))));
@@ -404,7 +404,7 @@ class ManualGameControllerTest extends AbstractMockedAdaptersTest {
void add_MergedGameIgdbLaterFindsOnSteam_IsStillInTheLibrary() throws Exception { void add_MergedGameIgdbLaterFindsOnSteam_IsStillInTheLibrary() throws Exception {
steamGameRepository.save(new SteamGame(-1_900_400L, "Zelda TotK", null, Instant.now())); steamGameRepository.save(new SteamGame(-1_900_400L, "Zelda TotK", null, Instant.now()));
jdbc.update("insert into game_alias (from_appid, to_appid) values (?, ?)", -119388L, -1_900_400L); jdbc.update("insert into game_alias (from_appid, to_appid) values (?, ?)", -119388L, -1_900_400L);
userGameRepository.save(UserGame.manual(USER_ID, -1_900_400L, "Switch", GameStatus.BACKLOG, 0, Instant.now())); userGameRepository.save(UserGame.manual(USER_ID, -1_900_400L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
when(igdbApiService.getGame(119388)).thenReturn(Optional.of(new IgdbApiService.Game(119388, ZELDA.name(), when(igdbApiService.getGame(119388)).thenReturn(Optional.of(new IgdbApiService.Game(119388, ZELDA.name(),
2023, null, List.of("Switch"), 2_000_000L))); 2023, null, List.of("Switch"), 2_000_000L)));
@@ -0,0 +1,276 @@
package core_service.modules.library.api;
import com.jayway.jsonpath.JsonPath;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.modules.psn_adapter.service.PsnAuthService;
import core_service.shared.security.SecretBox;
import core_service.shared.test.AbstractMockedAdaptersTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The dev profile authenticates every request as user 1. PlayStation is mocked at the two adapter services.
*/
@AutoConfigureMockMvc
class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
private static final String NPSSO = "NPSSO-SECRET-VALUE";
private static final String REFRESH = "REFRESH-SECRET-VALUE";
private static final String ACCESS = "ACCESS-SECRET-VALUE";
private static final IgdbApiService.Game GHOST = new IgdbApiService.Game(300, "Ghost of Yotei", 2025, null,
List.of("PS5"), null);
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private UserGameRepository userGameRepository;
@Value("${app.psn.token-key}")
private String tokenKey;
@BeforeEach
void seed() {
jdbc.update("delete from user_psn_account");
jdbc.update("delete from game_alias");
userGameRepository.deleteAll();
jdbc.update("delete from game_import");
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.searchGames("Ghost of Yotei")).thenReturn(Optional.of(List.of(GHOST)));
when(igdbApiService.getTimeToBeat(any())).thenReturn(Optional.of(Map.of()));
when(igdbApiService.getTimeToBeatByGameIds(any())).thenReturn(Optional.of(Map.of()));
when(psnAuthService.exchangeNpsso(NPSSO)).thenReturn(Optional.of(tokens(Instant.now().plus(Duration.ofDays(10)))));
when(psnAuthService.refresh(REFRESH)).thenReturn(Optional.of(tokens(Instant.now().plus(Duration.ofDays(10)))));
when(psnApiService.getPlayedTitles(ACCESS)).thenReturn(List.of(new PsnApiService.Title("PPSA1_00",
"Ghost of Yotei™", "ps5_native_game", Duration.ofMinutes(125), 10L)));
}
private static PsnAuthService.Tokens tokens(Instant refreshExpires) {
return new PsnAuthService.Tokens(ACCESS, Instant.now().plusSeconds(3599), REFRESH, refreshExpires);
}
private ResultActions link(String npsso) throws Exception {
return mockMvc.perform(post("/api/library/psn-account")
.header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"npsso\": \"" + npsso + "\"}"));
}
private ResultActions startImport() throws Exception {
return mockMvc.perform(post("/api/library/psn-account/imports").header(ClientHeaderFilter.CLIENT_HEADER, "test"));
}
private void awaitStatus(long id, String expected) throws InterruptedException {
for (int attempt = 0; attempt < 50; attempt++) {
if (expected.equals(jdbc.queryForObject("select status from game_import where id = ?", String.class, id))) {
return;
}
Thread.sleep(100);
}
throw new AssertionError("Import " + id + " not " + expected);
}
private long startedImport() throws Exception {
return ((Number) JsonPath.read(startImport().andExpect(status().isAccepted()).andReturn().getResponse()
.getContentAsString(), "$.id")).longValue();
}
@Test
void link_StoresOnlyTheSealedRefreshToken_AndTheAnswerHoldsNoSecret() throws Exception {
String body = link(NPSSO).andExpect(status().isOk())
.andExpect(jsonPath("$.enabled").value(true))
.andExpect(jsonPath("$.linked").value(true))
.andExpect(jsonPath("$.expiresAt").isNotEmpty())
.andReturn().getResponse().getContentAsString();
assertThat(body).doesNotContain(NPSSO).doesNotContain(REFRESH).doesNotContain(ACCESS);
byte[] stored = jdbc.queryForObject("select refresh_token_enc from user_psn_account where user_id = 1", byte[].class);
assertThat(new String(stored, StandardCharsets.ISO_8859_1)).doesNotContain(REFRESH).doesNotContain(NPSSO);
assertThat(SecretBox.fromBase64Key(tokenKey).open(stored)).isEqualTo(REFRESH);
}
@Test
void link_RejectedNpsso_Is400_AndStoresNothing() throws Exception {
link("bad-npsso").andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("NPSSO_INVALID"))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("bad-npsso"))));
mockMvc.perform(get("/api/library/psn-account")).andExpect(jsonPath("$.linked").value(false));
}
@Test
void link_BlankOrTooLongNpsso_Is400_WithoutEchoingIt() throws Exception {
link(" ").andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
String tooLong = "x".repeat(129);
String body = link(tooLong).andExpect(status().isBadRequest()).andReturn().getResponse().getContentAsString();
assertThat(body).doesNotContain(tooLong);
verify(psnAuthService, never()).exchangeNpsso(any());
}
@Test
void link_PsnDown_Is502() throws Exception {
when(psnAuthService.exchangeNpsso(NPSSO)).thenThrow(new org.springframework.web.client.ResourceAccessException("down"));
link(NPSSO).andExpect(status().isBadGateway()).andExpect(jsonPath("$.code").value("PSN_ERROR"));
}
@Test
void get_ThenDelete_UnlinksAndDeletesTheToken() throws Exception {
link(NPSSO).andExpect(status().isOk());
mockMvc.perform(get("/api/library/psn-account"))
.andExpect(jsonPath("$.linked").value(true))
.andExpect(jsonPath("$.linkedAt").isNotEmpty());
mockMvc.perform(delete("/api/library/psn-account").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isNoContent());
mockMvc.perform(get("/api/library/psn-account")).andExpect(jsonPath("$.linked").value(false));
assertThat(jdbc.queryForObject("select count(*) from user_psn_account", Integer.class)).isZero();
}
@Test
void postWithoutTheClientHeader_IsRefused() throws Exception {
mockMvc.perform(post("/api/library/psn-account").contentType(MediaType.APPLICATION_JSON)
.content("{\"npsso\": \"" + NPSSO + "\"}")).andExpect(status().isForbidden());
}
@Test
void import_StartsAPsnImportWithTheTitlesAsLines() throws Exception {
link(NPSSO).andExpect(status().isOk());
long id = startedImport();
assertThat(jdbc.queryForObject("select source from game_import where id = ?", String.class, id)).isEqualTo("PSN");
assertThat(jdbc.queryForMap("select title, platform, playtime_minutes from game_import_row where import_id = ?", id))
.containsEntry("title", "Ghost of Yotei").containsEntry("platform", "PS5")
.containsEntry("playtime_minutes", 125);
}
@Test
void import_NotLinked_Is404() throws Exception {
startImport().andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("PSN_NOT_LINKED"));
}
@Test
void import_ExpiredLink_Is409Relink_WithoutAPsnCall() throws Exception {
link(NPSSO).andExpect(status().isOk());
jdbc.update("update user_psn_account set refresh_expires_at = now() - interval '1 hour'");
startImport().andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("PSN_RELINK"));
verify(psnAuthService, never()).refresh(any());
}
@Test
void import_RefreshRefused_Is409Relink_AndTheLinkShowsAsExpired() throws Exception {
link(NPSSO).andExpect(status().isOk());
when(psnAuthService.refresh(REFRESH)).thenReturn(Optional.empty());
startImport().andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("PSN_RELINK"));
assertThat(jdbc.queryForObject("select refresh_expires_at <= now() from user_psn_account", Boolean.class)).isTrue();
}
@Test
void import_NoTitles_Is204() throws Exception {
link(NPSSO).andExpect(status().isOk());
when(psnApiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
startImport().andExpect(status().isNoContent());
}
@Test
void import_PsnDown_Is502_AndIgdbOff_Is503() throws Exception {
link(NPSSO).andExpect(status().isOk());
when(psnApiService.getPlayedTitles(ACCESS)).thenThrow(new org.springframework.web.client.ResourceAccessException("down"));
startImport().andExpect(status().isBadGateway()).andExpect(jsonPath("$.code").value("PSN_ERROR"));
doReturn(List.of(new PsnApiService.Title("A_00", "A", "ps4_game", Duration.ZERO, null)))
.when(psnApiService).getPlayedTitles(ACCESS);
when(igdbApiService.isEnabled()).thenReturn(false);
startImport().andExpect(status().isServiceUnavailable()).andExpect(jsonPath("$.code").value("IGDB_OFF"));
}
@Test
void savedPsnImport_AddsPsnRows_TheUserCanEditAndDelete_AndUndoRemoves() throws Exception {
link(NPSSO).andExpect(status().isOk());
long id = startedImport();
awaitStatus(id, "READY");
mockMvc.perform(post("/api/library/imports/" + id + "/save").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"rows\": [{\"lineNo\": 1, \"igdbId\": 300, \"platform\": \"PS5\", "
+ "\"status\": \"PLAYING\", \"playtimeMinutes\": 125}]}"))
.andExpect(status().isAccepted());
awaitStatus(id, "SAVED");
Map<String, Object> row = jdbc.queryForMap("select source, platform, playtime_minutes, import_id from user_game "
+ "where user_id = 1 and appid = -300");
assertThat(row).containsEntry("source", GameSource.PSN.name()).containsEntry("platform", "PS5")
.containsEntry("playtime_minutes", 125).containsEntry("import_id", id);
mockMvc.perform(get("/api/library/imports/" + id + "/undo-check")).andExpect(jsonPath("$.games").value(1));
mockMvc.perform(put("/api/library/games/-300/manual").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"platform\": \"PS4\", \"playtimeMinutes\": 200}"))
.andExpect(status().isNoContent());
assertThat(jdbc.queryForObject("select platform from user_game where appid = -300", String.class)).isEqualTo("PS4");
mockMvc.perform(delete("/api/library/imports/" + id + "/games").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isNoContent());
assertThat(jdbc.queryForObject("select count(*) from user_game where appid = -300", Integer.class)).isZero();
}
@Test
void psnRow_CanBeDeletedByHand() throws Exception {
jdbc.update("insert into steam_game (appid, name) values (-301, 'X') on conflict do nothing");
jdbc.update("insert into user_game (user_id, appid, playtime_minutes, first_seen_at, last_seen_at, source, platform, status, "
+ "status_set_by_user) values (1, -301, 0, now(), now(), 'PSN', 'PS5', 'BACKLOG', true)");
mockMvc.perform(delete("/api/library/games/-301").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isNoContent());
assertThat(jdbc.queryForObject("select count(*) from user_game where appid = -301", Integer.class)).isZero();
}
@Test
void npssoWithCharsNotAllowedInAHeader_Is400_AndNeverReachesTheAdapter() throws Exception {
link(NPSSO + "\\u200b").andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
link(NPSSO + "\\nX").andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
verify(psnAuthService, never()).exchangeNpsso(any());
}
}
@@ -1,5 +1,6 @@
package core_service.modules.library.repo; package core_service.modules.library.repo;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
@@ -67,7 +68,7 @@ class BacklogSnapshotRepositoryTest extends AbstractPersistenceTest {
removed.setRemovedAt(Instant.now()); removed.setRemovedAt(Instant.now());
userGameRepository.saveAllAndFlush(List.of( userGameRepository.saveAllAndFlush(List.of(
game(1L, 220L, 0, GameStatus.BACKLOG), hidden, removed, game(1L, 220L, 0, GameStatus.BACKLOG), hidden, removed,
UserGame.manual(2L, -1942L, "Switch", GameStatus.PLAYING, 0, Instant.now()))); UserGame.manual(2L, -1942L, GameSource.MANUAL, "Switch", GameStatus.PLAYING, 0, Instant.now())));
assertThat(repository.writeAll(DAY)).isEqualTo(2); assertThat(repository.writeAll(DAY)).isEqualTo(2);
@@ -0,0 +1,65 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.PsnAccount;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import static org.assertj.core.api.Assertions.assertThat;
@Import(PsnAccountRepository.class)
class PsnAccountRepositoryTest extends AbstractPersistenceTest {
@Autowired
private PsnAccountRepository repository;
@Test
void upsert_StoresTheBytes_AndALaterLinkReplacesThem() {
Instant first = Instant.now().plus(10, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS);
Instant second = first.plus(1, ChronoUnit.DAYS);
repository.upsert(5L, new byte[]{1, 2, 3}, first);
repository.upsert(5L, new byte[]{4, 5}, second);
PsnAccount account = repository.find(5L).orElseThrow();
assertThat(account.refreshTokenEnc()).containsExactly(4, 5);
assertThat(account.refreshExpiresAt()).isEqualTo(second);
assertThat(account.linkedAt()).isNotNull();
}
@Test
void updateToken_ChangesTokenAndExpiry_AndKeepsLinkedAt() {
repository.upsert(6L, new byte[]{1}, Instant.now().plusSeconds(100));
Instant linkedAt = repository.find(6L).orElseThrow().linkedAt();
Instant expiry = Instant.now().plus(10, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS);
repository.updateToken(6L, new byte[]{9}, expiry);
PsnAccount account = repository.find(6L).orElseThrow();
assertThat(account.refreshTokenEnc()).containsExactly(9);
assertThat(account.refreshExpiresAt()).isEqualTo(expiry);
assertThat(account.linkedAt()).isEqualTo(linkedAt);
}
@Test
void expireNow_PutsTheExpiryInThePast() {
repository.upsert(7L, new byte[]{1}, Instant.now().plus(5, ChronoUnit.DAYS));
repository.expireNow(7L);
assertThat(repository.find(7L).orElseThrow().refreshExpiresAt()).isBeforeOrEqualTo(Instant.now());
}
@Test
void delete_RemovesTheRow() {
repository.upsert(8L, new byte[]{1}, Instant.now().plusSeconds(100));
assertThat(repository.delete(8L)).isEqualTo(1);
assertThat(repository.find(8L)).isEmpty();
assertThat(repository.delete(8L)).isZero();
}
}
@@ -1,5 +1,6 @@
package core_service.modules.library.repo; package core_service.modules.library.repo;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
@@ -60,7 +61,7 @@ class StatusChangeLogTest extends AbstractPersistenceTest {
@Test @Test
void manualAdd_LogsTheStatusByUser() { void manualAdd_LogsTheStatusByUser() {
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 0, Instant.now())); userGameRepository.save(UserGame.manual(USER_ID, -1942L, GameSource.MANUAL, "Switch", GameStatus.PLAYING, 0, Instant.now()));
assertThat(log()).containsExactly("null->PLAYING true"); assertThat(log()).containsExactly("null->PLAYING true");
} }
@@ -101,7 +102,7 @@ class StatusChangeLogTest extends AbstractPersistenceTest {
@Test @Test
void deletedGame_TakesItsLogRowsAlong() { void deletedGame_TakesItsLogRowsAlong() {
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 0, Instant.now())); userGameRepository.save(UserGame.manual(USER_ID, -1942L, GameSource.MANUAL, "Switch", GameStatus.PLAYING, 0, Instant.now()));
entityManager.flush(); entityManager.flush();
userGameRepository.delete(game(-1942L)); userGameRepository.delete(game(-1942L));
@@ -117,7 +117,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void softDeleteMissingSince_KeepsGamesAddedByHand() { void softDeleteMissingSince_KeepsGamesAddedByHand() {
steamGameRepository.save(new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now())); steamGameRepository.save(new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 600, userGameRepository.save(UserGame.manual(USER_ID, -1942L, GameSource.MANUAL, "Switch", GameStatus.PLAYING, 600,
Instant.now().minusSeconds(3600))); Instant.now().minusSeconds(3600)));
Instant syncStartedAt = Instant.now(); Instant syncStartedAt = Instant.now();
@@ -143,7 +143,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow().setHiddenAt(Instant.now()); userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow().setHiddenAt(Instant.now());
userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow().setRemovedAt(Instant.now()); userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow().setRemovedAt(Instant.now());
steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now())); steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, 999L, "PS5", GameStatus.PLAYING, 60, Instant.now())); userGameRepository.save(UserGame.manual(USER_ID, 999L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.now()));
userGameRepository.flush(); userGameRepository.flush();
assertEquals(Set.of(220L, 620L), assertEquals(Set.of(220L, 620L),
@@ -164,7 +164,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void findAchievementProgress_LeavesOutGamesAddedByHand() { void findAchievementProgress_LeavesOutGamesAddedByHand() {
steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now())); steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, 999L, "PS5", GameStatus.PLAYING, 60, Instant.now())); userGameRepository.save(UserGame.manual(USER_ID, 999L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.now()));
assertTrue(userGameRepository.findAchievementProgress(USER_ID).stream().noneMatch(p -> p.appid() == 999L)); assertTrue(userGameRepository.findAchievementProgress(USER_ID).stream().noneMatch(p -> p.appid() == 999L));
} }
@@ -173,7 +173,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
void findRandomBacklogGameAndPicker_ExcludeNothingStillFindTheGameWithKeyMinusOne() { void findRandomBacklogGameAndPicker_ExcludeNothingStillFindTheGameWithKeyMinusOne() {
userGameRepository.deleteAll(); userGameRepository.deleteAll();
steamGameRepository.save(new SteamGame(-1L, "IGDB game 1", null, Instant.now())); steamGameRepository.save(new SteamGame(-1L, "IGDB game 1", null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, -1L, "PS2", GameStatus.BACKLOG, 0, Instant.now())); userGameRepository.save(UserGame.manual(USER_ID, -1L, GameSource.MANUAL, "PS2", GameStatus.BACKLOG, 0, Instant.now()));
assertEquals(-1L, userGameRepository.findRandomBacklogGame(USER_ID, UserGameRepository.NO_GAME).orElseThrow().getAppid()); assertEquals(-1L, userGameRepository.findRandomBacklogGame(USER_ID, UserGameRepository.NO_GAME).orElseThrow().getAppid());
assertEquals(1, userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 3).size()); assertEquals(1, userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 3).size());
@@ -1,6 +1,7 @@
package core_service.modules.library.repo; package core_service.modules.library.repo;
import core_service.modules.library.domain.DayMinutes; import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
@@ -281,10 +282,10 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
Instant now = Instant.now(); Instant now = Instant.now();
userGameRepository.saveAllAndFlush(List.of( userGameRepository.saveAllAndFlush(List.of(
game(220L, 100, GameStatus.BACKLOG, now), game(620L, 50, GameStatus.BACKLOG, now), game(220L, 100, GameStatus.BACKLOG, now), game(620L, 50, GameStatus.BACKLOG, now),
UserGame.manual(USER_ID, 8001L, "PS5", GameStatus.BACKLOG, 10, now), UserGame.manual(USER_ID, 8001L, GameSource.MANUAL, "PS5", GameStatus.BACKLOG, 10, now),
UserGame.manual(USER_ID, 8002L, "PS5", GameStatus.BACKLOG, 20, now), UserGame.manual(USER_ID, 8002L, GameSource.MANUAL, "PS5", GameStatus.BACKLOG, 20, now),
UserGame.manual(USER_ID, 8003L, "ps5 ", GameStatus.BACKLOG, 30, now), UserGame.manual(USER_ID, 8003L, GameSource.MANUAL, "ps5 ", GameStatus.BACKLOG, 30, now),
UserGame.manual(USER_ID, 8004L, "Switch", GameStatus.BACKLOG, 5, now))); UserGame.manual(USER_ID, 8004L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 5, now)));
seedHiddenAndRemoved(); seedHiddenAndRemoved();
assertThat(repository.gamesPerPlatform(USER_ID)).containsExactly( assertThat(repository.gamesPerPlatform(USER_ID)).containsExactly(
@@ -332,7 +333,7 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void pileMoves_GamesAddedByHandAreAdded() { void pileMoves_GamesAddedByHandAreAdded() {
UserGame manual = UserGame.manual(USER_ID, 700L, "Switch", GameStatus.BACKLOG, 0, Instant.now()); UserGame manual = UserGame.manual(USER_ID, 700L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 0, Instant.now());
userGameRepository.saveAndFlush(manual); userGameRepository.saveAndFlush(manual);
assertThat(repository.pileMoves(USER_ID, Instant.now().minusSeconds(3600))) assertThat(repository.pileMoves(USER_ID, Instant.now().minusSeconds(3600)))
@@ -4,6 +4,7 @@ import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.domain.GameAchievementSchema; import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta; import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta; import core_service.modules.library.domain.UserGameAchievementMeta;
@@ -94,7 +95,7 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
@Test @Test
void getGameDetail_GameAddedByHand_AsksSteamNothingAndShowsNoAchievements() { void getGameDetail_GameAddedByHand_AsksSteamNothingAndShowsNoAchievements() {
userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now())); userGameRepository.save(UserGame.manual(USER_ID, 620L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.now()));
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 5, true, Instant.now().minus(Duration.ofDays(31)))); schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 5, true, Instant.now().minus(Duration.ofDays(31))));
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow(); GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
@@ -120,7 +121,7 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
@Test @Test
void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAndMoodsAgain() { void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAndMoodsAgain() {
userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now())); userGameRepository.save(UserGame.manual(USER_ID, 620L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.now()));
when(igdbApiService.isEnabled()).thenReturn(true); when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getTimeToBeat(Set.of(620L))).thenReturn(Optional.of(Map.of())); when(igdbApiService.getTimeToBeat(Set.of(620L))).thenReturn(Optional.of(Map.of()));
@@ -23,6 +23,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.InOrder; import org.mockito.InOrder;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
@@ -234,9 +235,10 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
assertEquals(expected, saved.getValue().getStatus()); assertEquals(expected, saved.getValue().getStatus());
} }
@Test @ParameterizedTest
void runSync_GameAddedByHandIsNowOnSteam_BecomesASteamGameAndKeepsTheBacklog() { @EnumSource(value = GameSource.class, names = {"MANUAL", "PSN"})
UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.FINISHED, 60, Instant.EPOCH); void runSync_GameAddedByHandOrFromPsnIsNowOnSteam_BecomesASteamGameAndKeepsTheBacklog(GameSource source) {
UserGame manual = UserGame.manual(USER_ID, 220L, source, "PS5", GameStatus.FINISHED, 60, Instant.EPOCH);
manual.setRating(5); manual.setRating(5);
manual.setNotes("Played on the couch"); manual.setNotes("Played on the couch");
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual)); when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual));
@@ -254,7 +256,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Test @Test
void runSync_GameAddedByHandIsNowOnSteam_TheHandTypedHoursAreNoPlaytimeGrowth() { void runSync_GameAddedByHandIsNowOnSteam_TheHandTypedHoursAreNoPlaytimeGrowth() {
UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH); UserGame manual = UserGame.manual(USER_ID, 220L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual)); when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual));
steamReturns(game(90, 30), STEAM_ID); steamReturns(game(90, 30), STEAM_ID);
@@ -675,7 +677,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
UserGame gone = new UserGame(USER_ID, 300L, 10, Instant.EPOCH, Instant.EPOCH); UserGame gone = new UserGame(USER_ID, 300L, 10, Instant.EPOCH, Instant.EPOCH);
UserGame alreadyRemoved = new UserGame(USER_ID, 400L, 10, Instant.EPOCH, Instant.EPOCH); UserGame alreadyRemoved = new UserGame(USER_ID, 400L, 10, Instant.EPOCH, Instant.EPOCH);
alreadyRemoved.setRemovedAt(Instant.EPOCH); alreadyRemoved.setRemovedAt(Instant.EPOCH);
UserGame byHand = UserGame.manual(USER_ID, 500L, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH); UserGame byHand = UserGame.manual(USER_ID, 500L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(grown, gone, alreadyRemoved, byHand)); when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(grown, gone, alreadyRemoved, byHand));
when(userSteamSyncRepository.findForClaim(USER_ID)).thenReturn(Optional.of(syncRow(STEAM_ID, STEAM_ID))); when(userSteamSyncRepository.findForClaim(USER_ID)).thenReturn(Optional.of(syncRow(STEAM_ID, STEAM_ID)));
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1); when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
@@ -724,7 +726,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Test @Test
void runSync_GameAddedByHandIsNowOnSteam_IsNeitherAddedNorPlaytime() { void runSync_GameAddedByHandIsNowOnSteam_IsNeitherAddedNorPlaytime() {
UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH); UserGame manual = UserGame.manual(USER_ID, 220L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual)); when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual));
steamReturns(game(90, 30), STEAM_ID); steamReturns(game(90, 30), STEAM_ID);
@@ -0,0 +1,272 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.PsnAccount;
import core_service.modules.library.repo.PsnAccountRepository;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.modules.psn_adapter.service.PsnAuthService;
import core_service.shared.security.SecretBox;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.http.HttpStatus;
import org.springframework.web.client.HttpServerErrorException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(OutputCaptureExtension.class)
class PsnAccountServiceTest extends AbstractUnitTest {
private static final String KEY = Base64.getEncoder().encodeToString("0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8));
private static final String NPSSO = "NPSSO-SECRET-VALUE";
private static final String OLD_REFRESH = "REFRESH-OLD-SECRET";
private static final String NEW_REFRESH = "REFRESH-NEW-SECRET";
private static final String ACCESS = "ACCESS-SECRET";
private static final Instant EXPIRES = Instant.now().plus(Duration.ofDays(9));
@Mock
private PsnAccountRepository repository;
@Mock
private PsnAuthService authService;
@Mock
private PsnApiService apiService;
@Mock
private GameImportService importService;
private final SecretBox box = SecretBox.fromBase64Key(KEY);
private PsnAccountService service;
@BeforeEach
void setUp() {
service = new PsnAccountService(repository, authService, apiService, importService, KEY);
}
private PsnAuthService.Tokens tokens(String refresh, Instant refreshExpires) {
return new PsnAuthService.Tokens(ACCESS, Instant.now().plusSeconds(3599), refresh, refreshExpires);
}
private void linked(Instant expires) {
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L, box.seal(OLD_REFRESH), expires, Instant.now())));
}
private static void assertNoSecrets(CapturedOutput output) {
assertThat(output.getAll()).doesNotContain(NPSSO).doesNotContain(OLD_REFRESH).doesNotContain(NEW_REFRESH)
.doesNotContain(ACCESS);
}
@Test
void link_SealsTheRefreshToken_AndLogsNoSecret(CapturedOutput output) {
when(authService.exchangeNpsso(NPSSO)).thenReturn(Optional.of(tokens(NEW_REFRESH, EXPIRES)));
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L, new byte[0], EXPIRES, Instant.now())));
PsnAccountService.LinkResult result = service.link(1L, NPSSO);
assertThat(result).isInstanceOf(PsnAccountService.LinkResult.Linked.class);
ArgumentCaptor<byte[]> sealed = ArgumentCaptor.forClass(byte[].class);
verify(repository).upsert(eq(1L), sealed.capture(), eq(EXPIRES));
assertThat(new String(sealed.getValue(), StandardCharsets.ISO_8859_1)).doesNotContain(NEW_REFRESH);
assertThat(box.open(sealed.getValue())).isEqualTo(NEW_REFRESH);
assertNoSecrets(output);
}
@Test
void link_RejectedNpsso_StoresNothing_AndLogsNoSecret(CapturedOutput output) {
when(authService.exchangeNpsso(NPSSO)).thenReturn(Optional.empty());
assertThat(service.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Rejected.class);
verify(repository, never()).upsert(anyLong(), any(), any());
assertNoSecrets(output);
}
@Test
void link_PsnServerError_IsUnavailable_AndLogsNeitherBodyNorSecret(CapturedOutput output) {
when(authService.exchangeNpsso(NPSSO)).thenThrow(HttpServerErrorException.create(HttpStatus.INTERNAL_SERVER_ERROR,
"Internal", null, ("echo " + NPSSO + " " + OLD_REFRESH).getBytes(StandardCharsets.UTF_8), null));
assertThat(service.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Unavailable.class);
assertNoSecrets(output);
assertThat(output.getAll()).contains("500");
}
@Test
void link_WithoutAKey_IsOff_AndCallsNothing() {
PsnAccountService off = new PsnAccountService(repository, authService, apiService, importService, "");
assertThat(off.isEnabled()).isFalse();
assertThat(off.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Off.class);
assertThat(off.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Off.class);
assertThat(off.get(1L).enabled()).isFalse();
verifyNoInteractions(authService, apiService, repository);
}
@Test
void constructor_RejectsAKeyOfTheWrongLength() {
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
new PsnAccountService(repository, authService, apiService, importService, "c2hvcnQ="))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void startImport_RefreshesReadsTitles_AndCreatesAPsnImport(CapturedOutput output) {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES.plusSeconds(5))));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of(new PsnApiService.Title("PPSA1_00", "Ghost",
"ps5_native_game", Duration.ofMinutes(90), 7L)));
when(importService.createLines(eq(1L), eq(GameImport.Source.PSN), any()))
.thenReturn(new GameImportService.CreateResult.Created(42L));
PsnAccountService.ImportResult result = service.startImport(1L);
assertThat(result).isEqualTo(new PsnAccountService.ImportResult.Started(42L));
ArgumentCaptor<List<GameImport.Line>> lines = ArgumentCaptor.captor();
verify(importService).createLines(eq(1L), eq(GameImport.Source.PSN), lines.capture());
assertThat(lines.getValue()).containsExactly(new GameImport.Line(1, "Ghost", "PS5", 90,
core_service.modules.library.domain.GameStatus.PLAYING, null));
assertNoSecrets(output);
}
@Test
void startImport_SameRefreshToken_KeepsTheStoredExpiry() {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES.plusSeconds(999))));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.NoTitles.class);
verify(repository, never()).updateToken(anyLong(), any(), any());
}
@Test
void startImport_NewRefreshToken_StoresItSealedWithItsExpiry(CapturedOutput output) {
linked(EXPIRES);
Instant newExpiry = Instant.now().plus(Duration.ofDays(10));
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(NEW_REFRESH, newExpiry)));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
service.startImport(1L);
ArgumentCaptor<byte[]> sealed = ArgumentCaptor.forClass(byte[].class);
verify(repository).updateToken(eq(1L), sealed.capture(), eq(newExpiry));
assertThat(box.open(sealed.getValue())).isEqualTo(NEW_REFRESH);
assertNoSecrets(output);
}
@Test
void startImport_ExpiredLink_IsRelink_WithoutAPsnCall() {
linked(Instant.now().minusSeconds(1));
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Relink.class);
verifyNoInteractions(authService, apiService);
}
@Test
void startImport_RefreshRefused_ExpiresTheLink_AndIsRelink() {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.empty());
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Relink.class);
verify(repository).expireNow(1L);
verify(apiService, never()).getPlayedTitles(any());
}
@Test
void startImport_TokenSealedWithAnotherKey_IsRelink(CapturedOutput output) {
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L,
SecretBox.fromBase64Key(Base64.getEncoder().encodeToString(new byte[32])).seal(OLD_REFRESH), EXPIRES,
Instant.now())));
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Relink.class);
verify(repository).expireNow(1L);
assertNoSecrets(output);
}
@Test
void startImport_NotLinked() {
when(repository.find(1L)).thenReturn(Optional.empty());
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.NotLinked.class);
}
@Test
void startImport_PsnServerError_IsUnavailable_AndLogsNoSecret(CapturedOutput output) {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES)));
when(apiService.getPlayedTitles(ACCESS)).thenThrow(HttpServerErrorException.create(
HttpStatus.BAD_GATEWAY, "Bad", null, (ACCESS + OLD_REFRESH).getBytes(StandardCharsets.UTF_8), null));
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Unavailable.class);
verify(importService, never()).createLines(anyLong(), any(), any());
assertNoSecrets(output);
}
@Test
void startImport_MapsTheImportResults() {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES)));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of(new PsnApiService.Title("A_00", "A", "ps4_game",
Duration.ZERO, null)));
when(importService.createLines(eq(1L), any(), any()))
.thenReturn(new GameImportService.CreateResult.AlreadyMatching())
.thenReturn(new GameImportService.CreateResult.SearchUnavailable());
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.AlreadyMatching.class);
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.SearchUnavailable.class);
}
@Test
void link_WhileAnotherCallOfTheSameUserRuns_IsBusy_WithoutTakingAPermit() throws Exception {
java.util.concurrent.CountDownLatch entered = new java.util.concurrent.CountDownLatch(1);
java.util.concurrent.CountDownLatch release = new java.util.concurrent.CountDownLatch(1);
when(authService.exchangeNpsso(NPSSO)).thenAnswer(call -> {
entered.countDown();
release.await();
return Optional.empty();
});
when(repository.find(2L)).thenReturn(Optional.empty());
java.util.concurrent.CompletableFuture<PsnAccountService.LinkResult> first =
java.util.concurrent.CompletableFuture.supplyAsync(() -> service.link(1L, NPSSO));
entered.await();
assertThat(service.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Busy.class);
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Busy.class);
assertThat(service.startImport(2L)).isInstanceOf(PsnAccountService.ImportResult.NotLinked.class);
release.countDown();
assertThat(first.get()).isInstanceOf(PsnAccountService.LinkResult.Rejected.class);
verify(authService, org.mockito.Mockito.times(1)).exchangeNpsso(NPSSO);
// The guard is free again.
assertThat(service.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Rejected.class);
}
@Test
void unlink_DeletesTheRow() {
service.unlink(1L);
verify(repository).delete(1L);
}
}
@@ -0,0 +1,83 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.ManualGameUpdate;
import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.time.Duration;
import java.util.List;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
class PsnTitlesTest extends AbstractUnitTest {
private static PsnApiService.Title title(String id, String name, String category, String duration, Long concept) {
return new PsnApiService.Title(id, name, category, Duration.parse(duration), concept);
}
@ParameterizedTest
@CsvSource(delimiter = '|', value = {
"Celeste™|Celeste",
"Ghost of Tsushima DIRECTORS CUT®|Ghost of Tsushima DIRECTORS CUT",
"Hades PS4 & PS5|Hades",
"Hades PS5 & PS4|Hades",
"Control (PS5)|Control",
"Control (ps4)|Control",
"Returnal PS5 Edition|Returnal",
"Doom - PS4 Version|Doom",
"Spider-Man: Miles Morales|Spider-Man: Miles Morales",
"Fez: PS4 Edition|Fez",
"™|™"})
void cleanName_StripsMarksAndPlatformSuffixes(String raw, String expected) {
assertThat(PsnTitles.cleanName(raw)).isEqualTo(expected);
}
@Test
void cleanName_CutsTo100Characters() {
assertThat(PsnTitles.cleanName("x".repeat(150))).hasSize(100);
}
@Test
void platform_Ps5NativeIsPs5_ElsePs4() {
assertThat(PsnTitles.platform("ps5_native_game")).isEqualTo("PS5");
assertThat(PsnTitles.platform("ps4_game")).isEqualTo("PS4");
assertThat(PsnTitles.platform(null)).isEqualTo("PS4");
}
@Test
void toLines_OneConceptIsOneLine_WithTheMostPlayedNameAndPlatform_AndTheSummedMinutes() {
List<GameImport.Line> lines = PsnTitles.toLines(List.of(
title("CUSA1_00", "Hades PS4", "ps4_game", "PT2H", 1L),
title("PPSA1_00", "Hades™", "ps5_native_game", "PT65H9M15S", 1L),
title("PPSA2_00", "Celeste", "ps5_native_game", "PT0S", 2L)));
assertThat(lines).hasSize(2);
assertThat(lines.get(0)).isEqualTo(new GameImport.Line(1, "Hades", "PS5", 65 * 60 + 9 + 120,
GameStatus.PLAYING, null));
assertThat(lines.get(1)).isEqualTo(new GameImport.Line(2, "Celeste", "PS5", 0, GameStatus.BACKLOG, null));
}
@Test
void toLines_TitlesWithoutAConceptStaySeparate() {
List<GameImport.Line> lines = PsnTitles.toLines(List.of(
title("A_00", "A", "ps4_game", "PT1H", null), title("B_00", "B", "ps4_game", "PT1H", null)));
assertThat(lines).extracting(GameImport.Line::title).containsExactly("A", "B");
}
@Test
void toLines_CapsTheMinutes_AndTheLineCount() {
assertThat(PsnTitles.toLines(List.of(title("A_00", "A", "ps4_game", "PT999999H", 1L))).getFirst()
.playtimeMinutes()).isEqualTo(ManualGameUpdate.MAX_MINUTES);
List<PsnApiService.Title> many = IntStream.range(0, 1200)
.mapToObj(i -> title("T" + i, "Game " + i, "ps4_game", "PT1H", (long) i)).toList();
assertThat(PsnTitles.toLines(many)).hasSize(1000);
}
}
@@ -1,5 +1,6 @@
package core_service.modules.library.service; package core_service.modules.library.service;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.StatGroup; import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
@@ -61,7 +62,7 @@ class UserStatsServiceTrendTest extends AbstractPersistenceTest {
@Test @Test
void thisMonth_FirstSteamImportAfterManualGames_IsNoAdd() { void thisMonth_FirstSteamImportAfterManualGames_IsNoAdd() {
// Last month the user had only a game added by hand, so the nightly job wrote a snapshot then. // Last month the user had only a game added by hand, so the nightly job wrote a snapshot then.
userGameRepository.saveAndFlush(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.BACKLOG, 0, Instant.now())); userGameRepository.saveAndFlush(UserGame.manual(USER_ID, -1942L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
jdbc.update("update user_game_status_change set changed_at = now() - interval '40 days' where appid = -1942"); jdbc.update("update user_game_status_change set changed_at = now() - interval '40 days' where appid = -1942");
LocalDate today = LocalDate.now(ZoneId.of("Europe/Berlin")); LocalDate today = LocalDate.now(ZoneId.of("Europe/Berlin"));
jdbc.update("insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, " + jdbc.update("insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, " +
@@ -0,0 +1,16 @@
package core_service.modules.psn_adapter.config;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.net.http.HttpClient;
import static org.assertj.core.api.Assertions.assertThat;
class PsnClientConfigTest extends AbstractUnitTest {
@Test
void psnHttpClient_NeverFollowsARedirect() {
assertThat(new PsnClientConfig().psnHttpClient().followRedirects()).isEqualTo(HttpClient.Redirect.NEVER);
}
}
@@ -0,0 +1,80 @@
package core_service.modules.psn_adapter.service;
import core_service.shared.test.AbstractUnitTest;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.time.Duration;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class PsnApiServiceTest extends AbstractUnitTest {
private MockRestServiceServer server;
private PsnApiService service;
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder().baseUrl("http://psn.test/api");
server = MockRestServiceServer.bindTo(builder).build();
service = new PsnApiService(builder.build(), RateLimiterRegistry.ofDefaults());
}
@Test
void getPlayedTitles_FollowsNextOffset_WithBearerAndLimit200() {
server.expect(requestTo(containsString("/gamelist/v2/users/me/titles?")))
.andExpect(requestTo(containsString("limit=200")))
.andExpect(requestTo(containsString("offset=0")))
.andExpect(requestTo(containsString("categories=ps4_game,ps5_native_game")))
.andExpect(header("Authorization", "Bearer ACCESS-1"))
.andRespond(withSuccess("""
{"titles":[{"titleId":"PPSA1_00","name":"Ghost","category":"ps5_native_game",
"playDuration":"PT65H9M15S","concept":{"id":10,"titleIds":["PPSA1_00"]},"extra":1}],
"totalItemCount":2,"nextOffset":200}""", MediaType.APPLICATION_JSON));
server.expect(requestTo(containsString("offset=200")))
.andRespond(withSuccess("""
{"titles":[{"titleId":"CUSA2_00","name":"Mega","category":"ps4_game","playDuration":"PT2H"}],
"totalItemCount":2,"nextOffset":null}""", MediaType.APPLICATION_JSON));
List<PsnApiService.Title> titles = service.getPlayedTitles("ACCESS-1");
assertThat(titles).extracting(PsnApiService.Title::titleId).containsExactly("PPSA1_00", "CUSA2_00");
assertThat(titles.get(0).playDuration()).isEqualTo(Duration.parse("PT65H9M15S"));
assertThat(titles.get(0).conceptId()).isEqualTo(10L);
assertThat(titles.get(1).conceptId()).isNull();
server.verify();
}
@Test
void getPlayedTitles_BadDuration_IsZero_AndTitlesWithoutIdOrNameAreSkipped() {
server.expect(requestTo(containsString("/titles")))
.andRespond(withSuccess("""
{"titles":[{"titleId":"A_00","name":"A","playDuration":"nonsense"},
{"name":"No id"},{"titleId":"B_00"}],"nextOffset":null}""",
MediaType.APPLICATION_JSON));
List<PsnApiService.Title> titles = service.getPlayedTitles("t");
assertThat(titles).hasSize(1);
assertThat(titles.getFirst().playDuration()).isZero();
}
@Test
void getPlayedTitles_ServerErrorPropagates() {
server.expect(requestTo(containsString("/titles"))).andRespond(withServerError());
assertThatThrownBy(() -> service.getPlayedTitles("t")).isInstanceOf(RestClientException.class);
}
}
@@ -0,0 +1,157 @@
package core_service.modules.psn_adapter.service;
import core_service.modules.psn_adapter.api.dto.response.PsnTokenResponse;
import core_service.shared.test.AbstractUnitTest;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.net.URI;
import java.time.Instant;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class PsnAuthServiceTest extends AbstractUnitTest {
private static final String BASIC = "Basic dGVzdC1pZDp0ZXN0LXNlY3JldA==";
private static final String TOKENS = """
{"access_token":"ACCESS-1","token_type":"bearer","expires_in":3599,"scope":"x","id_token":"ID-1",
"refresh_token":"REFRESH-1","refresh_token_expires_in":863999}""";
private MockRestServiceServer server;
private PsnAuthService service;
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder().baseUrl("http://psn.test/oauth");
server = MockRestServiceServer.bindTo(builder).build();
service = new PsnAuthService(builder.build(), RateLimiterRegistry.ofDefaults(), "test-id", "test-secret",
"app://redirect");
}
@Test
void exchangeNpsso_SendsTheCookie_ReadsTheCode_AndPostsTheForm() {
server.expect(requestTo(containsString("/oauth/authorize?")))
.andExpect(method(org.springframework.http.HttpMethod.GET))
.andExpect(header("Cookie", "npsso=NPSSO-1"))
.andExpect(requestTo(containsString("access_type=offline")))
.andExpect(requestTo(containsString("client_id=test-id")))
.andExpect(requestTo(containsString("response_type=code")))
.andRespond(withStatus(HttpStatus.FOUND).location(URI.create("app://redirect/?code=CODE-1&cid=x")));
server.expect(requestTo("http://psn.test/oauth/token"))
.andExpect(method(org.springframework.http.HttpMethod.POST))
.andExpect(header("Authorization", BASIC))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_FORM_URLENCODED))
.andExpect(content().string(containsString("code=CODE-1")))
.andExpect(content().string(containsString("grant_type=authorization_code")))
.andExpect(content().string(containsString("token_format=jwt")))
.andRespond(withSuccess(TOKENS, MediaType.APPLICATION_JSON));
Optional<PsnAuthService.Tokens> tokens = service.exchangeNpsso("NPSSO-1");
assertThat(tokens).isPresent();
assertThat(tokens.get().accessToken()).isEqualTo("ACCESS-1");
assertThat(tokens.get().refreshToken()).isEqualTo("REFRESH-1");
assertThat(tokens.get().refreshExpiresAt()).isBetween(Instant.now().plusSeconds(863_990),
Instant.now().plusSeconds(864_010));
server.verify();
}
@Test
void exchangeNpsso_NoCodeInTheRedirect_IsEmpty() {
server.expect(requestTo(containsString("/authorize")))
.andRespond(withStatus(HttpStatus.FOUND).location(URI.create("app://redirect/?error=invalid_grant")));
assertThat(service.exchangeNpsso("bad")).isEmpty();
server.verify();
}
@Test
void exchangeNpsso_NoRedirect_IsEmpty() {
server.expect(requestTo(containsString("/authorize"))).andRespond(withSuccess());
assertThat(service.exchangeNpsso("bad")).isEmpty();
}
@Test
void exchangeNpsso_TokenRefusedWith4xx_IsEmpty() {
server.expect(requestTo(containsString("/authorize")))
.andRespond(withStatus(HttpStatus.FOUND).location(URI.create("app://redirect/?code=CODE-1")));
server.expect(requestTo(containsString("/token"))).andRespond(withStatus(HttpStatus.UNAUTHORIZED));
assertThat(service.exchangeNpsso("npsso")).isEmpty();
}
@Test
void exchangeNpsso_ServerErrorPropagates() {
server.expect(requestTo(containsString("/authorize")))
.andRespond(withStatus(HttpStatus.FOUND).location(URI.create("app://redirect/?code=CODE-1")));
server.expect(requestTo(containsString("/token"))).andRespond(withServerError());
assertThatThrownBy(() -> service.exchangeNpsso("npsso")).isInstanceOf(RestClientException.class);
}
@Test
void exchangeNpsso_ValueTheJdkRefusesInAHeader_ThrowsWithoutTheValue() {
String npsso = "SECRET\u200bNPSSO\nX";
server.expect(requestTo(containsString("/authorize"))).andRespond(request -> {
throw new IllegalArgumentException("invalid header value: \"npsso=" + npsso + "\"");
});
assertThatThrownBy(() -> service.exchangeNpsso(npsso)).isInstanceOf(RestClientException.class)
.hasMessageNotContaining("SECRET").hasNoCause();
}
@Test
void refresh_PostsTheRefreshToken_AndReturnsTheTokens() {
server.expect(requestTo("http://psn.test/oauth/token"))
.andExpect(header("Authorization", BASIC))
.andExpect(content().string(containsString("refresh_token=REFRESH-0")))
.andExpect(content().string(containsString("grant_type=refresh_token")))
.andRespond(withSuccess(TOKENS, MediaType.APPLICATION_JSON));
Optional<PsnAuthService.Tokens> tokens = service.refresh("REFRESH-0");
assertThat(tokens).isPresent();
assertThat(tokens.get().accessToken()).isEqualTo("ACCESS-1");
server.verify();
}
@Test
void refresh_Refused_IsEmpty() {
server.expect(requestTo(containsString("/token"))).andRespond(withStatus(HttpStatus.BAD_REQUEST));
assertThat(service.refresh("old")).isEmpty();
}
@Test
void refresh_RateLimited_IsAFailureNotARefusal() {
server.expect(requestTo(containsString("/token"))).andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS));
assertThatThrownBy(() -> service.refresh("old")).isInstanceOf(RestClientException.class);
}
@Test
void toString_OfTheTokenTypes_HoldsNoToken() {
PsnTokenResponse response = new PsnTokenResponse("ACCESS-1", 3599, "REFRESH-1", 863999L);
PsnAuthService.Tokens tokens = new PsnAuthService.Tokens("ACCESS-1", Instant.now(), "REFRESH-1", Instant.now());
assertThat(response.toString()).doesNotContain("ACCESS-1").doesNotContain("REFRESH-1").contains("863999");
assertThat(tokens.toString()).doesNotContain("ACCESS-1").doesNotContain("REFRESH-1");
}
}
@@ -1,5 +1,6 @@
package core_service.modules.stats.repo; package core_service.modules.stats.repo;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
@@ -97,9 +98,9 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
steamGameRepository.saveAllAndFlush(List.of(new SteamGame(8001L, "A", null, Instant.now()), steamGameRepository.saveAllAndFlush(List.of(new SteamGame(8001L, "A", null, Instant.now()),
new SteamGame(8002L, "B", null, Instant.now()), new SteamGame(8003L, "C", null, Instant.now()))); new SteamGame(8002L, "B", null, Instant.now()), new SteamGame(8003L, "C", null, Instant.now())));
userGameRepository.saveAllAndFlush(List.of( userGameRepository.saveAllAndFlush(List.of(
UserGame.manual(1L, 8001L, "ps5", GameStatus.BACKLOG, 10, Instant.now()), UserGame.manual(1L, 8001L, GameSource.MANUAL, "ps5", GameStatus.BACKLOG, 10, Instant.now()),
UserGame.manual(2L, 8002L, "PS5", GameStatus.BACKLOG, 20, Instant.now()), UserGame.manual(2L, 8002L, GameSource.MANUAL, "PS5", GameStatus.BACKLOG, 20, Instant.now()),
UserGame.manual(3L, 8003L, "Switch", GameStatus.BACKLOG, 20, Instant.now()))); UserGame.manual(3L, 8003L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 20, Instant.now())));
jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)"); jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)");
List<Map<String, Object>> rows = statsRepository.gamesPerPlatform(); List<Map<String, Object>> rows = statsRepository.gamesPerPlatform();
@@ -154,8 +155,8 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
new SteamGame(8003L, "C", null, Instant.now()))); new SteamGame(8003L, "C", null, Instant.now())));
userGameRepository.saveAllAndFlush(List.of( userGameRepository.saveAllAndFlush(List.of(
new UserGame(5L, 220L, 100, Instant.now(), Instant.now()), new UserGame(5L, 220L, 100, Instant.now(), Instant.now()),
UserGame.manual(5L, 8001L, "ps5", GameStatus.BACKLOG, 10, Instant.now()), UserGame.manual(5L, 8001L, GameSource.MANUAL, "ps5", GameStatus.BACKLOG, 10, Instant.now()),
UserGame.manual(3L, 8003L, "ps5", GameStatus.BACKLOG, 20, Instant.now()))); UserGame.manual(3L, 8003L, GameSource.MANUAL, "ps5", GameStatus.BACKLOG, 20, Instant.now())));
jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)"); jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)");
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 5 AND appid = 220"); jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 5 AND appid = 220");
gameMoodsRepository.upsert(220L, new String[]{}, new String[]{"RPG"}, false, Instant.now()); gameMoodsRepository.upsert(220L, new String[]{}, new String[]{"RPG"}, false, Instant.now());
@@ -0,0 +1,91 @@
package core_service.shared.security;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class SecretBoxTest extends AbstractUnitTest {
private static String newKey() {
byte[] key = new byte[32];
new SecureRandom().nextBytes(key);
return Base64.getEncoder().encodeToString(key);
}
@Test
void open_GivesBackWhatSealSealed() {
SecretBox box = SecretBox.fromBase64Key(newKey());
assertThat(box.open(box.seal("refresh-token-ä"))).isEqualTo("refresh-token-ä");
}
@Test
void seal_UsesAFreshNonceEachTime() {
SecretBox box = SecretBox.fromBase64Key(newKey());
assertThat(box.seal("same")).isNotEqualTo(box.seal("same"));
}
@Test
void seal_DoesNotHoldThePlainText() {
byte[] sealed = SecretBox.fromBase64Key(newKey()).seal("a-very-recognizable-token");
assertThat(new String(sealed, java.nio.charset.StandardCharsets.ISO_8859_1)).doesNotContain("recognizable");
assertThat(sealed[0]).isEqualTo((byte) 1);
}
@Test
void open_ThrowsForAChangedByte() {
SecretBox box = SecretBox.fromBase64Key(newKey());
byte[] sealed = box.seal("token");
sealed[sealed.length - 1] ^= 1;
assertThatThrownBy(() -> box.open(sealed)).isInstanceOf(IllegalStateException.class);
}
@Test
void open_ThrowsForAnotherKey() {
byte[] sealed = SecretBox.fromBase64Key(newKey()).seal("token");
assertThatThrownBy(() -> SecretBox.fromBase64Key(newKey()).open(sealed))
.isInstanceOf(IllegalStateException.class);
}
@Test
void open_ThrowsForAnUnknownVersionOrShortBytes() {
SecretBox box = SecretBox.fromBase64Key(newKey());
byte[] sealed = box.seal("token");
sealed[0] = 2;
assertThatThrownBy(() -> box.open(sealed)).isInstanceOf(IllegalStateException.class);
assertThatThrownBy(() -> box.open(Arrays.copyOf(sealed, 5))).isInstanceOf(IllegalStateException.class);
}
@Test
void fromBase64Key_RejectsAKeyThatIsNot32Bytes() {
String shortKey = Base64.getEncoder().encodeToString(new byte[16]);
assertThatThrownBy(() -> SecretBox.fromBase64Key(shortKey)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("APP_PSN_TOKEN_KEY must be 32 bytes, base64")
.hasMessageNotContaining(shortKey);
assertThatThrownBy(() -> SecretBox.fromBase64Key("not base64 !!")).isInstanceOf(IllegalArgumentException.class);
}
@Test
void errorsAndToString_NeverHoldTheKeyOrTheBytes() {
String key = newKey();
SecretBox box = SecretBox.fromBase64Key(key);
byte[] sealed = box.seal("token");
sealed[sealed.length - 1] ^= 1;
assertThat(box.toString()).isEqualTo("SecretBox").doesNotContain(key);
assertThatThrownBy(() -> box.open(sealed)).hasMessageNotContaining(key)
.hasMessageNotContaining(Base64.getEncoder().encodeToString(sealed));
}
}
@@ -108,6 +108,8 @@ public abstract class AbstractIntegrationTest {
static void secrets(DynamicPropertyRegistry registry) { static void secrets(DynamicPropertyRegistry registry) {
registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(TEST_KEYS.getPublic().getEncoded())); registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(TEST_KEYS.getPublic().getEncoded()));
registry.add("app.steam.api-key", () -> "test-key"); registry.add("app.steam.api-key", () -> "test-key");
// 32 bytes, base64: a key for tests only.
registry.add("app.psn.token-key", () -> "dGVzdC1vbmx5LXBzbi1rZXktMzItYnl0ZXMtISEhISE=");
registry.add("app.library.achievement-sync-pause-ms", () -> "0"); registry.add("app.library.achievement-sync-pause-ms", () -> "0");
} }
@@ -1,12 +1,14 @@
package core_service.shared.test; package core_service.shared.test;
import core_service.modules.igdb_adapter.service.IgdbApiService; import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.modules.psn_adapter.service.PsnAuthService;
import core_service.modules.speedrun_adapter.service.SpeedrunApiService; import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
import core_service.modules.steam_adapter.service.SteamApiService; import core_service.modules.steam_adapter.service.SteamApiService;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
/** /**
* Integration tests with the three external APIs mocked, in one shared context. A mock answers empty, so a sync a * Integration tests with the external APIs mocked, in one shared context. A mock answers empty, so a sync a
* request starts finds no games and leaves the library as it is. * request starts finds no games and leaves the library as it is.
*/ */
public abstract class AbstractMockedAdaptersTest extends AbstractIntegrationTest { public abstract class AbstractMockedAdaptersTest extends AbstractIntegrationTest {
@@ -19,4 +21,10 @@ public abstract class AbstractMockedAdaptersTest extends AbstractIntegrationTest
@MockitoBean @MockitoBean
protected SpeedrunApiService speedrunApiService; protected SpeedrunApiService speedrunApiService;
@MockitoBean
protected PsnAuthService psnAuthService;
@MockitoBean
protected PsnApiService psnApiService;
} }