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

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

No files matched your search

@@ -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
*/
@@ -0,0 +1,14 @@
package core_service.modules.library.api.dto;
import java.time.Instant;
/**
* @param enabled false when Core has no APP_PSN_TOKEN_KEY: the PlayStation link is off
* @param expiresAt when the link ends and a new NPSSO is needed; null when not linked
*/
public record PsnAccountDto(
boolean enabled,
boolean linked,
Instant linkedAt,
Instant expiresAt
) {}
@@ -0,0 +1,18 @@
package core_service.modules.library.api.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/**
* The NPSSO of the user's PlayStation session. Used once for the sign-in; never logged, never stored.
*/
public record PsnLinkRequest(
@NotBlank @Size(max = 128) @Pattern(regexp = "[A-Za-z0-9_-]+", message = "must be letters, digits, - or _") String npsso
) {
@Override
public String toString() {
return "PsnLinkRequest[]";
}
}
@@ -20,9 +20,10 @@ public record GameImport(long id, long userId, Source source, Status status, int
/**
* Where the lines came from. GALAXY, GROUVEE, HLTB and PLAYNITE are exports the Frontend reads; BOOKMARKLET is
* 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
}
@@ -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;
@@ -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 +
@@ -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);
}
@@ -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:
+16 -1
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
@@ -126,4 +135,10 @@ resilience4j:
# IGDB allows 4 requests/s. Taken per HTTP request.
limit-for-period: 4
limit-refresh-period: 1s
timeout-duration: 10s
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 {
@@ -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;
}