Public Access
Compare commits
2
Commits
117ff61730
...
7ac887ed5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ac887ed5a | ||
|
|
e87f40bba0 |
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
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"urlPath": "/psn/auth/token"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "psn-token.json"
|
||||
}
|
||||
}
|
||||
@@ -21,19 +21,23 @@ To Be Played is split into three repositories:
|
||||
can be taken over; its own finish then changes nothing.
|
||||
- **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority,
|
||||
filters and sorting. "Pile of Shame" summary with a random suggestion of what to play.
|
||||
- **Picker**: six rows of up to 3 backlog games each, for a time budget (IGDB normal time to beat), in this order:
|
||||
- **Picker**: seven rows of up to 3 backlog games each, for a time budget (IGDB normal time to beat), in this order:
|
||||
`NEXT_UP` (the first games of the user's own backlog order), `SERIES` (next in series: for each of the 6 most played
|
||||
IGDB series the user played, one backlog part, the parts released after the latest played part first), `SIMILAR`
|
||||
("because you played": for each of the 3 most played games the first backlog game sharing its moods, no game twice),
|
||||
`STUDIO` (for each of the 6 developers of the user's most played games, one backlog game with a credit of that
|
||||
company, developer credits first; publishers are never anchors, so a big publisher adds at most one card),
|
||||
`FRIENDS` (for each followed user with a public profile, one backlog game of the user that the friend finished or
|
||||
played 5 h or more, strongest signal first; a finish and playtime need the `SUMMARY` or `STATUS` profile section (`FINISHED` alone lists only 12
|
||||
games); `DROPPED`, hidden and removed games never count; the friend's current
|
||||
display name, never an id),
|
||||
`LONGEST` (oldest `firstSeenAt`) and `RANDOM` (the mood row: random, HIGH priority first, then MEDIUM, then the
|
||||
rest). The mood filters only the `RANDOM` row; budget and exclude filters apply to every row. A row with no game is
|
||||
left out. A game can show in more than one row. IGDB series (collections) and companies are stored per game
|
||||
(`game_credit`, `game_credit_check`) and refreshed with the other IGDB data; a scheduled backfill
|
||||
(`app.library.credit-backfill-*`) fetches them for existing games, 250 games per 30 s, so the two rows fill up over
|
||||
time after a deploy. Every game carries a `reason` (`source`, plus the anchor game for `SIMILAR`, `seriesName` for
|
||||
`SERIES`, `studioName` for `STUDIO` or `firstSeenAt` for `LONGEST`).
|
||||
`SERIES`, `studioName` for `STUDIO`, `friendName`, `friendFinished` and `friendPlaytimeMinutes` for `FRIENDS` or `firstSeenAt` for `LONGEST`).
|
||||
Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`,
|
||||
`CHILL`, `ACTION`, `SCARY` come from IGDB genres, themes and game modes; `SPEEDRUN` from speedrun.com.
|
||||
- **Follow users (#27)**: a logged-in user follows people with a public profile by display name (`/api/follows`, at most
|
||||
@@ -132,6 +136,9 @@ To Be Played is split into three repositories:
|
||||
then after 30 days.
|
||||
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the
|
||||
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
|
||||
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
|
||||
@@ -204,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 |
|
||||
| `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/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 |
|
||||
| `POST` | `/api/library/sync` | JWT |
|
||||
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
|
||||
@@ -262,9 +272,11 @@ flowchart LR
|
||||
Steam["steam_adapter"]
|
||||
Igdb["igdb_adapter"]
|
||||
Speedrun["speedrun_adapter"]
|
||||
Psn["psn_adapter"]
|
||||
LibService --> Steam
|
||||
LibService --> Igdb
|
||||
LibService --> Speedrun
|
||||
LibService --> Psn
|
||||
end
|
||||
Browser -->|"tbp_access cookie"| Security
|
||||
Rewards --> Db
|
||||
@@ -274,7 +286,8 @@ flowchart LR
|
||||
Igdb --> IgdbApi["IGDB API"]
|
||||
Igdb -->|"OAuth token"| Twitch["Twitch"]
|
||||
Speedrun --> SpeedrunApi["speedrun.com API"]
|
||||
LibRepo --> Db[("PostgreSQL tbp_core")]
|
||||
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")]
|
||||
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
|
||||
Inbox --> Db
|
||||
@@ -284,7 +297,7 @@ flowchart LR
|
||||
|
||||
| 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 |
|
||||
| `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` |
|
||||
@@ -293,6 +306,7 @@ flowchart LR
|
||||
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
|
||||
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
|
||||
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
|
||||
| `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`,
|
||||
`user_game` and `user_game_status_change` in SQL; the sync tells `follow` through `shared.event.LibraryActivityEvent`.
|
||||
@@ -301,7 +315,7 @@ flowchart LR
|
||||
SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
|
||||
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
|
||||
into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library`
|
||||
reads `deleted_user` and `blocked_user`. A module that moves to its own service takes these reads along as calls.
|
||||
reads `deleted_user` and `blocked_user`, and for the picker `follow`, `public_user` and `account_name`. A module that moves to its own service takes these reads along as calls.
|
||||
The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view
|
||||
`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. The numbers that
|
||||
count games once per linked set read `counted_user_game` (V28), built on `listed_user_game`: a migration that changes
|
||||
@@ -310,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`
|
||||
(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
|
||||
`speedrun_adapter`.
|
||||
- Modules do not depend on each other. Only `library` may use `steam_adapter`, `igdb_adapter`,
|
||||
`speedrun_adapter` and `psn_adapter`.
|
||||
- No cycles between `modules`, `shared` and `configuration`.
|
||||
- `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
|
||||
@@ -500,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
|
||||
`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 tombstone also deletes the user's stored PlayStation token (`user_psn_account`) at once.
|
||||
|
||||
### Blocked users and Steam ID changes
|
||||
|
||||
@@ -524,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
|
||||
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
|
||||
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
|
||||
@@ -566,10 +581,34 @@ stateDiagram-v2
|
||||
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;
|
||||
`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
|
||||
|
||||
### Local development without keys (profile `local`)
|
||||
@@ -603,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.
|
||||
- "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.
|
||||
- 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
|
||||
Steam copy, so the link queue (auditor) lists the pair with the IGDB badge.
|
||||
|
||||
@@ -633,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 |
|
||||
| `STEAM_API_KEY` | Steam Web API key |
|
||||
| `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 |
|
||||
|
||||
## 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.
|
||||
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",
|
||||
"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");
|
||||
|
||||
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
|
||||
private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
|
||||
"game_import", "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;
|
||||
|
||||
@@ -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) {
|
||||
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_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 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.RestClient;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
@@ -673,7 +674,8 @@ public class IgdbApiService {
|
||||
.uri(path)
|
||||
.header("Client-ID", clientId)
|
||||
.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)
|
||||
.body(body)
|
||||
.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;
|
||||
|
||||
@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
|
||||
*/
|
||||
|
||||
@@ -7,35 +7,43 @@ import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Why the picker chose a game. The anchor fields are set only for SIMILAR, {@code firstSeenAt} only for LONGEST,
|
||||
* {@code seriesName} only for SERIES, {@code studioName} only for STUDIO; the others are null.
|
||||
* {@code seriesName} only for SERIES, {@code studioName} only for STUDIO, {@code friendName}, {@code friendFinished} and
|
||||
* {@code friendPlaytimeMinutes} only for FRIENDS; the others are null.
|
||||
*
|
||||
* @param anchorName null when the anchor game has no name
|
||||
*/
|
||||
public record PickReasonDto(PickSource source, Long anchorAppid, String anchorName, Integer anchorPlaytimeMinutes,
|
||||
Instant firstSeenAt, String seriesName, String studioName) {
|
||||
Instant firstSeenAt, String seriesName, String studioName,
|
||||
String friendName, Boolean friendFinished, Integer friendPlaytimeMinutes) {
|
||||
|
||||
public static PickReasonDto similar(UserGame anchor, String anchorName) {
|
||||
return new PickReasonDto(PickSource.SIMILAR, anchor.getAppid(), anchorName, anchor.getPlaytimeMinutes(), null,
|
||||
null, null);
|
||||
null, null, null, null, null);
|
||||
}
|
||||
|
||||
public static PickReasonDto longest(Instant firstSeenAt) {
|
||||
return new PickReasonDto(PickSource.LONGEST, null, null, null, firstSeenAt, null, null);
|
||||
return new PickReasonDto(PickSource.LONGEST, null, null, null, firstSeenAt, null, null, null, null, null);
|
||||
}
|
||||
|
||||
public static PickReasonDto random() {
|
||||
return new PickReasonDto(PickSource.RANDOM, null, null, null, null, null, null);
|
||||
return new PickReasonDto(PickSource.RANDOM, null, null, null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
public static PickReasonDto nextUp() {
|
||||
return new PickReasonDto(PickSource.NEXT_UP, null, null, null, null, null, null);
|
||||
return new PickReasonDto(PickSource.NEXT_UP, null, null, null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
public static PickReasonDto series(String seriesName) {
|
||||
return new PickReasonDto(PickSource.SERIES, null, null, null, null, seriesName, null);
|
||||
return new PickReasonDto(PickSource.SERIES, null, null, null, null, seriesName, null, null, null, null);
|
||||
}
|
||||
|
||||
public static PickReasonDto studio(String studioName) {
|
||||
return new PickReasonDto(PickSource.STUDIO, null, null, null, null, null, studioName);
|
||||
return new PickReasonDto(PickSource.STUDIO, null, null, null, null, null, studioName, null, null, null);
|
||||
}
|
||||
|
||||
/** @param playtimeMinutes null when the friend's profile hides it */
|
||||
public static PickReasonDto friends(String friendName, boolean finished, Integer playtimeMinutes) {
|
||||
return new PickReasonDto(PickSource.FRIENDS, null, null, null, null, null, null, friendName, finished,
|
||||
playtimeMinutes);
|
||||
}
|
||||
}
|
||||
@@ -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[]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
/**
|
||||
* A backlog game of the caller that a followed user finished or played a lot, with that friend's visible data.
|
||||
*
|
||||
* @param friendUserId never sent to the client; one slot per friend
|
||||
* @param playtimeMinutes null when the friend's profile hides SUMMARY
|
||||
*/
|
||||
public record FriendPlay(long appid, long friendUserId, String friendName, boolean finished, Integer playtimeMinutes) {}
|
||||
@@ -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
|
||||
* 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 }
|
||||
|
||||
|
||||
@@ -7,5 +7,10 @@ package core_service.modules.library.domain;
|
||||
*/
|
||||
public enum GameSource {
|
||||
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
|
||||
}
|
||||
@@ -2,14 +2,15 @@ package core_service.modules.library.domain;
|
||||
|
||||
/**
|
||||
* Where a picker game came from, and the picker row it is shown in. The order of the constants is the order of the
|
||||
* rows. SERIES is the next part of a played series, STUDIO a game by a developer the user played. RANDOM is the mood
|
||||
* row.
|
||||
* rows. SERIES is the next part of a played series, STUDIO a game by a developer the user played, FRIENDS a
|
||||
* backlog game a followed user finished or played a lot. RANDOM is the mood row.
|
||||
*/
|
||||
public enum PickSource {
|
||||
NEXT_UP,
|
||||
SERIES,
|
||||
SIMILAR,
|
||||
STUDIO,
|
||||
FRIENDS,
|
||||
LONGEST,
|
||||
RANDOM
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
public static UserGame manual(Long userId, Long appid, String platform, GameStatus status, int playtimeMinutes,
|
||||
Instant now) {
|
||||
public static UserGame manual(Long userId, Long appid, GameSource source, String platform, GameStatus status,
|
||||
int playtimeMinutes, Instant now) {
|
||||
UserGame game = new UserGame(userId, appid, playtimeMinutes, now, now);
|
||||
game.source = GameSource.MANUAL;
|
||||
game.source = source;
|
||||
game.platform = platform;
|
||||
game.status = status;
|
||||
game.statusSetByUser = true;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.FriendPlay;
|
||||
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.Types;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What the users the caller follows played of the caller's backlog. Plain SQL: it reads the follow module's tables
|
||||
* so the privacy rules and the data come from one statement.
|
||||
*/
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
public class FriendPlayRepository {
|
||||
|
||||
/** Playtime from which a friend "played" a game: 5 h. */
|
||||
private static final int PLAYED_MINUTES = 300;
|
||||
|
||||
// A finish and playtime are shown with SUMMARY or STATUS (the profile's full lists); FINISHED alone lists only
|
||||
// 12 games. No sections = all shown. Hidden values are nulled before the order, so they do not leak through it.
|
||||
private static final String SQL = """
|
||||
with played as (
|
||||
select ug.appid, f.followed_user_id, a.display_name,
|
||||
(fg.status in ('FINISHED', 'COMPLETED')
|
||||
and (p.sections is null or p.sections && array['SUMMARY', 'STATUS']::varchar[])) as finished,
|
||||
case when p.sections is null or p.sections && array['SUMMARY', 'STATUS']::varchar[]
|
||||
then fg.playtime_minutes end as playtime_minutes
|
||||
from follow f
|
||||
join public_user p on p.user_id = f.followed_user_id
|
||||
join account_name a on a.user_id = f.followed_user_id
|
||||
join counted_user_game fg on fg.user_id = f.followed_user_id
|
||||
join counted_user_game ug on ug.user_id = f.user_id and ug.appid = fg.appid
|
||||
where f.user_id = :userId
|
||||
and fg.status <> 'DROPPED'
|
||||
and not exists (select 1 from blocked_user b where b.user_id = f.followed_user_id)
|
||||
and not exists (select 1 from deleted_user d where d.user_id = f.followed_user_id)
|
||||
and %s
|
||||
),
|
||||
best as (
|
||||
select distinct on (appid) * from played
|
||||
where finished or playtime_minutes >= :minPlayed
|
||||
order by appid, finished desc, playtime_minutes desc nulls last, followed_user_id
|
||||
)
|
||||
select appid, followed_user_id, display_name, finished, playtime_minutes from best
|
||||
order by finished desc, playtime_minutes desc nulls last, random()
|
||||
""".formatted(UserGameRepository.PICKER_FILTERS);
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
|
||||
/**
|
||||
* Per backlog game of the caller that passes the picker filters, the followed user with the strongest signal
|
||||
* (a finish first, then the most playtime), strongest game first. Only active follows of public users who are
|
||||
* not blocked or deleted; a finish and playtime count only when the profile shows SUMMARY or STATUS. Hidden, removed and dropped games of the friend are not counted.
|
||||
*
|
||||
* @param maxMinutes only games whose normal time to beat is known and at most this; null for every game
|
||||
* @param excludeAppids never picked; may be empty
|
||||
*/
|
||||
public List<FriendPlay> findFriendPlays(long userId, Integer maxMinutes, Collection<Long> excludeAppids) {
|
||||
MapSqlParameterSource params = new MapSqlParameterSource("userId", userId)
|
||||
.addValue("excludeAppids", UserGameRepository.excludedOrNone(excludeAppids))
|
||||
.addValue("maxMinutes", maxMinutes, Types.INTEGER)
|
||||
.addValue("mood", null, Types.VARCHAR)
|
||||
.addValue("speedrun", false)
|
||||
.addValue("minPlayed", PLAYED_MINUTES);
|
||||
return jdbc.query(SQL, params, (rs, row) -> new FriendPlay(rs.getLong("appid"),
|
||||
rs.getLong("followed_user_id"), rs.getString("display_name"), rs.getBoolean("finished"),
|
||||
rs.getObject("playtime_minutes", Integer.class)));
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,11 @@ public class GameImportRepository {
|
||||
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.
|
||||
*/
|
||||
@@ -235,7 +240,7 @@ public class GameImportRepository {
|
||||
*/
|
||||
public int countGames(long userId, long importId) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -250,7 +255,7 @@ public class GameImportRepository {
|
||||
public int countChangedGames(long userId, long importId) {
|
||||
return jdbc.queryForObject("""
|
||||
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
|
||||
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))
|
||||
@@ -261,7 +266,7 @@ public class GameImportRepository {
|
||||
* Deletes {@link #countGames} the way the manual remove does: with status, rating, notes and history.
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@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);
|
||||
|
||||
@Query("select ug.appid from UserGame ug where " + LISTED +
|
||||
@@ -303,7 +303,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
"order by ug.playtime_minutes desc, ug.appid limit :limit", nativeQuery = true)
|
||||
List<UserGame> findMostPlayedGames(@Param("userId") Long userId, @Param("limit") int limit);
|
||||
|
||||
private static List<Long> excludedOrNone(Collection<Long> excludeAppids) {
|
||||
static List<Long> excludedOrNone(Collection<Long> excludeAppids) {
|
||||
List<Long> excluded = new ArrayList<>(excludeAppids);
|
||||
// "not in ()" is invalid SQL
|
||||
excluded.add(NO_GAME);
|
||||
|
||||
@@ -119,7 +119,7 @@ public class GameDetailService {
|
||||
gameEditionService.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.
|
||||
timeToBeatService.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) {
|
||||
if (!igdbApiService.isEnabled()) {
|
||||
return new CreateResult.SearchUnavailable();
|
||||
}
|
||||
List<GameImport.Line> lines = IntStream.range(0, rows.size()).mapToObj(index -> {
|
||||
ImportRequest.Row row = rows.get(index);
|
||||
return new GameImport.Line(index + 1, strippedOrNull(row.title()), strippedOrNull(row.platform()),
|
||||
row.playtimeMinutes(), row.status(), row.igdbId());
|
||||
}).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;
|
||||
try {
|
||||
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.GameImport;
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.IgdbGame;
|
||||
import core_service.modules.library.repo.GameImportRepository;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
@@ -141,6 +142,8 @@ public class GameImportWorker {
|
||||
List<GameImport.Choice> choices = importRepository.findUnsavedChoices(importId);
|
||||
if (!choices.isEmpty()) {
|
||||
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()
|
||||
.collect(Collectors.toMap(row -> row.line().lineNo(), row -> row));
|
||||
for (GameImport.Choice choice : choices) {
|
||||
@@ -154,7 +157,7 @@ public class GameImportWorker {
|
||||
return;
|
||||
}
|
||||
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(),
|
||||
added ? GameImport.SaveResult.ADDED : GameImport.SaveResult.SKIPPED);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import core_service.modules.library.api.dto.WishlistDto;
|
||||
import core_service.modules.library.domain.AchievementProgress;
|
||||
import core_service.modules.library.domain.ComingGame;
|
||||
import core_service.modules.library.domain.DayMinutes;
|
||||
import core_service.modules.library.domain.FriendPlay;
|
||||
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
@@ -26,8 +27,10 @@ import core_service.modules.library.domain.StatGroup;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||
import core_service.modules.library.domain.UserGameId;
|
||||
import core_service.modules.library.domain.UserSteamSync;
|
||||
import core_service.modules.library.repo.ComingGameRepository;
|
||||
import core_service.modules.library.repo.FriendPlayRepository;
|
||||
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
|
||||
import core_service.modules.library.repo.GameCreditRepository;
|
||||
import core_service.modules.library.repo.GameLinkRepository;
|
||||
@@ -96,6 +99,7 @@ public class LibraryQueryService {
|
||||
private final SpeedrunService speedrunService;
|
||||
private final GameLinkRepository gameLinkRepository;
|
||||
private final GameCreditRepository gameCreditRepository;
|
||||
private final FriendPlayRepository friendPlayRepository;
|
||||
|
||||
@Value("${app.library.zone:Europe/Berlin}")
|
||||
private ZoneId zone;
|
||||
@@ -115,9 +119,9 @@ public class LibraryQueryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per {@link PickSource} (next up, next in series, because you played, same studio, longest waiting,
|
||||
* mood), in the order of its constants, each with up to three backlog games. Budget and exclude apply to every
|
||||
* row; the mood only to the RANDOM row. Rows without a game are left out.
|
||||
* One row per {@link PickSource} (next up, next in series, because you played, same studio, friends played,
|
||||
* longest waiting, mood), in the order of its constants, each with up to three backlog games. Budget and exclude
|
||||
* apply to every row; the mood only to the RANDOM row. Rows without a game are left out.
|
||||
* With a mood, also refreshes the backlog's moods or speedrun links in the background for the next pick.
|
||||
*
|
||||
* @param only the one row to build (a reroll); null builds all rows
|
||||
@@ -139,6 +143,7 @@ public class LibraryQueryService {
|
||||
case SERIES -> nextInSeries(userId, maxMinutes, excludeAppids);
|
||||
case SIMILAR -> becauseYouPlayed(userId, maxMinutes, excludeAppids);
|
||||
case STUDIO -> sameStudio(userId, maxMinutes, excludeAppids);
|
||||
case FRIENDS -> friendsPlayed(userId, maxMinutes, excludeAppids);
|
||||
case LONGEST -> withReason(userGameRepository.findLongestWaitingGames(userId, maxMinutes, null,
|
||||
excludeAppids, ROW_SIZE), g -> PickReasonDto.longest(g.getFirstSeenAt()));
|
||||
case RANDOM -> withReason(userGameRepository.findPickerGames(userId, maxMinutes, mood,
|
||||
@@ -179,6 +184,24 @@ public class LibraryQueryService {
|
||||
anchor -> PickReasonDto.similar(anchor, names.get(anchor.getAppid())));
|
||||
}
|
||||
|
||||
/**
|
||||
* The strongest friend signals first, one game per friend. A game a stronger friend took is not shown for another
|
||||
* friend.
|
||||
*/
|
||||
private List<Map.Entry<UserGame, PickReasonDto>> friendsPlayed(Long userId, Integer maxMinutes,
|
||||
List<Long> excludeAppids) {
|
||||
Set<Long> friends = new HashSet<>();
|
||||
List<FriendPlay> picks = friendPlayRepository.findFriendPlays(userId, maxMinutes, excludeAppids).stream()
|
||||
.filter(p -> friends.add(p.friendUserId())).limit(ROW_SIZE).toList();
|
||||
Map<Long, UserGame> games = userGameRepository.findAllById(
|
||||
picks.stream().map(p -> new UserGameId(userId, p.appid())).toList()).stream()
|
||||
.collect(Collectors.toMap(UserGame::getAppid, g -> g));
|
||||
return picks.stream()
|
||||
.map(p -> Map.entry(games.get(p.appid()),
|
||||
PickReasonDto.friends(p.friendName(), p.finished(), p.playtimeMinutes())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* For each series the user played (most played first), the first backlog part no earlier series took: the parts
|
||||
* released after the latest played part come first.
|
||||
|
||||
@@ -276,7 +276,7 @@ public class LibrarySyncWorker {
|
||||
Long appid = g.appid().longValue();
|
||||
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.
|
||||
boolean takesOverManualGame = existing != null && existing.getSource() == GameSource.MANUAL;
|
||||
boolean takesOverManualGame = existing != null && existing.getSource() != GameSource.STEAM;
|
||||
int previousPlaytime = existing != null ? existing.getPlaytimeMinutes() : 0;
|
||||
int playtime2Weeks = g.playtime2Weeks() != null ? g.playtime2Weeks() : 0;
|
||||
if (existing == null || existing.getRemovedAt() != null) {
|
||||
|
||||
@@ -173,7 +173,7 @@ public class ManualGameService {
|
||||
if (found.isEmpty()) {
|
||||
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) {
|
||||
timeToBeatService.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
|
||||
* 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
|
||||
* row that comes back keeps no import id.
|
||||
*/
|
||||
public AddResult addGame(Long userId, IgdbGame game, String platform, GameStatus status,
|
||||
int playtimeMinutes, Long importId) {
|
||||
int playtimeMinutes, GameSource source, Long importId) {
|
||||
AddResult result;
|
||||
try {
|
||||
result = addInTransaction(userId, game, platform.strip(), status, playtimeMinutes, importId);
|
||||
result = addInTransaction(userId, game, platform.strip(), status, playtimeMinutes, source, importId);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
// 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);
|
||||
try {
|
||||
result = addInTransaction(userId, game, platform.strip(), status, playtimeMinutes, importId);
|
||||
result = addInTransaction(userId, game, platform.strip(), status, playtimeMinutes, source, importId);
|
||||
} catch (DataIntegrityViolationException again) {
|
||||
return new AddResult.AlreadyInLibrary();
|
||||
}
|
||||
@@ -233,7 +234,7 @@ public class ManualGameService {
|
||||
}
|
||||
|
||||
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 -> {
|
||||
Instant now = Instant.now();
|
||||
Map<Long, Long> targets = mergeRepository.findTargets(ownKeys(game));
|
||||
@@ -245,7 +246,7 @@ public class ManualGameService {
|
||||
saveCatalogEntry(appid, game, now);
|
||||
if (existing.isPresent()) {
|
||||
UserGame revived = existing.get();
|
||||
revived.setSource(GameSource.MANUAL);
|
||||
revived.setSource(source);
|
||||
revived.setPlatform(platform);
|
||||
revived.setStatus(status);
|
||||
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.
|
||||
revived.setImportId(null);
|
||||
} 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);
|
||||
userGameRepository.save(added);
|
||||
}
|
||||
@@ -287,7 +288,7 @@ public class ManualGameService {
|
||||
if (game.isEmpty()) {
|
||||
return ChangeResult.NOT_FOUND;
|
||||
}
|
||||
if (game.get().getSource() != GameSource.MANUAL) {
|
||||
if (game.get().getSource() == GameSource.STEAM) {
|
||||
return ChangeResult.STEAM_GAME;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
+19
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,11 @@ app:
|
||||
client-secret: local
|
||||
base-url: "http://localhost:8089/igdb"
|
||||
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:
|
||||
base-url: "http://localhost:8089/speedrun"
|
||||
library:
|
||||
|
||||
@@ -51,6 +51,15 @@ app:
|
||||
client-secret: ${IGDB_CLIENT_SECRET:}
|
||||
base-url: https://api.igdb.com/v4
|
||||
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:
|
||||
# No key needed. false stops every call to speedrun.com.
|
||||
enabled: true
|
||||
@@ -127,3 +136,9 @@ resilience4j:
|
||||
limit-for-period: 4
|
||||
limit-refresh-period: 1s
|
||||
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):
|
||||
* - JPA entities live in `..domain..` (not `..persistence..`).
|
||||
* - 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.
|
||||
*/
|
||||
@AnalyzeClasses(packages = "core_service", importOptions = ImportOption.DoNotIncludeTests.class)
|
||||
@@ -44,8 +44,8 @@ class ArchitectureTest extends AbstractArchitectureTest {
|
||||
.ignoreDependency(
|
||||
JavaClass.Predicates.resideInAPackage("core_service.modules.library.."),
|
||||
JavaClass.Predicates.resideInAnyPackage("core_service.modules.steam_adapter..", "core_service.modules.igdb_adapter..",
|
||||
"core_service.modules.speedrun_adapter.."))
|
||||
.because("Feature modules should be isolated; steam_adapter, igdb_adapter and speedrun_adapter are explicit shared ports.");
|
||||
"core_service.modules.speedrun_adapter..", "core_service.modules.psn_adapter.."))
|
||||
.because("Feature modules should be isolated; steam_adapter, igdb_adapter, speedrun_adapter and psn_adapter are explicit shared ports.");
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule no_cyclic_dependencies = SlicesRuleDefinition.slices()
|
||||
@@ -74,7 +74,7 @@ class ArchitectureTest extends AbstractArchitectureTest {
|
||||
.that().resideInAPackage("core_service.modules..domain..")
|
||||
.should().dependOnClassesThat().resideInAnyPackage("..api..", "..service..", "..repo..",
|
||||
"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.");
|
||||
|
||||
// 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(
|
||||
"core_service.modules.steam_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.");
|
||||
|
||||
@ArchTest
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package core_service.configuration;
|
||||
|
||||
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.steam_adapter.api.dto.response.SteamGlobalAchievementPercentagesResponse;
|
||||
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.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
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-editions", IgdbResponses.Edition[].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-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();
|
||||
|
||||
@Test
|
||||
@@ -62,7 +70,9 @@ class LocalMocksParseTest extends AbstractUnitTest {
|
||||
|
||||
for (Path mapping : mappings) {
|
||||
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")) {
|
||||
assertThat(FILES.resolve(response.get("bodyFileName").asString())).as(mapping.toString()).exists();
|
||||
}
|
||||
@@ -80,6 +90,9 @@ class LocalMocksParseTest extends AbstractUnitTest {
|
||||
return;
|
||||
}
|
||||
for (RecordComponent component : parsed.getClass().getRecordComponents()) {
|
||||
if (OPTIONAL_FIELDS.contains(component.getName())) {
|
||||
continue;
|
||||
}
|
||||
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",
|
||||
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
|
||||
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
|
||||
"reward_equipped", "listed_user", "public_user", "follow", "follow_digest", "inbox_note");
|
||||
"reward_equipped", "user_psn_account", "listed_user", "public_user", "follow", "follow_digest", "inbox_note");
|
||||
|
||||
@Autowired
|
||||
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 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 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);
|
||||
}
|
||||
}
|
||||
@@ -93,6 +94,14 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
|
||||
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
|
||||
void deleteRows_AlsoDropsTheNotesAboutTheUser() {
|
||||
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));
|
||||
|
||||
// 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))
|
||||
.forEach(table -> assertThat(deletedRows.get(table)).as(table).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));
|
||||
}
|
||||
|
||||
@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
|
||||
void searchGames_PutsTheExactNameFirstThenTheGamesWithMoreRatings() {
|
||||
expectToken();
|
||||
|
||||
@@ -169,7 +169,7 @@ class GameImportControllerTest extends AbstractMockedAdaptersTest {
|
||||
@Test
|
||||
void create_MatchesInTheBackground_SameNameFirstAndMarksGamesInTheLibrary() throws Exception {
|
||||
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\"}, "
|
||||
+ "{\"title\": \"Unknown Game\"}]}");
|
||||
@@ -218,6 +218,11 @@ class GameImportControllerTest extends AbstractMockedAdaptersTest {
|
||||
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
|
||||
void create_WhileAnotherImportIsMatching_Is409() throws Exception {
|
||||
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;
|
||||
|
||||
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.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
@@ -90,7 +91,7 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -105,7 +105,7 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ class GameMergeUndoFlowTest extends AbstractIntegrationTest {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
@@ -57,7 +58,7 @@ class LibraryControllerComingTest extends AbstractMockedAdaptersTest {
|
||||
gameReleaseRepository.deleteAll();
|
||||
for (long appid : new long[]{COMING, COMING_SOON, RELEASED}) {
|
||||
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_SOON, null, true, Instant.now());
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import core_service.configuration.security.ClientHeaderFilter;
|
||||
import core_service.modules.library.domain.FinishedHintType;
|
||||
import core_service.modules.library.domain.GameAchievementSchema;
|
||||
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.GameTimeToBeat;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
@@ -146,7 +147,7 @@ class LibraryControllerFinishedHintTest extends AbstractIntegrationTest {
|
||||
|
||||
@Test
|
||||
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))
|
||||
.andExpect(jsonPath("$.finishedHint.type").value("PLAYTIME"))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package core_service.modules.library.api;
|
||||
|
||||
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.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
@@ -56,7 +57,7 @@ class LibraryControllerHiddenTest extends AbstractMockedAdaptersTest {
|
||||
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, 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 {
|
||||
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
package core_service.modules.library.api;
|
||||
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.GameTimeToBeat;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.modules.library.repo.GameReleaseRepository;
|
||||
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
||||
import core_service.modules.library.repo.SteamGameRepository;
|
||||
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import core_service.shared.test.AbstractMockedLibraryServicesTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.ResultActions;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.hamcrest.Matchers.contains;
|
||||
import static org.hamcrest.Matchers.containsInAnyOrder;
|
||||
import static org.hamcrest.Matchers.hasItem;
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* The FRIENDS picker row. The dev profile authenticates every request as user 1.
|
||||
*/
|
||||
@AutoConfigureMockMvc
|
||||
class LibraryControllerPickerFriendsTest extends AbstractMockedLibraryServicesTest {
|
||||
|
||||
private static final long USER_ID = 1L;
|
||||
private static final long MAX = 950_001L;
|
||||
private static final long ANNA = 950_002L;
|
||||
private static final long TOM = 950_003L;
|
||||
private static final long[] FRIENDS = {MAX, ANNA, TOM, 950_004L, 950_005L, 950_006L, 950_007L};
|
||||
private static final String ROW = "$.categories[?(@.source == 'FRIENDS')]";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@Autowired
|
||||
private SteamGameRepository steamGameRepository;
|
||||
|
||||
@Autowired
|
||||
private UserGameRepository userGameRepository;
|
||||
|
||||
@Autowired
|
||||
private GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||
|
||||
@Autowired
|
||||
private GameReleaseRepository gameReleaseRepository;
|
||||
|
||||
@Autowired
|
||||
private UserGameAchievementMetaRepository achievementMetaRepository;
|
||||
|
||||
@BeforeEach
|
||||
void clean() {
|
||||
for (long id : FRIENDS) {
|
||||
jdbc.update("delete from follow where user_id = ? or followed_user_id = ?", id, id);
|
||||
jdbc.update("delete from public_user where user_id = ?", id);
|
||||
jdbc.update("delete from blocked_user where user_id = ?", id);
|
||||
jdbc.update("delete from deleted_user where user_id = ?", id);
|
||||
jdbc.update("delete from account_name where user_id = ?", id);
|
||||
}
|
||||
jdbc.update("delete from follow where user_id = ?", USER_ID);
|
||||
gameReleaseRepository.deleteAll();
|
||||
achievementMetaRepository.deleteAll();
|
||||
userGameRepository.deleteAll();
|
||||
gameTimeToBeatRepository.deleteAll();
|
||||
}
|
||||
|
||||
private void mine(long appid, GameStatus status, Integer normallyMinutes) {
|
||||
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
|
||||
save(USER_ID, appid, status, 0);
|
||||
if (normallyMinutes != null) {
|
||||
gameTimeToBeatRepository.save(new GameTimeToBeat(appid, null, normallyMinutes, null, Instant.now()));
|
||||
}
|
||||
}
|
||||
|
||||
private void friendGame(long userId, long appid, GameStatus status, int playtime) {
|
||||
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
|
||||
save(userId, appid, status, playtime);
|
||||
}
|
||||
|
||||
private void save(long userId, long appid, GameStatus status, int playtime) {
|
||||
UserGame game = new UserGame(userId, appid, playtime, Instant.now(), Instant.now());
|
||||
game.setStatus(status);
|
||||
userGameRepository.save(game);
|
||||
}
|
||||
|
||||
/** A public friend the caller follows; null sections show everything. */
|
||||
private void friend(long userId, String name, String[] sections) {
|
||||
jdbc.update("insert into account_name (user_id, display_name) values (?, ?)", userId, name);
|
||||
jdbc.update("insert into public_user (user_id, sections) values (?, ?)", userId, sections);
|
||||
jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", USER_ID, userId);
|
||||
}
|
||||
|
||||
private ResultActions pick(String... params) throws Exception {
|
||||
var request = get("/api/library/picker").param("category", "FRIENDS");
|
||||
for (int i = 0; i < params.length; i += 2) {
|
||||
request.param(params[i], params[i + 1]);
|
||||
}
|
||||
return mockMvc.perform(request).andExpect(status().isOk());
|
||||
}
|
||||
|
||||
private void expectNoRow() throws Exception {
|
||||
pick().andExpect(jsonPath("$.categories", hasSize(0)));
|
||||
}
|
||||
|
||||
private static String reason(long appid, String field) {
|
||||
return ROW + ".games[?(@.appid == " + appid + ")].reason." + field;
|
||||
}
|
||||
|
||||
@Test
|
||||
void friends_FinishedAndPlayedGames_WithReason() throws Exception {
|
||||
mine(10L, GameStatus.BACKLOG, null);
|
||||
mine(20L, GameStatus.BACKLOG, null);
|
||||
mine(30L, GameStatus.BACKLOG, null);
|
||||
friend(MAX, "Max", null);
|
||||
friend(ANNA, "Anna", null);
|
||||
friend(TOM, "Tom", null);
|
||||
friendGame(MAX, 10L, GameStatus.FINISHED, 100);
|
||||
friendGame(ANNA, 20L, GameStatus.PLAYING, 2400);
|
||||
friendGame(TOM, 30L, GameStatus.PLAYING, 60);
|
||||
|
||||
pick().andExpect(jsonPath(ROW + ".games[*].appid", contains(10, 20)))
|
||||
.andExpect(jsonPath(reason(10, "source"), contains("FRIENDS")))
|
||||
.andExpect(jsonPath(reason(10, "friendName"), contains("Max")))
|
||||
.andExpect(jsonPath(reason(10, "friendFinished"), contains(true)))
|
||||
.andExpect(jsonPath(reason(20, "friendName"), contains("Anna")))
|
||||
.andExpect(jsonPath(reason(20, "friendFinished"), contains(false)))
|
||||
.andExpect(jsonPath(reason(20, "friendPlaytimeMinutes"), contains(2400)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void friends_UsesTheCurrentDisplayName() throws Exception {
|
||||
mine(10L, GameStatus.BACKLOG, null);
|
||||
friend(MAX, "Max", null);
|
||||
friendGame(MAX, 10L, GameStatus.FINISHED, 100);
|
||||
jdbc.update("update account_name set display_name = 'Maximilian' where user_id = ?", MAX);
|
||||
|
||||
pick().andExpect(jsonPath(reason(10, "friendName"), contains("Maximilian")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void friends_NeverSendsAUserId() throws Exception {
|
||||
mine(10L, GameStatus.BACKLOG, null);
|
||||
friend(MAX, "Max", null);
|
||||
friendGame(MAX, 10L, GameStatus.FINISHED, 100);
|
||||
|
||||
String body = pick().andReturn().getResponse().getContentAsString();
|
||||
org.assertj.core.api.Assertions.assertThat(body).doesNotContain(String.valueOf(MAX)).doesNotContain("friendUserId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void friends_OneGamePerFriend_StrongestFriendPerGame() throws Exception {
|
||||
mine(10L, GameStatus.BACKLOG, null);
|
||||
mine(20L, GameStatus.BACKLOG, null);
|
||||
mine(30L, GameStatus.BACKLOG, null);
|
||||
mine(40L, GameStatus.BACKLOG, null);
|
||||
friend(MAX, "Max", null);
|
||||
friend(ANNA, "Anna", null);
|
||||
friendGame(MAX, 10L, GameStatus.FINISHED, 900);
|
||||
friendGame(MAX, 20L, GameStatus.FINISHED, 100);
|
||||
friendGame(MAX, 30L, GameStatus.FINISHED, 50);
|
||||
friendGame(ANNA, 10L, GameStatus.PLAYING, 600);
|
||||
friendGame(ANNA, 40L, GameStatus.PLAYING, 400);
|
||||
|
||||
pick().andExpect(jsonPath(ROW + ".games[*].appid", contains(10, 40)))
|
||||
.andExpect(jsonPath(ROW + ".games[*].reason.friendName", contains("Max", "Anna")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void friends_ExactlyFiveHours_Counts() throws Exception {
|
||||
mine(10L, GameStatus.BACKLOG, null);
|
||||
mine(20L, GameStatus.BACKLOG, null);
|
||||
friend(MAX, "Max", null);
|
||||
friend(ANNA, "Anna", null);
|
||||
friendGame(MAX, 10L, GameStatus.PLAYING, 300);
|
||||
friendGame(ANNA, 20L, GameStatus.PLAYING, 299);
|
||||
|
||||
pick().andExpect(jsonPath(ROW + ".games[*].appid", contains(10)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void friends_Reroll_ExcludesShownAndEndsWhenNoneLeft() throws Exception {
|
||||
mine(10L, GameStatus.BACKLOG, null);
|
||||
mine(20L, GameStatus.BACKLOG, null);
|
||||
friend(MAX, "Max", null);
|
||||
friendGame(MAX, 10L, GameStatus.FINISHED, 900);
|
||||
friendGame(MAX, 20L, GameStatus.FINISHED, 100);
|
||||
|
||||
pick().andExpect(jsonPath(ROW + ".games[*].appid", contains(10)));
|
||||
pick("exclude", "10").andExpect(jsonPath(ROW + ".games[*].appid", contains(20)));
|
||||
pick("exclude", "10,20").andExpect(jsonPath("$.categories", hasSize(0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void friends_RowSitsBetweenStudioAndLongest() throws Exception {
|
||||
mine(10L, GameStatus.BACKLOG, null);
|
||||
friend(MAX, "Max", null);
|
||||
friendGame(MAX, 10L, GameStatus.FINISHED, 100);
|
||||
|
||||
mockMvc.perform(get("/api/library/picker")).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.categories[*].source", contains("NEXT_UP", "FRIENDS", "LONGEST", "RANDOM")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void friends_NoFollows_LeavesOutTheRow() throws Exception {
|
||||
mine(10L, GameStatus.BACKLOG, null);
|
||||
|
||||
mockMvc.perform(get("/api/library/picker")).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.categories[*].source", not(hasItem("FRIENDS"))));
|
||||
}
|
||||
|
||||
private enum Hidden { NOT_FOLLOWED, NOT_PUBLIC, BLOCKED, DELETED, ONLY_FOLLOWS_ME }
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(Hidden.class)
|
||||
void friends_NeverShows(Hidden case_) throws Exception {
|
||||
mine(10L, GameStatus.BACKLOG, null);
|
||||
jdbc.update("insert into account_name (user_id, display_name) values (?, 'Max')", MAX);
|
||||
if (case_ != Hidden.NOT_PUBLIC) {
|
||||
jdbc.update("insert into public_user (user_id) values (?)", MAX);
|
||||
}
|
||||
switch (case_) {
|
||||
case NOT_FOLLOWED, NOT_PUBLIC -> { }
|
||||
case BLOCKED -> jdbc.update("insert into blocked_user (user_id) values (?)", MAX);
|
||||
case DELETED -> jdbc.update("insert into deleted_user (user_id) values (?)", MAX);
|
||||
case ONLY_FOLLOWS_ME -> jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", MAX, USER_ID);
|
||||
}
|
||||
if (case_ != Hidden.NOT_FOLLOWED && case_ != Hidden.ONLY_FOLLOWS_ME) {
|
||||
jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", USER_ID, MAX);
|
||||
}
|
||||
friendGame(MAX, 10L, GameStatus.FINISHED, 2400);
|
||||
|
||||
expectNoRow();
|
||||
jdbc.update("delete from follow where user_id = ?", MAX);
|
||||
}
|
||||
|
||||
@Test
|
||||
void friends_HiddenSections_ShownOnlyWithSummaryOrStatus() throws Exception {
|
||||
for (long appid = 10; appid <= 60; appid += 10) {
|
||||
mine(appid, GameStatus.BACKLOG, null);
|
||||
}
|
||||
friend(MAX, "Stats", new String[]{"STATS"});
|
||||
friend(ANNA, "Summary", new String[]{"SUMMARY"});
|
||||
friend(TOM, "FinishedOnly", new String[]{"FINISHED"});
|
||||
friend(950_004L, "All", null);
|
||||
friend(950_005L, "Status", new String[]{"STATUS"});
|
||||
friend(950_006L, "PlayingFinished", new String[]{"PLAYING", "FINISHED"});
|
||||
friendGame(MAX, 10L, GameStatus.FINISHED, 2400);
|
||||
friendGame(ANNA, 20L, GameStatus.FINISHED, 2400);
|
||||
friendGame(TOM, 30L, GameStatus.FINISHED, 2400);
|
||||
friendGame(950_004L, 40L, GameStatus.FINISHED, 2400);
|
||||
friendGame(950_005L, 50L, GameStatus.FINISHED, 2400);
|
||||
friendGame(950_006L, 60L, GameStatus.FINISHED, 2400);
|
||||
|
||||
pick().andExpect(jsonPath(ROW + ".games[*].appid", containsInAnyOrder(20, 40, 50)));
|
||||
pick("exclude", "20,40,50").andExpect(jsonPath("$.categories", hasSize(0)));
|
||||
for (long appid : new long[]{20, 40, 50}) {
|
||||
pick().andExpect(jsonPath(reason(appid, "friendFinished"), contains(true)))
|
||||
.andExpect(jsonPath(reason(appid, "friendPlaytimeMinutes"), contains(2400)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void friends_FinishedOnlyProfile_NeverShowsAFinishTheProfileDoesNotList() throws Exception {
|
||||
friend(MAX, "Max", new String[]{"FINISHED"});
|
||||
for (long appid = 101; appid <= 113; appid++) {
|
||||
friendGame(MAX, appid, GameStatus.FINISHED, 2400);
|
||||
}
|
||||
mine(113L, GameStatus.BACKLOG, null);
|
||||
|
||||
expectNoRow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void friends_DroppedGame_NeverCounts() throws Exception {
|
||||
mine(10L, GameStatus.BACKLOG, null);
|
||||
friend(MAX, "Max", null);
|
||||
friendGame(MAX, 10L, GameStatus.DROPPED, 6000);
|
||||
|
||||
expectNoRow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void friends_HiddenOrRemovedGameOfFriend_NeverCounts() throws Exception {
|
||||
mine(10L, GameStatus.BACKLOG, null);
|
||||
mine(20L, GameStatus.BACKLOG, null);
|
||||
friend(MAX, "Max", null);
|
||||
friendGame(MAX, 10L, GameStatus.FINISHED, 100);
|
||||
friendGame(MAX, 20L, GameStatus.FINISHED, 100);
|
||||
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = 10", MAX);
|
||||
jdbc.update("update user_game set removed_at = now() where user_id = ? and appid = 20", MAX);
|
||||
|
||||
expectNoRow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void friends_OnlyMyBacklog() throws Exception {
|
||||
mine(10L, GameStatus.PLAYING, null);
|
||||
mine(20L, GameStatus.BACKLOG, null);
|
||||
mine(30L, GameStatus.BACKLOG, null);
|
||||
friend(MAX, "Max", null);
|
||||
friendGame(MAX, 10L, GameStatus.FINISHED, 100);
|
||||
friendGame(MAX, 20L, GameStatus.FINISHED, 100);
|
||||
friendGame(MAX, 40L, GameStatus.FINISHED, 100);
|
||||
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = 20", USER_ID);
|
||||
|
||||
expectNoRow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void friends_BudgetAndReleasedOnly() throws Exception {
|
||||
mine(10L, GameStatus.BACKLOG, 3000);
|
||||
mine(20L, GameStatus.BACKLOG, 60);
|
||||
mine(30L, GameStatus.BACKLOG, 60);
|
||||
gameReleaseRepository.upsert(20L, LocalDate.now().plusDays(1), false, Instant.now());
|
||||
friend(MAX, "Max", null);
|
||||
friend(ANNA, "Anna", null);
|
||||
friend(TOM, "Tom", null);
|
||||
friendGame(MAX, 10L, GameStatus.FINISHED, 100);
|
||||
// No playtime: a game anybody played is never "coming" (coming_game view).
|
||||
friendGame(ANNA, 20L, GameStatus.FINISHED, 0);
|
||||
friendGame(TOM, 30L, GameStatus.FINISHED, 100);
|
||||
|
||||
pick("maxMinutes", "300").andExpect(jsonPath(ROW + ".games[*].appid", contains(30)))
|
||||
.andExpect(jsonPath(ROW + ".games", hasSize(1)));
|
||||
}
|
||||
}
|
||||
@@ -241,7 +241,7 @@ class ManualGameControllerTest extends AbstractMockedAdaptersTest {
|
||||
|
||||
@Test
|
||||
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,
|
||||
ZELDA.name(), 2023, null, List.of("Switch"), 2_000_000L))));
|
||||
|
||||
@@ -404,7 +404,7 @@ class ManualGameControllerTest extends AbstractMockedAdaptersTest {
|
||||
void add_MergedGameIgdbLaterFindsOnSteam_IsStillInTheLibrary() throws Exception {
|
||||
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);
|
||||
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(),
|
||||
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;
|
||||
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.GameTimeToBeat;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
@@ -67,7 +68,7 @@ class BacklogSnapshotRepositoryTest extends AbstractPersistenceTest {
|
||||
removed.setRemovedAt(Instant.now());
|
||||
userGameRepository.saveAllAndFlush(List.of(
|
||||
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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
@@ -60,7 +61,7 @@ class StatusChangeLogTest extends AbstractPersistenceTest {
|
||||
|
||||
@Test
|
||||
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");
|
||||
}
|
||||
@@ -101,7 +102,7 @@ class StatusChangeLogTest extends AbstractPersistenceTest {
|
||||
|
||||
@Test
|
||||
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();
|
||||
|
||||
userGameRepository.delete(game(-1942L));
|
||||
|
||||
@@ -117,7 +117,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
@Test
|
||||
void softDeleteMissingSince_KeepsGamesAddedByHand() {
|
||||
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 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, 700L)).orElseThrow().setRemovedAt(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();
|
||||
|
||||
assertEquals(Set.of(220L, 620L),
|
||||
@@ -164,7 +164,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
@Test
|
||||
void findAchievementProgress_LeavesOutGamesAddedByHand() {
|
||||
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));
|
||||
}
|
||||
@@ -173,7 +173,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
void findRandomBacklogGameAndPicker_ExcludeNothingStillFindTheGameWithKeyMinusOne() {
|
||||
userGameRepository.deleteAll();
|
||||
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(1, userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 3).size());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
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.GameTimeToBeat;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
@@ -281,10 +282,10 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
|
||||
Instant now = Instant.now();
|
||||
userGameRepository.saveAllAndFlush(List.of(
|
||||
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, 8002L, "PS5", GameStatus.BACKLOG, 20, now),
|
||||
UserGame.manual(USER_ID, 8003L, "ps5 ", GameStatus.BACKLOG, 30, now),
|
||||
UserGame.manual(USER_ID, 8004L, "Switch", GameStatus.BACKLOG, 5, now)));
|
||||
UserGame.manual(USER_ID, 8001L, GameSource.MANUAL, "PS5", GameStatus.BACKLOG, 10, now),
|
||||
UserGame.manual(USER_ID, 8002L, GameSource.MANUAL, "PS5", GameStatus.BACKLOG, 20, now),
|
||||
UserGame.manual(USER_ID, 8003L, GameSource.MANUAL, "ps5 ", GameStatus.BACKLOG, 30, now),
|
||||
UserGame.manual(USER_ID, 8004L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 5, now)));
|
||||
seedHiddenAndRemoved();
|
||||
|
||||
assertThat(repository.gamesPerPlatform(USER_ID)).containsExactly(
|
||||
@@ -332,7 +333,7 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@Test
|
||||
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);
|
||||
|
||||
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.GameAchievementSchemaMeta;
|
||||
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.UserGame;
|
||||
import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||
@@ -94,7 +95,7 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
|
||||
|
||||
@Test
|
||||
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))));
|
||||
|
||||
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
|
||||
@@ -120,7 +121,7 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
|
||||
|
||||
@Test
|
||||
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.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.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.InjectMocks;
|
||||
@@ -234,9 +235,10 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
assertEquals(expected, saved.getValue().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_GameAddedByHandIsNowOnSteam_BecomesASteamGameAndKeepsTheBacklog() {
|
||||
UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.FINISHED, 60, Instant.EPOCH);
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = GameSource.class, names = {"MANUAL", "PSN"})
|
||||
void runSync_GameAddedByHandOrFromPsnIsNowOnSteam_BecomesASteamGameAndKeepsTheBacklog(GameSource source) {
|
||||
UserGame manual = UserGame.manual(USER_ID, 220L, source, "PS5", GameStatus.FINISHED, 60, Instant.EPOCH);
|
||||
manual.setRating(5);
|
||||
manual.setNotes("Played on the couch");
|
||||
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual));
|
||||
@@ -254,7 +256,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
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));
|
||||
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 alreadyRemoved = new UserGame(USER_ID, 400L, 10, Instant.EPOCH, 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(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);
|
||||
@@ -724,7 +726,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
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));
|
||||
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;
|
||||
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.StatGroup;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
@@ -61,7 +62,7 @@ class UserStatsServiceTrendTest extends AbstractPersistenceTest {
|
||||
@Test
|
||||
void thisMonth_FirstSteamImportAfterManualGames_IsNoAdd() {
|
||||
// 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");
|
||||
LocalDate today = LocalDate.now(ZoneId.of("Europe/Berlin"));
|
||||
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;
|
||||
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
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()),
|
||||
new SteamGame(8002L, "B", null, Instant.now()), new SteamGame(8003L, "C", null, Instant.now())));
|
||||
userGameRepository.saveAllAndFlush(List.of(
|
||||
UserGame.manual(1L, 8001L, "ps5", GameStatus.BACKLOG, 10, Instant.now()),
|
||||
UserGame.manual(2L, 8002L, "PS5", GameStatus.BACKLOG, 20, Instant.now()),
|
||||
UserGame.manual(3L, 8003L, "Switch", GameStatus.BACKLOG, 20, Instant.now())));
|
||||
UserGame.manual(1L, 8001L, GameSource.MANUAL, "ps5", GameStatus.BACKLOG, 10, Instant.now()),
|
||||
UserGame.manual(2L, 8002L, GameSource.MANUAL, "PS5", 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)");
|
||||
|
||||
List<Map<String, Object>> rows = statsRepository.gamesPerPlatform();
|
||||
@@ -154,8 +155,8 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
|
||||
new SteamGame(8003L, "C", null, Instant.now())));
|
||||
userGameRepository.saveAllAndFlush(List.of(
|
||||
new UserGame(5L, 220L, 100, Instant.now(), Instant.now()),
|
||||
UserGame.manual(5L, 8001L, "ps5", GameStatus.BACKLOG, 10, Instant.now()),
|
||||
UserGame.manual(3L, 8003L, "ps5", GameStatus.BACKLOG, 20, Instant.now())));
|
||||
UserGame.manual(5L, 8001L, GameSource.MANUAL, "ps5", GameStatus.BACKLOG, 10, 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("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 5 AND appid = 220");
|
||||
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) {
|
||||
registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(TEST_KEYS.getPublic().getEncoded()));
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package core_service.shared.test;
|
||||
|
||||
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.steam_adapter.service.SteamApiService;
|
||||
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.
|
||||
*/
|
||||
public abstract class AbstractMockedAdaptersTest extends AbstractIntegrationTest {
|
||||
@@ -19,4 +21,10 @@ public abstract class AbstractMockedAdaptersTest extends AbstractIntegrationTest
|
||||
|
||||
@MockitoBean
|
||||
protected SpeedrunApiService speedrunApiService;
|
||||
|
||||
@MockitoBean
|
||||
protected PsnAuthService psnAuthService;
|
||||
|
||||
@MockitoBean
|
||||
protected PsnApiService psnApiService;
|
||||
}
|
||||
Reference in new issue
Block a user