Compare commits

...
2 Commits
Author SHA1 Message Date
lobo 7ac887ed5a feat(psn): link PlayStation account and import played games
Build, Test & Push Core Service / docker (push) Successful in 1m27s
Build, Test & Push Core Service / quality-check (push) Successful in 1m22s
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.
2026-10-07 13:30:48 +02:00
lobo e87f40bba0 feat(picker): add FRIENDS row from followed users
Backlog games a followed user finished or played >= 5 h. One game per
friend; strongest signal first (finished, then playtime). Requires an
active follow of a public profile that shows SUMMARY or STATUS. Skips
DROPPED games, blocked and deleted users, hidden and removed games.
2026-10-07 09:42:10 +02:00
78 changed files with 2887 additions and 95 deletions

No files matched your search

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