From 7ac887ed5aca5d3dbaa0f6baee6205776db8a899 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 7 Oct 2026 13:30:48 +0200 Subject: [PATCH] feat(psn): link PlayStation account and import played games MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NPSSO is used once to get a refresh token; only that token is stored, sealed with AES-GCM (SecretBox, key APP_PSN_TOKEN_KEY; no key = feature off). One-time import of played PS4/PS5 titles through the import pipeline as source PSN. Unlink keeps the games. One PSN call per user at a time. IGDB search now sends UTF-8, so non-ASCII names like "Yōtei" match again. --- .../igdb-game-search-psn-mega-man-legacy.json | 17 ++ .../igdb-game-search-psn-tsushima.json | 21 ++ .../__files/igdb-game-search-psn-yotei.json | 17 ++ local/wiremock/__files/psn-gamelist.json | 81 +++++ local/wiremock/__files/psn-token.json | 9 + .../igdb-game-search-psn-mega-man-legacy.json | 19 ++ .../igdb-game-search-psn-tsushima.json | 19 ++ .../mappings/igdb-game-search-psn-yotei.json | 19 ++ .../mappings/psn-authorize-invalid.json | 13 + local/wiremock/mappings/psn-authorize.json | 18 ++ local/wiremock/mappings/psn-gamelist.json | 13 + local/wiremock/mappings/psn-token.json | 13 + readme.md | 49 +++- .../repo/AccountCleanupRepository.java | 8 +- .../igdb_adapter/service/IgdbApiService.java | 4 +- .../library/api/PsnAccountController.java | 90 ++++++ .../library/api/dto/ImportRequest.java | 5 + .../library/api/dto/PsnAccountDto.java | 14 + .../library/api/dto/PsnLinkRequest.java | 18 ++ .../modules/library/domain/GameImport.java | 5 +- .../modules/library/domain/GameSource.java | 7 +- .../modules/library/domain/PsnAccount.java | 16 + .../modules/library/domain/UserGame.java | 6 +- .../library/repo/GameImportRepository.java | 11 +- .../library/repo/PsnAccountRepository.java | 58 ++++ .../library/repo/UserGameRepository.java | 2 +- .../library/service/GameDetailService.java | 2 +- .../library/service/GameImportService.java | 13 +- .../library/service/GameImportWorker.java | 5 +- .../library/service/LibrarySyncWorker.java | 2 +- .../library/service/ManualGameService.java | 17 +- .../library/service/PsnAccountService.java | 206 +++++++++++++ .../modules/library/service/PsnTitles.java | 65 +++++ .../api/dto/response/PsnGameListResponse.java | 19 ++ .../api/dto/response/PsnTokenResponse.java | 21 ++ .../psn_adapter/config/PsnClientConfig.java | 45 +++ .../psn_adapter/service/PsnApiService.java | 86 ++++++ .../psn_adapter/service/PsnAuthService.java | 146 +++++++++ .../shared/security/SecretBox.java | 84 ++++++ src/main/resources/application-local.yml | 5 + src/main/resources/application.yml | 17 +- .../db/migration/V38__Psn_Account.sql | 8 + .../architecture/ArchitectureTest.java | 11 +- .../configuration/LocalMocksParseTest.java | 15 +- .../repo/AccountCleanupRepositoryTest.java | 13 +- .../service/IgdbApiServiceTest.java | 14 + .../library/api/GameImportControllerTest.java | 7 +- .../modules/library/api/GameLinkFlowTest.java | 3 +- .../library/api/GameMergeFlowTest.java | 2 +- .../library/api/GameMergeUndoFlowTest.java | 2 +- .../api/LibraryControllerComingTest.java | 3 +- .../LibraryControllerFinishedHintTest.java | 3 +- .../api/LibraryControllerHiddenTest.java | 3 +- .../library/api/ManualGameControllerTest.java | 4 +- .../library/api/PsnAccountFlowTest.java | 276 ++++++++++++++++++ .../repo/BacklogSnapshotRepositoryTest.java | 3 +- .../repo/PsnAccountRepositoryTest.java | 65 +++++ .../library/repo/StatusChangeLogTest.java | 5 +- .../library/repo/UserGameRepositoryTest.java | 8 +- .../library/repo/UserStatsRepositoryTest.java | 11 +- .../service/GameDetailServiceTest.java | 5 +- .../service/LibrarySyncWorkerTest.java | 14 +- .../service/PsnAccountServiceTest.java | 272 +++++++++++++++++ .../library/service/PsnTitlesTest.java | 83 ++++++ .../service/UserStatsServiceTrendTest.java | 3 +- .../config/PsnClientConfigTest.java | 16 + .../service/PsnApiServiceTest.java | 80 +++++ .../service/PsnAuthServiceTest.java | 157 ++++++++++ .../stats/repo/StatsRepositoryTest.java | 11 +- .../shared/security/SecretBoxTest.java | 91 ++++++ .../shared/test/AbstractIntegrationTest.java | 2 + .../test/AbstractMockedAdaptersTest.java | 10 +- 72 files changed, 2407 insertions(+), 78 deletions(-) create mode 100644 local/wiremock/__files/igdb-game-search-psn-mega-man-legacy.json create mode 100644 local/wiremock/__files/igdb-game-search-psn-tsushima.json create mode 100644 local/wiremock/__files/igdb-game-search-psn-yotei.json create mode 100644 local/wiremock/__files/psn-gamelist.json create mode 100644 local/wiremock/__files/psn-token.json create mode 100644 local/wiremock/mappings/igdb-game-search-psn-mega-man-legacy.json create mode 100644 local/wiremock/mappings/igdb-game-search-psn-tsushima.json create mode 100644 local/wiremock/mappings/igdb-game-search-psn-yotei.json create mode 100644 local/wiremock/mappings/psn-authorize-invalid.json create mode 100644 local/wiremock/mappings/psn-authorize.json create mode 100644 local/wiremock/mappings/psn-gamelist.json create mode 100644 local/wiremock/mappings/psn-token.json create mode 100644 src/main/java/core_service/modules/library/api/PsnAccountController.java create mode 100644 src/main/java/core_service/modules/library/api/dto/PsnAccountDto.java create mode 100644 src/main/java/core_service/modules/library/api/dto/PsnLinkRequest.java create mode 100644 src/main/java/core_service/modules/library/domain/PsnAccount.java create mode 100644 src/main/java/core_service/modules/library/repo/PsnAccountRepository.java create mode 100644 src/main/java/core_service/modules/library/service/PsnAccountService.java create mode 100644 src/main/java/core_service/modules/library/service/PsnTitles.java create mode 100644 src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnGameListResponse.java create mode 100644 src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnTokenResponse.java create mode 100644 src/main/java/core_service/modules/psn_adapter/config/PsnClientConfig.java create mode 100644 src/main/java/core_service/modules/psn_adapter/service/PsnApiService.java create mode 100644 src/main/java/core_service/modules/psn_adapter/service/PsnAuthService.java create mode 100644 src/main/java/core_service/shared/security/SecretBox.java create mode 100644 src/main/resources/db/migration/V38__Psn_Account.sql create mode 100644 src/test/java/core_service/modules/library/api/PsnAccountFlowTest.java create mode 100644 src/test/java/core_service/modules/library/repo/PsnAccountRepositoryTest.java create mode 100644 src/test/java/core_service/modules/library/service/PsnAccountServiceTest.java create mode 100644 src/test/java/core_service/modules/library/service/PsnTitlesTest.java create mode 100644 src/test/java/core_service/modules/psn_adapter/config/PsnClientConfigTest.java create mode 100644 src/test/java/core_service/modules/psn_adapter/service/PsnApiServiceTest.java create mode 100644 src/test/java/core_service/modules/psn_adapter/service/PsnAuthServiceTest.java create mode 100644 src/test/java/core_service/shared/security/SecretBoxTest.java diff --git a/local/wiremock/__files/igdb-game-search-psn-mega-man-legacy.json b/local/wiremock/__files/igdb-game-search-psn-mega-man-legacy.json new file mode 100644 index 0000000..742e998 --- /dev/null +++ b/local/wiremock/__files/igdb-game-search-psn-mega-man-legacy.json @@ -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 + } +] diff --git a/local/wiremock/__files/igdb-game-search-psn-tsushima.json b/local/wiremock/__files/igdb-game-search-psn-tsushima.json new file mode 100644 index 0000000..d972fce --- /dev/null +++ b/local/wiremock/__files/igdb-game-search-psn-tsushima.json @@ -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 + } +] diff --git a/local/wiremock/__files/igdb-game-search-psn-yotei.json b/local/wiremock/__files/igdb-game-search-psn-yotei.json new file mode 100644 index 0000000..658b5ae --- /dev/null +++ b/local/wiremock/__files/igdb-game-search-psn-yotei.json @@ -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 + } +] diff --git a/local/wiremock/__files/psn-gamelist.json b/local/wiremock/__files/psn-gamelist.json new file mode 100644 index 0000000..8cbd421 --- /dev/null +++ b/local/wiremock/__files/psn-gamelist.json @@ -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 +} diff --git a/local/wiremock/__files/psn-token.json b/local/wiremock/__files/psn-token.json new file mode 100644 index 0000000..f11f07a --- /dev/null +++ b/local/wiremock/__files/psn-token.json @@ -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 +} diff --git a/local/wiremock/mappings/igdb-game-search-psn-mega-man-legacy.json b/local/wiremock/mappings/igdb-game-search-psn-mega-man-legacy.json new file mode 100644 index 0000000..4c86840 --- /dev/null +++ b/local/wiremock/mappings/igdb-game-search-psn-mega-man-legacy.json @@ -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 +} diff --git a/local/wiremock/mappings/igdb-game-search-psn-tsushima.json b/local/wiremock/mappings/igdb-game-search-psn-tsushima.json new file mode 100644 index 0000000..8cf6932 --- /dev/null +++ b/local/wiremock/mappings/igdb-game-search-psn-tsushima.json @@ -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 +} diff --git a/local/wiremock/mappings/igdb-game-search-psn-yotei.json b/local/wiremock/mappings/igdb-game-search-psn-yotei.json new file mode 100644 index 0000000..92d3598 --- /dev/null +++ b/local/wiremock/mappings/igdb-game-search-psn-yotei.json @@ -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 +} diff --git a/local/wiremock/mappings/psn-authorize-invalid.json b/local/wiremock/mappings/psn-authorize-invalid.json new file mode 100644 index 0000000..de5931f --- /dev/null +++ b/local/wiremock/mappings/psn-authorize-invalid.json @@ -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 +} diff --git a/local/wiremock/mappings/psn-authorize.json b/local/wiremock/mappings/psn-authorize.json new file mode 100644 index 0000000..b299bf2 --- /dev/null +++ b/local/wiremock/mappings/psn-authorize.json @@ -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 +} diff --git a/local/wiremock/mappings/psn-gamelist.json b/local/wiremock/mappings/psn-gamelist.json new file mode 100644 index 0000000..c494db9 --- /dev/null +++ b/local/wiremock/mappings/psn-gamelist.json @@ -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" + } +} diff --git a/local/wiremock/mappings/psn-token.json b/local/wiremock/mappings/psn-token.json new file mode 100644 index 0000000..469d99b --- /dev/null +++ b/local/wiremock/mappings/psn-token.json @@ -0,0 +1,13 @@ +{ + "request": { + "method": "POST", + "urlPath": "/psn/auth/token" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "psn-token.json" + } +} diff --git a/readme.md b/readme.md index a9c8687..3c86571 100644 --- a/readme.md +++ b/readme.md @@ -136,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 @@ -208,6 +211,9 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `GET` | `/api/library/imports` (`{imports}`, the last 20), `/api/library/imports/{id}` (status, progress counts, `ahead` while it waits in a queue, `attempts`; the rows with their candidates while `READY`) | JWT | | `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 | @@ -266,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 @@ -278,6 +286,7 @@ flowchart LR Igdb --> IgdbApi["IGDB API"] Igdb -->|"OAuth token"| Twitch["Twitch"] Speedrun --> SpeedrunApi["speedrun.com API"] + Psn -->|"NPSSO sign-in, played titles"| PsnApi["PlayStation API (unofficial)"] LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user"| Db[("PostgreSQL tbp_core")] 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 @@ -288,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` | @@ -297,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`. @@ -314,8 +324,8 @@ a column of `user_game` drops this view first, then `listed_user_game`. Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo` (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 @@ -504,6 +514,7 @@ user ids, which removes the rows of users Auth does not know (refused for more t The tombstone also ends the user's follows in both directions without a note and removes the user's `public_user` and `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 @@ -528,7 +539,7 @@ A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games `user_game.source` (`GameSource`) says who owns the row. The sync never changes or removes a `MANUAL` row, with 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 @@ -570,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`) @@ -607,6 +642,9 @@ What the `local` profile does: - The browser still loads game pictures from the Steam and IGDB image servers; offline they stay gray. - "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. @@ -637,6 +675,7 @@ Configuration comes from environment variables: | `JWT_PUBLIC_KEY` | Ed25519 public key (Base64 of the X.509 DER bytes), used until the first JWKS load. Set this, `JWT_JWKS_URL`, or both | | `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 diff --git a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java index 6913cf1..c922d0c 100644 --- a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java +++ b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java @@ -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 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 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 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)); } /** diff --git a/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java b/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java index 0c509f1..7b368ed 100644 --- a/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java +++ b/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java @@ -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() diff --git a/src/main/java/core_service/modules/library/api/PsnAccountController.java b/src/main/java/core_service/modules/library/api/PsnAccountController.java new file mode 100644 index 0000000..c7eee8d --- /dev/null +++ b/src/main/java/core_service/modules/library/api/PsnAccountController.java @@ -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 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)); + } +} diff --git a/src/main/java/core_service/modules/library/api/dto/ImportRequest.java b/src/main/java/core_service/modules/library/api/dto/ImportRequest.java index 3c2a948..753ec2b 100644 --- a/src/main/java/core_service/modules/library/api/dto/ImportRequest.java +++ b/src/main/java/core_service/modules/library/api/dto/ImportRequest.java @@ -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 */ diff --git a/src/main/java/core_service/modules/library/api/dto/PsnAccountDto.java b/src/main/java/core_service/modules/library/api/dto/PsnAccountDto.java new file mode 100644 index 0000000..fabee31 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/PsnAccountDto.java @@ -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 +) {} diff --git a/src/main/java/core_service/modules/library/api/dto/PsnLinkRequest.java b/src/main/java/core_service/modules/library/api/dto/PsnLinkRequest.java new file mode 100644 index 0000000..fa68b75 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/PsnLinkRequest.java @@ -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[]"; + } +} diff --git a/src/main/java/core_service/modules/library/domain/GameImport.java b/src/main/java/core_service/modules/library/domain/GameImport.java index cd11683..7d25b43 100644 --- a/src/main/java/core_service/modules/library/domain/GameImport.java +++ b/src/main/java/core_service/modules/library/domain/GameImport.java @@ -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 } diff --git a/src/main/java/core_service/modules/library/domain/GameSource.java b/src/main/java/core_service/modules/library/domain/GameSource.java index 2c449a0..4ae9770 100644 --- a/src/main/java/core_service/modules/library/domain/GameSource.java +++ b/src/main/java/core_service/modules/library/domain/GameSource.java @@ -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 } diff --git a/src/main/java/core_service/modules/library/domain/PsnAccount.java b/src/main/java/core_service/modules/library/domain/PsnAccount.java new file mode 100644 index 0000000..aec7b45 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/PsnAccount.java @@ -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 + "]"; + } +} diff --git a/src/main/java/core_service/modules/library/domain/UserGame.java b/src/main/java/core_service/modules/library/domain/UserGame.java index 0df5fae..3602d1f 100644 --- a/src/main/java/core_service/modules/library/domain/UserGame.java +++ b/src/main/java/core_service/modules/library/domain/UserGame.java @@ -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; diff --git a/src/main/java/core_service/modules/library/repo/GameImportRepository.java b/src/main/java/core_service/modules/library/repo/GameImportRepository.java index d985879..391c0a9 100644 --- a/src/main/java/core_service/modules/library/repo/GameImportRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameImportRepository.java @@ -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)); } diff --git a/src/main/java/core_service/modules/library/repo/PsnAccountRepository.java b/src/main/java/core_service/modules/library/repo/PsnAccountRepository.java new file mode 100644 index 0000000..a6e33fa --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/PsnAccountRepository.java @@ -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 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)); + } +} diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index 5874bec..92566c5 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -49,7 +49,7 @@ public interface UserGameRepository extends JpaRepository List findActiveAppids(@Param("userId") Long userId, @Param("appids") Collection 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 findActiveManualAppids(@Param("userId") Long userId); @Query("select ug.appid from UserGame ug where " + LISTED + diff --git a/src/main/java/core_service/modules/library/service/GameDetailService.java b/src/main/java/core_service/modules/library/service/GameDetailService.java index 2cb9c75..8981385 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -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)); diff --git a/src/main/java/core_service/modules/library/service/GameImportService.java b/src/main/java/core_service/modules/library/service/GameImportService.java index 81d1001..eb0decb 100644 --- a/src/main/java/core_service/modules/library/service/GameImportService.java +++ b/src/main/java/core_service/modules/library/service/GameImportService.java @@ -56,14 +56,21 @@ public class GameImportService { } public CreateResult create(Long userId, GameImport.Source source, List rows) { - if (!igdbApiService.isEnabled()) { - return new CreateResult.SearchUnavailable(); - } List 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 lines) { + if (!igdbApiService.isEnabled()) { + return new CreateResult.SearchUnavailable(); + } long id; try { id = transaction.execute(tx -> importRepository.create(userId, source, lines)); diff --git a/src/main/java/core_service/modules/library/service/GameImportWorker.java b/src/main/java/core_service/modules/library/service/GameImportWorker.java index 4224cea..d794a44 100644 --- a/src/main/java/core_service/modules/library/service/GameImportWorker.java +++ b/src/main/java/core_service/modules/library/service/GameImportWorker.java @@ -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 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 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); } diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index 94fd872..0d9e93e 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -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) { diff --git a/src/main/java/core_service/modules/library/service/ManualGameService.java b/src/main/java/core_service/modules/library/service/ManualGameService.java index 056d49d..fd3d875 100644 --- a/src/main/java/core_service/modules/library/service/ManualGameService.java +++ b/src/main/java/core_service/modules/library/service/ManualGameService.java @@ -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 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()); diff --git a/src/main/java/core_service/modules/library/service/PsnAccountService.java b/src/main/java/core_service/modules/library/service/PsnAccountService.java new file mode 100644 index 0000000..c9485f1 --- /dev/null +++ b/src/main/java/core_service/modules/library/service/PsnAccountService.java @@ -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 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 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 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 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 titles; + try { + Optional 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 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() : ""); + } +} diff --git a/src/main/java/core_service/modules/library/service/PsnTitles.java b/src/main/java/core_service/modules/library/service/PsnTitles.java new file mode 100644 index 0000000..9688775 --- /dev/null +++ b/src/main/java/core_service/modules/library/service/PsnTitles.java @@ -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 toLines(List titles) { + Map> 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 lines = new ArrayList<>(); + for (List 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; + } +} diff --git a/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnGameListResponse.java b/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnGameListResponse.java new file mode 100644 index 0000000..f5a376d --- /dev/null +++ b/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnGameListResponse.java @@ -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 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) {} +} diff --git a/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnTokenResponse.java b/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnTokenResponse.java new file mode 100644 index 0000000..018c33f --- /dev/null +++ b/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnTokenResponse.java @@ -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 + "]"; + } +} diff --git a/src/main/java/core_service/modules/psn_adapter/config/PsnClientConfig.java b/src/main/java/core_service/modules/psn_adapter/config/PsnClientConfig.java new file mode 100644 index 0000000..77574e6 --- /dev/null +++ b/src/main/java/core_service/modules/psn_adapter/config/PsnClientConfig.java @@ -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(); + } +} diff --git a/src/main/java/core_service/modules/psn_adapter/service/PsnApiService.java b/src/main/java/core_service/modules/psn_adapter/service/PsnApiService.java new file mode 100644 index 0000000..568f644 --- /dev/null +++ b/src/main/java/core_service/modules/psn_adapter/service/PsnApiService.java @@ -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; + } + } +} diff --git a/src/main/java/core_service/modules/psn_adapter/service/PsnAuthService.java b/src/main/java/core_service/modules/psn_adapter/service/PsnAuthService.java new file mode 100644 index 0000000..95b0a75 --- /dev/null +++ b/src/main/java/core_service/modules/psn_adapter/service/PsnAuthService.java @@ -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()))); + } +} diff --git a/src/main/java/core_service/shared/security/SecretBox.java b/src/main/java/core_service/shared/security/SecretBox.java new file mode 100644 index 0000000..e62e3c0 --- /dev/null +++ b/src/main/java/core_service/shared/security/SecretBox.java @@ -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"; + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index c43d69b..3a6dba4 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -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: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2cafc57..b634834 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 @@ -126,4 +135,10 @@ resilience4j: # IGDB allows 4 requests/s. Taken per HTTP request. limit-for-period: 4 limit-refresh-period: 1s - timeout-duration: 10s \ No newline at end of file + 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 diff --git a/src/main/resources/db/migration/V38__Psn_Account.sql b/src/main/resources/db/migration/V38__Psn_Account.sql new file mode 100644 index 0000000..1e941b8 --- /dev/null +++ b/src/main/resources/db/migration/V38__Psn_Account.sql @@ -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() +); diff --git a/src/test/java/core_service/architecture/ArchitectureTest.java b/src/test/java/core_service/architecture/ArchitectureTest.java index 0c39925..22f43dd 100644 --- a/src/test/java/core_service/architecture/ArchitectureTest.java +++ b/src/test/java/core_service/architecture/ArchitectureTest.java @@ -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 diff --git a/src/test/java/core_service/configuration/LocalMocksParseTest.java b/src/test/java/core_service/configuration/LocalMocksParseTest.java index 05f8ab3..3d715b5 100644 --- a/src/test/java/core_service/configuration/LocalMocksParseTest.java +++ b/src/test/java/core_service/configuration/LocalMocksParseTest.java @@ -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(); } } diff --git a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java index 84f8500..8fea4fe 100644 --- a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java +++ b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java @@ -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); diff --git a/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java b/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java index a92edc4..a92a1fe 100644 --- a/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java +++ b/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java @@ -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(); diff --git a/src/test/java/core_service/modules/library/api/GameImportControllerTest.java b/src/test/java/core_service/modules/library/api/GameImportControllerTest.java index 55e862d..ec60f29 100644 --- a/src/test/java/core_service/modules/library/api/GameImportControllerTest.java +++ b/src/test/java/core_service/modules/library/api/GameImportControllerTest.java @@ -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); diff --git a/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java b/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java index 233f629..eccdd19 100644 --- a/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java +++ b/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java @@ -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) { diff --git a/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java b/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java index e284efb..7d73494 100644 --- a/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java +++ b/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java @@ -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); } diff --git a/src/test/java/core_service/modules/library/api/GameMergeUndoFlowTest.java b/src/test/java/core_service/modules/library/api/GameMergeUndoFlowTest.java index 9750776..e7e034e 100644 --- a/src/test/java/core_service/modules/library/api/GameMergeUndoFlowTest.java +++ b/src/test/java/core_service/modules/library/api/GameMergeUndoFlowTest.java @@ -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) { diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerComingTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerComingTest.java index fbff608..e2f912a 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerComingTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerComingTest.java @@ -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()); diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerFinishedHintTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerFinishedHintTest.java index 66eecf6..6131724 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerFinishedHintTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerFinishedHintTest.java @@ -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")) diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerHiddenTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerHiddenTest.java index a50abe3..db70ecd 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerHiddenTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerHiddenTest.java @@ -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 { diff --git a/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java b/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java index abd5386..13be437 100644 --- a/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java +++ b/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java @@ -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))); diff --git a/src/test/java/core_service/modules/library/api/PsnAccountFlowTest.java b/src/test/java/core_service/modules/library/api/PsnAccountFlowTest.java new file mode 100644 index 0000000..9114adf --- /dev/null +++ b/src/test/java/core_service/modules/library/api/PsnAccountFlowTest.java @@ -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()); + } +} diff --git a/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java b/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java index cf7d8b5..3ae0229 100644 --- a/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java @@ -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); diff --git a/src/test/java/core_service/modules/library/repo/PsnAccountRepositoryTest.java b/src/test/java/core_service/modules/library/repo/PsnAccountRepositoryTest.java new file mode 100644 index 0000000..da878b0 --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/PsnAccountRepositoryTest.java @@ -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(); + } +} diff --git a/src/test/java/core_service/modules/library/repo/StatusChangeLogTest.java b/src/test/java/core_service/modules/library/repo/StatusChangeLogTest.java index b4b124b..b81b57c 100644 --- a/src/test/java/core_service/modules/library/repo/StatusChangeLogTest.java +++ b/src/test/java/core_service/modules/library/repo/StatusChangeLogTest.java @@ -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)); diff --git a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java index 6ffb971..5cfc837 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -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()); diff --git a/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java index a32f920..f6673bf 100644 --- a/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java @@ -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))) diff --git a/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java b/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java index c5eb9f6..844483f 100644 --- a/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java +++ b/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java @@ -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())); diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index c688854..0005b89 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -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); diff --git a/src/test/java/core_service/modules/library/service/PsnAccountServiceTest.java b/src/test/java/core_service/modules/library/service/PsnAccountServiceTest.java new file mode 100644 index 0000000..ffa3345 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/PsnAccountServiceTest.java @@ -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); + } +} diff --git a/src/test/java/core_service/modules/library/service/PsnTitlesTest.java b/src/test/java/core_service/modules/library/service/PsnTitlesTest.java new file mode 100644 index 0000000..2c464a2 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/PsnTitlesTest.java @@ -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); + } +} diff --git a/src/test/java/core_service/modules/library/service/UserStatsServiceTrendTest.java b/src/test/java/core_service/modules/library/service/UserStatsServiceTrendTest.java index 91138ce..3a4fbe1 100644 --- a/src/test/java/core_service/modules/library/service/UserStatsServiceTrendTest.java +++ b/src/test/java/core_service/modules/library/service/UserStatsServiceTrendTest.java @@ -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, " + diff --git a/src/test/java/core_service/modules/psn_adapter/config/PsnClientConfigTest.java b/src/test/java/core_service/modules/psn_adapter/config/PsnClientConfigTest.java new file mode 100644 index 0000000..190d052 --- /dev/null +++ b/src/test/java/core_service/modules/psn_adapter/config/PsnClientConfigTest.java @@ -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); + } +} diff --git a/src/test/java/core_service/modules/psn_adapter/service/PsnApiServiceTest.java b/src/test/java/core_service/modules/psn_adapter/service/PsnApiServiceTest.java new file mode 100644 index 0000000..e0b948c --- /dev/null +++ b/src/test/java/core_service/modules/psn_adapter/service/PsnApiServiceTest.java @@ -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); + } +} diff --git a/src/test/java/core_service/modules/psn_adapter/service/PsnAuthServiceTest.java b/src/test/java/core_service/modules/psn_adapter/service/PsnAuthServiceTest.java new file mode 100644 index 0000000..cd3a7d0 --- /dev/null +++ b/src/test/java/core_service/modules/psn_adapter/service/PsnAuthServiceTest.java @@ -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"); + } +} diff --git a/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java b/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java index 0656623..8f9a11d 100644 --- a/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java +++ b/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java @@ -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()); diff --git a/src/test/java/core_service/shared/security/SecretBoxTest.java b/src/test/java/core_service/shared/security/SecretBoxTest.java new file mode 100644 index 0000000..1a8d240 --- /dev/null +++ b/src/test/java/core_service/shared/security/SecretBoxTest.java @@ -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)); + } +} diff --git a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java index 36de2ea..cdb3217 100644 --- a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java +++ b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java @@ -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"); } diff --git a/src/test/java/core_service/shared/test/AbstractMockedAdaptersTest.java b/src/test/java/core_service/shared/test/AbstractMockedAdaptersTest.java index 69a1256..dedff36 100644 --- a/src/test/java/core_service/shared/test/AbstractMockedAdaptersTest.java +++ b/src/test/java/core_service/shared/test/AbstractMockedAdaptersTest.java @@ -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; }