Public Access
feat(psn): link PlayStation account and import played games
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:
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;
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package core_service.modules.psn_adapter.api.dto.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One page of the played titles (gamelist v2). playDuration is ISO 8601, e.g. PT65H9M15S; category is
|
||||
* ps5_native_game or ps4_game.
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record PsnGameListResponse(List<Title> titles, Integer nextOffset) {
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Title(String titleId, String name, String category, String playDuration, Concept concept) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Concept(Long id) {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package core_service.modules.psn_adapter.api.dto.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* The answer of PlayStation's /token. The tokens never show in {@link #toString()}.
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record PsnTokenResponse(
|
||||
@JsonProperty("access_token") String accessToken,
|
||||
@JsonProperty("expires_in") Integer expiresIn,
|
||||
@JsonProperty("refresh_token") String refreshToken,
|
||||
@JsonProperty("refresh_token_expires_in") Long refreshTokenExpiresIn
|
||||
) {
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "PsnTokenResponse[expiresIn=" + expiresIn + ", refreshTokenExpiresIn=" + refreshTokenExpiresIn + "]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package core_service.modules.psn_adapter.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* The PlayStation clients never follow a redirect: the authorize answer is a 302 to the app's own scheme, and the
|
||||
* code in its Location header must not be fetched.
|
||||
*/
|
||||
@Configuration
|
||||
public class PsnClientConfig {
|
||||
|
||||
@Bean
|
||||
public HttpClient psnHttpClient() {
|
||||
return HttpClient.newBuilder()
|
||||
.followRedirects(HttpClient.Redirect.NEVER)
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RestClient psnAuthRestClient(@Qualifier("psnHttpClient") HttpClient psnHttpClient,
|
||||
@Value("${app.psn.auth-base-url}") String baseUrl) {
|
||||
return client(psnHttpClient, baseUrl);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RestClient psnApiRestClient(@Qualifier("psnHttpClient") HttpClient psnHttpClient,
|
||||
@Value("${app.psn.api-base-url}") String baseUrl) {
|
||||
return client(psnHttpClient, baseUrl);
|
||||
}
|
||||
|
||||
private static RestClient client(HttpClient httpClient, String baseUrl) {
|
||||
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient);
|
||||
factory.setReadTimeout(Duration.ofSeconds(15));
|
||||
return RestClient.builder().baseUrl(baseUrl).requestFactory(factory).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package core_service.modules.psn_adapter.service;
|
||||
|
||||
import core_service.modules.psn_adapter.api.dto.response.PsnGameListResponse;
|
||||
import core_service.shared.security.RequiresRole;
|
||||
import core_service.shared.security.Role;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiter;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The titles a PlayStation account has played (gamelist v2, unofficial), PS4 and PS5.
|
||||
*/
|
||||
@Service
|
||||
@RequiresRole(Role.USER)
|
||||
public class PsnApiService {
|
||||
|
||||
/**
|
||||
* @param conceptId PlayStation's id of the game across its PS4 and PS5 versions; may be null
|
||||
*/
|
||||
public record Title(String titleId, String name, String category, Duration playDuration, Long conceptId) {}
|
||||
|
||||
static final int PAGE_SIZE = 200;
|
||||
static final int MAX_TITLES = 1000;
|
||||
|
||||
private final RestClient apiClient;
|
||||
private final RateLimiter rateLimiter;
|
||||
|
||||
public PsnApiService(@Qualifier("psnApiRestClient") RestClient apiClient, RateLimiterRegistry rateLimiterRegistry) {
|
||||
this.apiClient = apiClient;
|
||||
this.rateLimiter = rateLimiterRegistry.rateLimiter("psnApi");
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws org.springframework.web.client.RestClientException when PlayStation fails
|
||||
*/
|
||||
public List<Title> getPlayedTitles(String accessToken) {
|
||||
List<Title> titles = new ArrayList<>();
|
||||
int offset = 0;
|
||||
while (titles.size() < MAX_TITLES) {
|
||||
int from = offset;
|
||||
RateLimiter.waitForPermission(rateLimiter);
|
||||
PsnGameListResponse page = apiClient.get()
|
||||
.uri(uri -> uri.path("/gamelist/v2/users/me/titles")
|
||||
.queryParam("categories", "ps4_game,ps5_native_game")
|
||||
.queryParam("limit", PAGE_SIZE)
|
||||
.queryParam("offset", from)
|
||||
.build())
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
|
||||
.retrieve()
|
||||
.body(PsnGameListResponse.class);
|
||||
if (page == null || page.titles() == null) {
|
||||
break;
|
||||
}
|
||||
for (PsnGameListResponse.Title title : page.titles()) {
|
||||
if (title.titleId() != null && title.name() != null && titles.size() < MAX_TITLES) {
|
||||
titles.add(new Title(title.titleId(), title.name(), title.category(), duration(title.playDuration()),
|
||||
title.concept() != null ? title.concept().id() : null));
|
||||
}
|
||||
}
|
||||
if (page.nextOffset() == null || page.nextOffset() <= offset) {
|
||||
break;
|
||||
}
|
||||
offset = page.nextOffset();
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
|
||||
private static Duration duration(String iso) {
|
||||
if (iso == null) {
|
||||
return Duration.ZERO;
|
||||
}
|
||||
try {
|
||||
return Duration.parse(iso);
|
||||
} catch (DateTimeParseException e) {
|
||||
return Duration.ZERO;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package core_service.modules.psn_adapter.service;
|
||||
|
||||
import core_service.modules.psn_adapter.api.dto.response.PsnTokenResponse;
|
||||
import core_service.shared.security.RequiresRole;
|
||||
import core_service.shared.security.Role;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiter;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* PlayStation sign-in (unofficial): the user's NPSSO becomes a one-time code and then tokens. Nothing of the
|
||||
* exchange is logged: not the NPSSO, the code, the Location header, a body or a token.
|
||||
*/
|
||||
@Service
|
||||
@RequiresRole(Role.USER)
|
||||
@Slf4j
|
||||
public class PsnAuthService {
|
||||
|
||||
private static final String SCOPE = "psn:mobile.v2.core psn:clientapp";
|
||||
|
||||
/**
|
||||
* The tokens never show in {@link #toString()}.
|
||||
*/
|
||||
public record Tokens(String accessToken, Instant accessExpiresAt, String refreshToken, Instant refreshExpiresAt) {
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Tokens[accessExpiresAt=" + accessExpiresAt + ", refreshExpiresAt=" + refreshExpiresAt + "]";
|
||||
}
|
||||
}
|
||||
|
||||
private final RestClient authClient;
|
||||
private final RateLimiter rateLimiter;
|
||||
private final String clientId;
|
||||
private final String basicAuth;
|
||||
private final String redirectUri;
|
||||
|
||||
public PsnAuthService(@Qualifier("psnAuthRestClient") RestClient authClient,
|
||||
RateLimiterRegistry rateLimiterRegistry,
|
||||
@Value("${app.psn.client-id}") String clientId,
|
||||
@Value("${app.psn.client-secret}") String clientSecret,
|
||||
@Value("${app.psn.redirect-uri}") String redirectUri) {
|
||||
this.authClient = authClient;
|
||||
this.rateLimiter = rateLimiterRegistry.rateLimiter("psnApi");
|
||||
this.clientId = clientId;
|
||||
this.basicAuth = "Basic " + Base64.getEncoder()
|
||||
.encodeToString((clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8));
|
||||
this.redirectUri = redirectUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return empty when PlayStation does not accept the NPSSO (invalid or expired); a failure of PlayStation itself
|
||||
* throws a RestClientException
|
||||
*/
|
||||
public Optional<Tokens> exchangeNpsso(String npsso) {
|
||||
RateLimiter.waitForPermission(rateLimiter);
|
||||
String location;
|
||||
try {
|
||||
location = authClient.get()
|
||||
.uri(uri -> uri.path("/authorize")
|
||||
.queryParam("access_type", "offline")
|
||||
.queryParam("client_id", clientId)
|
||||
.queryParam("redirect_uri", "{redirect}")
|
||||
.queryParam("response_type", "code")
|
||||
.queryParam("scope", "{scope}")
|
||||
.build(redirectUri, SCOPE))
|
||||
.header(HttpHeaders.COOKIE, "npsso=" + npsso)
|
||||
.retrieve()
|
||||
.toBodilessEntity()
|
||||
.getHeaders().getFirst(HttpHeaders.LOCATION);
|
||||
} catch (RestClientException e) {
|
||||
throw e;
|
||||
} catch (RuntimeException e) {
|
||||
// The JDK puts a refused header value, i.e. the NPSSO, into its message: a fixed text and no cause instead.
|
||||
throw new RestClientException("PSN authorize request failed");
|
||||
}
|
||||
String code = location == null ? null
|
||||
: UriComponentsBuilder.fromUriString(location).build().getQueryParams().getFirst("code");
|
||||
if (code == null || code.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
|
||||
form.add("code", code);
|
||||
form.add("redirect_uri", redirectUri);
|
||||
form.add("grant_type", "authorization_code");
|
||||
form.add("token_format", "jwt");
|
||||
return token(form);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return empty when PlayStation refuses the refresh token: the user must link again
|
||||
*/
|
||||
public Optional<Tokens> refresh(String refreshToken) {
|
||||
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
|
||||
form.add("refresh_token", refreshToken);
|
||||
form.add("grant_type", "refresh_token");
|
||||
form.add("token_format", "jwt");
|
||||
form.add("scope", SCOPE);
|
||||
return token(form);
|
||||
}
|
||||
|
||||
private Optional<Tokens> token(MultiValueMap<String, String> form) {
|
||||
RateLimiter.waitForPermission(rateLimiter);
|
||||
PsnTokenResponse answer;
|
||||
try {
|
||||
answer = authClient.post()
|
||||
.uri("/token")
|
||||
.header(HttpHeaders.AUTHORIZATION, basicAuth)
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(form)
|
||||
.retrieve()
|
||||
.body(PsnTokenResponse.class);
|
||||
} catch (HttpClientErrorException e) {
|
||||
int status = e.getStatusCode().value();
|
||||
// Only these mean "refused". A rate limit (429) or another 4xx is a failure of PlayStation's side.
|
||||
if (status != 400 && status != 401 && status != 403) {
|
||||
throw e;
|
||||
}
|
||||
log.info("PSN token request refused with status {}", status);
|
||||
return Optional.empty();
|
||||
}
|
||||
if (answer == null || answer.accessToken() == null || answer.refreshToken() == null
|
||||
|| answer.expiresIn() == null || answer.refreshTokenExpiresIn() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Instant now = Instant.now();
|
||||
return Optional.of(new Tokens(answer.accessToken(), now.plusSeconds(answer.expiresIn()),
|
||||
answer.refreshToken(), now.plusSeconds(answer.refreshTokenExpiresIn())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package core_service.shared.security;
|
||||
|
||||
import javax.crypto.AEADBadTagException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Seals a secret string for the database: AES-256-GCM with a fresh nonce per value. The sealed bytes are
|
||||
* {@code 0x01 || nonce || ciphertext}; the first byte is the format version, for a later key rotation.
|
||||
*/
|
||||
public final class SecretBox {
|
||||
|
||||
private static final int KEY_BYTES = 32;
|
||||
private static final int NONCE_BYTES = 12;
|
||||
private static final int TAG_BITS = 128;
|
||||
private static final byte VERSION = 1;
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final String BAD_KEY = "APP_PSN_TOKEN_KEY must be 32 bytes, base64";
|
||||
|
||||
private final SecretKeySpec key;
|
||||
|
||||
private SecretBox(byte[] keyBytes) {
|
||||
this.key = new SecretKeySpec(keyBytes, "AES");
|
||||
}
|
||||
|
||||
public static SecretBox fromBase64Key(String base64Key) {
|
||||
byte[] keyBytes;
|
||||
try {
|
||||
keyBytes = Base64.getDecoder().decode(base64Key.strip());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IllegalArgumentException(BAD_KEY);
|
||||
}
|
||||
if (keyBytes.length != KEY_BYTES) {
|
||||
throw new IllegalArgumentException(BAD_KEY);
|
||||
}
|
||||
return new SecretBox(keyBytes);
|
||||
}
|
||||
|
||||
public byte[] seal(String plain) {
|
||||
byte[] nonce = new byte[NONCE_BYTES];
|
||||
RANDOM.nextBytes(nonce);
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, nonce));
|
||||
byte[] encrypted = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] sealed = new byte[1 + NONCE_BYTES + encrypted.length];
|
||||
sealed[0] = VERSION;
|
||||
System.arraycopy(nonce, 0, sealed, 1, NONCE_BYTES);
|
||||
System.arraycopy(encrypted, 0, sealed, 1 + NONCE_BYTES, encrypted.length);
|
||||
return sealed;
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("Sealing failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws IllegalStateException for a wrong key, changed bytes or an unknown version
|
||||
*/
|
||||
public String open(byte[] sealed) {
|
||||
if (sealed == null || sealed.length < 1 + NONCE_BYTES || sealed[0] != VERSION) {
|
||||
throw new IllegalStateException("Unknown sealed format");
|
||||
}
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, Arrays.copyOfRange(sealed, 1, 1 + NONCE_BYTES)));
|
||||
return new String(cipher.doFinal(sealed, 1 + NONCE_BYTES, sealed.length - 1 - NONCE_BYTES), StandardCharsets.UTF_8);
|
||||
} catch (AEADBadTagException e) {
|
||||
throw new IllegalStateException("Opening failed: wrong key or changed bytes");
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("Opening failed");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SecretBox";
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,11 @@ app:
|
||||
client-secret: local
|
||||
base-url: "http://localhost:8089/igdb"
|
||||
token-base-url: "http://localhost:8089/twitch"
|
||||
psn:
|
||||
# Local only, not a secret.
|
||||
token-key: "bG9jYWwtb25seS1wc24ta2V5LTMyLWJ5dGVzISEhISE="
|
||||
auth-base-url: "http://localhost:8089/psn/auth"
|
||||
api-base-url: "http://localhost:8089/psn"
|
||||
speedrun:
|
||||
base-url: "http://localhost:8089/speedrun"
|
||||
library:
|
||||
|
||||
@@ -51,6 +51,15 @@ app:
|
||||
client-secret: ${IGDB_CLIENT_SECRET:}
|
||||
base-url: https://api.igdb.com/v4
|
||||
token-base-url: https://id.twitch.tv
|
||||
psn:
|
||||
# Base64 of 32 random bytes (openssl rand -base64 32). Empty = the PlayStation link is off.
|
||||
token-key: ${APP_PSN_TOKEN_KEY:}
|
||||
auth-base-url: https://ca.account.sony.com/api/authz/v3/oauth
|
||||
api-base-url: https://m.np.playstation.com/api
|
||||
# The public client of the PlayStation app, as the psn-api project ships it. Not a secret of ours.
|
||||
client-id: 09515159-7237-4370-9b40-3806e67c0891
|
||||
client-secret: ucPjka5tntB2KqsP
|
||||
redirect-uri: com.scee.psxandroid.scecompcall://redirect
|
||||
speedrun:
|
||||
# No key needed. false stops every call to speedrun.com.
|
||||
enabled: true
|
||||
@@ -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());
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import core_service.configuration.security.ClientHeaderFilter;
|
||||
import core_service.modules.library.domain.FinishedHintType;
|
||||
import core_service.modules.library.domain.GameAchievementSchema;
|
||||
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.GameTimeToBeat;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
@@ -146,7 +147,7 @@ class LibraryControllerFinishedHintTest extends AbstractIntegrationTest {
|
||||
|
||||
@Test
|
||||
void detail_GameAddedByHand_GetsThePlaytimeHint() throws Exception {
|
||||
userGameRepository.save(UserGame.manual(USER_ID, MANUAL_GAME, "Switch", GameStatus.PLAYING, 600, Instant.now()));
|
||||
userGameRepository.save(UserGame.manual(USER_ID, MANUAL_GAME, GameSource.MANUAL, "Switch", GameStatus.PLAYING, 600, Instant.now()));
|
||||
|
||||
mockMvc.perform(get("/api/library/games/" + MANUAL_GAME))
|
||||
.andExpect(jsonPath("$.finishedHint.type").value("PLAYTIME"))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package core_service.modules.library.api;
|
||||
|
||||
import core_service.configuration.security.ClientHeaderFilter;
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
@@ -56,7 +57,7 @@ class LibraryControllerHiddenTest extends AbstractMockedAdaptersTest {
|
||||
steamGameRepository.save(new SteamGame(-1942L, "Zelda", null, Instant.now()));
|
||||
userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now()));
|
||||
userGameRepository.save(new UserGame(USER_ID, 1000L, 0, Instant.now(), Instant.now()));
|
||||
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 60, Instant.now()));
|
||||
userGameRepository.save(UserGame.manual(USER_ID, -1942L, GameSource.MANUAL, "Switch", GameStatus.PLAYING, 60, Instant.now()));
|
||||
}
|
||||
|
||||
private ResultActions setHidden(long appid, String body) throws Exception {
|
||||
|
||||
@@ -241,7 +241,7 @@ class ManualGameControllerTest extends AbstractMockedAdaptersTest {
|
||||
|
||||
@Test
|
||||
void search_GameAddedBeforeIgdbKnewItOnSteam_IsInTheLibrary() throws Exception {
|
||||
userGameRepository.save(UserGame.manual(USER_ID, -119388L, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
|
||||
userGameRepository.save(UserGame.manual(USER_ID, -119388L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
|
||||
when(igdbApiService.searchGames("zelda")).thenReturn(Optional.of(List.of(new IgdbApiService.Game(119388,
|
||||
ZELDA.name(), 2023, null, List.of("Switch"), 2_000_000L))));
|
||||
|
||||
@@ -404,7 +404,7 @@ class ManualGameControllerTest extends AbstractMockedAdaptersTest {
|
||||
void add_MergedGameIgdbLaterFindsOnSteam_IsStillInTheLibrary() throws Exception {
|
||||
steamGameRepository.save(new SteamGame(-1_900_400L, "Zelda TotK", null, Instant.now()));
|
||||
jdbc.update("insert into game_alias (from_appid, to_appid) values (?, ?)", -119388L, -1_900_400L);
|
||||
userGameRepository.save(UserGame.manual(USER_ID, -1_900_400L, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
|
||||
userGameRepository.save(UserGame.manual(USER_ID, -1_900_400L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
|
||||
when(igdbApiService.getGame(119388)).thenReturn(Optional.of(new IgdbApiService.Game(119388, ZELDA.name(),
|
||||
2023, null, List.of("Switch"), 2_000_000L)));
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package core_service.modules.library.api;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import core_service.configuration.security.ClientHeaderFilter;
|
||||
import core_service.modules.igdb_adapter.service.IgdbApiService;
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import core_service.modules.psn_adapter.service.PsnApiService;
|
||||
import core_service.modules.psn_adapter.service.PsnAuthService;
|
||||
import core_service.shared.security.SecretBox;
|
||||
import core_service.shared.test.AbstractMockedAdaptersTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.ResultActions;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* The dev profile authenticates every request as user 1. PlayStation is mocked at the two adapter services.
|
||||
*/
|
||||
@AutoConfigureMockMvc
|
||||
class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
|
||||
|
||||
private static final String NPSSO = "NPSSO-SECRET-VALUE";
|
||||
private static final String REFRESH = "REFRESH-SECRET-VALUE";
|
||||
private static final String ACCESS = "ACCESS-SECRET-VALUE";
|
||||
private static final IgdbApiService.Game GHOST = new IgdbApiService.Game(300, "Ghost of Yotei", 2025, null,
|
||||
List.of("PS5"), null);
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@Autowired
|
||||
private UserGameRepository userGameRepository;
|
||||
|
||||
@Value("${app.psn.token-key}")
|
||||
private String tokenKey;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbc.update("delete from user_psn_account");
|
||||
jdbc.update("delete from game_alias");
|
||||
userGameRepository.deleteAll();
|
||||
jdbc.update("delete from game_import");
|
||||
when(igdbApiService.isEnabled()).thenReturn(true);
|
||||
when(igdbApiService.searchGames("Ghost of Yotei")).thenReturn(Optional.of(List.of(GHOST)));
|
||||
when(igdbApiService.getTimeToBeat(any())).thenReturn(Optional.of(Map.of()));
|
||||
when(igdbApiService.getTimeToBeatByGameIds(any())).thenReturn(Optional.of(Map.of()));
|
||||
when(psnAuthService.exchangeNpsso(NPSSO)).thenReturn(Optional.of(tokens(Instant.now().plus(Duration.ofDays(10)))));
|
||||
when(psnAuthService.refresh(REFRESH)).thenReturn(Optional.of(tokens(Instant.now().plus(Duration.ofDays(10)))));
|
||||
when(psnApiService.getPlayedTitles(ACCESS)).thenReturn(List.of(new PsnApiService.Title("PPSA1_00",
|
||||
"Ghost of Yotei™", "ps5_native_game", Duration.ofMinutes(125), 10L)));
|
||||
}
|
||||
|
||||
private static PsnAuthService.Tokens tokens(Instant refreshExpires) {
|
||||
return new PsnAuthService.Tokens(ACCESS, Instant.now().plusSeconds(3599), REFRESH, refreshExpires);
|
||||
}
|
||||
|
||||
private ResultActions link(String npsso) throws Exception {
|
||||
return mockMvc.perform(post("/api/library/psn-account")
|
||||
.header(ClientHeaderFilter.CLIENT_HEADER, "test")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"npsso\": \"" + npsso + "\"}"));
|
||||
}
|
||||
|
||||
private ResultActions startImport() throws Exception {
|
||||
return mockMvc.perform(post("/api/library/psn-account/imports").header(ClientHeaderFilter.CLIENT_HEADER, "test"));
|
||||
}
|
||||
|
||||
private void awaitStatus(long id, String expected) throws InterruptedException {
|
||||
for (int attempt = 0; attempt < 50; attempt++) {
|
||||
if (expected.equals(jdbc.queryForObject("select status from game_import where id = ?", String.class, id))) {
|
||||
return;
|
||||
}
|
||||
Thread.sleep(100);
|
||||
}
|
||||
throw new AssertionError("Import " + id + " not " + expected);
|
||||
}
|
||||
|
||||
private long startedImport() throws Exception {
|
||||
return ((Number) JsonPath.read(startImport().andExpect(status().isAccepted()).andReturn().getResponse()
|
||||
.getContentAsString(), "$.id")).longValue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_StoresOnlyTheSealedRefreshToken_AndTheAnswerHoldsNoSecret() throws Exception {
|
||||
String body = link(NPSSO).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.enabled").value(true))
|
||||
.andExpect(jsonPath("$.linked").value(true))
|
||||
.andExpect(jsonPath("$.expiresAt").isNotEmpty())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
assertThat(body).doesNotContain(NPSSO).doesNotContain(REFRESH).doesNotContain(ACCESS);
|
||||
byte[] stored = jdbc.queryForObject("select refresh_token_enc from user_psn_account where user_id = 1", byte[].class);
|
||||
assertThat(new String(stored, StandardCharsets.ISO_8859_1)).doesNotContain(REFRESH).doesNotContain(NPSSO);
|
||||
assertThat(SecretBox.fromBase64Key(tokenKey).open(stored)).isEqualTo(REFRESH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_RejectedNpsso_Is400_AndStoresNothing() throws Exception {
|
||||
link("bad-npsso").andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("NPSSO_INVALID"))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("bad-npsso"))));
|
||||
|
||||
mockMvc.perform(get("/api/library/psn-account")).andExpect(jsonPath("$.linked").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_BlankOrTooLongNpsso_Is400_WithoutEchoingIt() throws Exception {
|
||||
link(" ").andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
|
||||
String tooLong = "x".repeat(129);
|
||||
String body = link(tooLong).andExpect(status().isBadRequest()).andReturn().getResponse().getContentAsString();
|
||||
assertThat(body).doesNotContain(tooLong);
|
||||
verify(psnAuthService, never()).exchangeNpsso(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_PsnDown_Is502() throws Exception {
|
||||
when(psnAuthService.exchangeNpsso(NPSSO)).thenThrow(new org.springframework.web.client.ResourceAccessException("down"));
|
||||
|
||||
link(NPSSO).andExpect(status().isBadGateway()).andExpect(jsonPath("$.code").value("PSN_ERROR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void get_ThenDelete_UnlinksAndDeletesTheToken() throws Exception {
|
||||
link(NPSSO).andExpect(status().isOk());
|
||||
mockMvc.perform(get("/api/library/psn-account"))
|
||||
.andExpect(jsonPath("$.linked").value(true))
|
||||
.andExpect(jsonPath("$.linkedAt").isNotEmpty());
|
||||
|
||||
mockMvc.perform(delete("/api/library/psn-account").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
mockMvc.perform(get("/api/library/psn-account")).andExpect(jsonPath("$.linked").value(false));
|
||||
assertThat(jdbc.queryForObject("select count(*) from user_psn_account", Integer.class)).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void postWithoutTheClientHeader_IsRefused() throws Exception {
|
||||
mockMvc.perform(post("/api/library/psn-account").contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"npsso\": \"" + NPSSO + "\"}")).andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void import_StartsAPsnImportWithTheTitlesAsLines() throws Exception {
|
||||
link(NPSSO).andExpect(status().isOk());
|
||||
|
||||
long id = startedImport();
|
||||
|
||||
assertThat(jdbc.queryForObject("select source from game_import where id = ?", String.class, id)).isEqualTo("PSN");
|
||||
assertThat(jdbc.queryForMap("select title, platform, playtime_minutes from game_import_row where import_id = ?", id))
|
||||
.containsEntry("title", "Ghost of Yotei").containsEntry("platform", "PS5")
|
||||
.containsEntry("playtime_minutes", 125);
|
||||
}
|
||||
|
||||
@Test
|
||||
void import_NotLinked_Is404() throws Exception {
|
||||
startImport().andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("PSN_NOT_LINKED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void import_ExpiredLink_Is409Relink_WithoutAPsnCall() throws Exception {
|
||||
link(NPSSO).andExpect(status().isOk());
|
||||
jdbc.update("update user_psn_account set refresh_expires_at = now() - interval '1 hour'");
|
||||
|
||||
startImport().andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("PSN_RELINK"));
|
||||
verify(psnAuthService, never()).refresh(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void import_RefreshRefused_Is409Relink_AndTheLinkShowsAsExpired() throws Exception {
|
||||
link(NPSSO).andExpect(status().isOk());
|
||||
when(psnAuthService.refresh(REFRESH)).thenReturn(Optional.empty());
|
||||
|
||||
startImport().andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("PSN_RELINK"));
|
||||
|
||||
assertThat(jdbc.queryForObject("select refresh_expires_at <= now() from user_psn_account", Boolean.class)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void import_NoTitles_Is204() throws Exception {
|
||||
link(NPSSO).andExpect(status().isOk());
|
||||
when(psnApiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
|
||||
|
||||
startImport().andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void import_PsnDown_Is502_AndIgdbOff_Is503() throws Exception {
|
||||
link(NPSSO).andExpect(status().isOk());
|
||||
when(psnApiService.getPlayedTitles(ACCESS)).thenThrow(new org.springframework.web.client.ResourceAccessException("down"));
|
||||
startImport().andExpect(status().isBadGateway()).andExpect(jsonPath("$.code").value("PSN_ERROR"));
|
||||
|
||||
doReturn(List.of(new PsnApiService.Title("A_00", "A", "ps4_game", Duration.ZERO, null)))
|
||||
.when(psnApiService).getPlayedTitles(ACCESS);
|
||||
when(igdbApiService.isEnabled()).thenReturn(false);
|
||||
startImport().andExpect(status().isServiceUnavailable()).andExpect(jsonPath("$.code").value("IGDB_OFF"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void savedPsnImport_AddsPsnRows_TheUserCanEditAndDelete_AndUndoRemoves() throws Exception {
|
||||
link(NPSSO).andExpect(status().isOk());
|
||||
long id = startedImport();
|
||||
awaitStatus(id, "READY");
|
||||
|
||||
mockMvc.perform(post("/api/library/imports/" + id + "/save").header(ClientHeaderFilter.CLIENT_HEADER, "test")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"rows\": [{\"lineNo\": 1, \"igdbId\": 300, \"platform\": \"PS5\", "
|
||||
+ "\"status\": \"PLAYING\", \"playtimeMinutes\": 125}]}"))
|
||||
.andExpect(status().isAccepted());
|
||||
awaitStatus(id, "SAVED");
|
||||
|
||||
Map<String, Object> row = jdbc.queryForMap("select source, platform, playtime_minutes, import_id from user_game "
|
||||
+ "where user_id = 1 and appid = -300");
|
||||
assertThat(row).containsEntry("source", GameSource.PSN.name()).containsEntry("platform", "PS5")
|
||||
.containsEntry("playtime_minutes", 125).containsEntry("import_id", id);
|
||||
mockMvc.perform(get("/api/library/imports/" + id + "/undo-check")).andExpect(jsonPath("$.games").value(1));
|
||||
|
||||
mockMvc.perform(put("/api/library/games/-300/manual").header(ClientHeaderFilter.CLIENT_HEADER, "test")
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{\"platform\": \"PS4\", \"playtimeMinutes\": 200}"))
|
||||
.andExpect(status().isNoContent());
|
||||
assertThat(jdbc.queryForObject("select platform from user_game where appid = -300", String.class)).isEqualTo("PS4");
|
||||
|
||||
mockMvc.perform(delete("/api/library/imports/" + id + "/games").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
|
||||
.andExpect(status().isNoContent());
|
||||
assertThat(jdbc.queryForObject("select count(*) from user_game where appid = -300", Integer.class)).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void psnRow_CanBeDeletedByHand() throws Exception {
|
||||
jdbc.update("insert into steam_game (appid, name) values (-301, 'X') on conflict do nothing");
|
||||
jdbc.update("insert into user_game (user_id, appid, playtime_minutes, first_seen_at, last_seen_at, source, platform, status, "
|
||||
+ "status_set_by_user) values (1, -301, 0, now(), now(), 'PSN', 'PS5', 'BACKLOG', true)");
|
||||
|
||||
mockMvc.perform(delete("/api/library/games/-301").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
|
||||
.andExpect(status().isNoContent());
|
||||
assertThat(jdbc.queryForObject("select count(*) from user_game where appid = -301", Integer.class)).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void npssoWithCharsNotAllowedInAHeader_Is400_AndNeverReachesTheAdapter() throws Exception {
|
||||
link(NPSSO + "\\u200b").andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
|
||||
link(NPSSO + "\\nX").andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
|
||||
|
||||
verify(psnAuthService, never()).exchangeNpsso(any());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.GameTimeToBeat;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
@@ -67,7 +68,7 @@ class BacklogSnapshotRepositoryTest extends AbstractPersistenceTest {
|
||||
removed.setRemovedAt(Instant.now());
|
||||
userGameRepository.saveAllAndFlush(List.of(
|
||||
game(1L, 220L, 0, GameStatus.BACKLOG), hidden, removed,
|
||||
UserGame.manual(2L, -1942L, "Switch", GameStatus.PLAYING, 0, Instant.now())));
|
||||
UserGame.manual(2L, -1942L, GameSource.MANUAL, "Switch", GameStatus.PLAYING, 0, Instant.now())));
|
||||
|
||||
assertThat(repository.writeAll(DAY)).isEqualTo(2);
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.PsnAccount;
|
||||
import core_service.shared.test.AbstractPersistenceTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@Import(PsnAccountRepository.class)
|
||||
class PsnAccountRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@Autowired
|
||||
private PsnAccountRepository repository;
|
||||
|
||||
@Test
|
||||
void upsert_StoresTheBytes_AndALaterLinkReplacesThem() {
|
||||
Instant first = Instant.now().plus(10, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS);
|
||||
Instant second = first.plus(1, ChronoUnit.DAYS);
|
||||
|
||||
repository.upsert(5L, new byte[]{1, 2, 3}, first);
|
||||
repository.upsert(5L, new byte[]{4, 5}, second);
|
||||
|
||||
PsnAccount account = repository.find(5L).orElseThrow();
|
||||
assertThat(account.refreshTokenEnc()).containsExactly(4, 5);
|
||||
assertThat(account.refreshExpiresAt()).isEqualTo(second);
|
||||
assertThat(account.linkedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateToken_ChangesTokenAndExpiry_AndKeepsLinkedAt() {
|
||||
repository.upsert(6L, new byte[]{1}, Instant.now().plusSeconds(100));
|
||||
Instant linkedAt = repository.find(6L).orElseThrow().linkedAt();
|
||||
Instant expiry = Instant.now().plus(10, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS);
|
||||
|
||||
repository.updateToken(6L, new byte[]{9}, expiry);
|
||||
|
||||
PsnAccount account = repository.find(6L).orElseThrow();
|
||||
assertThat(account.refreshTokenEnc()).containsExactly(9);
|
||||
assertThat(account.refreshExpiresAt()).isEqualTo(expiry);
|
||||
assertThat(account.linkedAt()).isEqualTo(linkedAt);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expireNow_PutsTheExpiryInThePast() {
|
||||
repository.upsert(7L, new byte[]{1}, Instant.now().plus(5, ChronoUnit.DAYS));
|
||||
|
||||
repository.expireNow(7L);
|
||||
|
||||
assertThat(repository.find(7L).orElseThrow().refreshExpiresAt()).isBeforeOrEqualTo(Instant.now());
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_RemovesTheRow() {
|
||||
repository.upsert(8L, new byte[]{1}, Instant.now().plusSeconds(100));
|
||||
|
||||
assertThat(repository.delete(8L)).isEqualTo(1);
|
||||
assertThat(repository.find(8L)).isEmpty();
|
||||
assertThat(repository.delete(8L)).isZero();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
@@ -60,7 +61,7 @@ class StatusChangeLogTest extends AbstractPersistenceTest {
|
||||
|
||||
@Test
|
||||
void manualAdd_LogsTheStatusByUser() {
|
||||
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 0, Instant.now()));
|
||||
userGameRepository.save(UserGame.manual(USER_ID, -1942L, GameSource.MANUAL, "Switch", GameStatus.PLAYING, 0, Instant.now()));
|
||||
|
||||
assertThat(log()).containsExactly("null->PLAYING true");
|
||||
}
|
||||
@@ -101,7 +102,7 @@ class StatusChangeLogTest extends AbstractPersistenceTest {
|
||||
|
||||
@Test
|
||||
void deletedGame_TakesItsLogRowsAlong() {
|
||||
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 0, Instant.now()));
|
||||
userGameRepository.save(UserGame.manual(USER_ID, -1942L, GameSource.MANUAL, "Switch", GameStatus.PLAYING, 0, Instant.now()));
|
||||
entityManager.flush();
|
||||
|
||||
userGameRepository.delete(game(-1942L));
|
||||
|
||||
@@ -117,7 +117,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
@Test
|
||||
void softDeleteMissingSince_KeepsGamesAddedByHand() {
|
||||
steamGameRepository.save(new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now()));
|
||||
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 600,
|
||||
userGameRepository.save(UserGame.manual(USER_ID, -1942L, GameSource.MANUAL, "Switch", GameStatus.PLAYING, 600,
|
||||
Instant.now().minusSeconds(3600)));
|
||||
Instant syncStartedAt = Instant.now();
|
||||
|
||||
@@ -143,7 +143,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow().setHiddenAt(Instant.now());
|
||||
userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow().setRemovedAt(Instant.now());
|
||||
steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now()));
|
||||
userGameRepository.save(UserGame.manual(USER_ID, 999L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
|
||||
userGameRepository.save(UserGame.manual(USER_ID, 999L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.now()));
|
||||
userGameRepository.flush();
|
||||
|
||||
assertEquals(Set.of(220L, 620L),
|
||||
@@ -164,7 +164,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
@Test
|
||||
void findAchievementProgress_LeavesOutGamesAddedByHand() {
|
||||
steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now()));
|
||||
userGameRepository.save(UserGame.manual(USER_ID, 999L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
|
||||
userGameRepository.save(UserGame.manual(USER_ID, 999L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.now()));
|
||||
|
||||
assertTrue(userGameRepository.findAchievementProgress(USER_ID).stream().noneMatch(p -> p.appid() == 999L));
|
||||
}
|
||||
@@ -173,7 +173,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
void findRandomBacklogGameAndPicker_ExcludeNothingStillFindTheGameWithKeyMinusOne() {
|
||||
userGameRepository.deleteAll();
|
||||
steamGameRepository.save(new SteamGame(-1L, "IGDB game 1", null, Instant.now()));
|
||||
userGameRepository.save(UserGame.manual(USER_ID, -1L, "PS2", GameStatus.BACKLOG, 0, Instant.now()));
|
||||
userGameRepository.save(UserGame.manual(USER_ID, -1L, GameSource.MANUAL, "PS2", GameStatus.BACKLOG, 0, Instant.now()));
|
||||
|
||||
assertEquals(-1L, userGameRepository.findRandomBacklogGame(USER_ID, UserGameRepository.NO_GAME).orElseThrow().getAppid());
|
||||
assertEquals(1, userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 3).size());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.DayMinutes;
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.GameTimeToBeat;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
@@ -281,10 +282,10 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
|
||||
Instant now = Instant.now();
|
||||
userGameRepository.saveAllAndFlush(List.of(
|
||||
game(220L, 100, GameStatus.BACKLOG, now), game(620L, 50, GameStatus.BACKLOG, now),
|
||||
UserGame.manual(USER_ID, 8001L, "PS5", GameStatus.BACKLOG, 10, now),
|
||||
UserGame.manual(USER_ID, 8002L, "PS5", GameStatus.BACKLOG, 20, now),
|
||||
UserGame.manual(USER_ID, 8003L, "ps5 ", GameStatus.BACKLOG, 30, now),
|
||||
UserGame.manual(USER_ID, 8004L, "Switch", GameStatus.BACKLOG, 5, now)));
|
||||
UserGame.manual(USER_ID, 8001L, GameSource.MANUAL, "PS5", GameStatus.BACKLOG, 10, now),
|
||||
UserGame.manual(USER_ID, 8002L, GameSource.MANUAL, "PS5", GameStatus.BACKLOG, 20, now),
|
||||
UserGame.manual(USER_ID, 8003L, GameSource.MANUAL, "ps5 ", GameStatus.BACKLOG, 30, now),
|
||||
UserGame.manual(USER_ID, 8004L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 5, now)));
|
||||
seedHiddenAndRemoved();
|
||||
|
||||
assertThat(repository.gamesPerPlatform(USER_ID)).containsExactly(
|
||||
@@ -332,7 +333,7 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@Test
|
||||
void pileMoves_GamesAddedByHandAreAdded() {
|
||||
UserGame manual = UserGame.manual(USER_ID, 700L, "Switch", GameStatus.BACKLOG, 0, Instant.now());
|
||||
UserGame manual = UserGame.manual(USER_ID, 700L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 0, Instant.now());
|
||||
userGameRepository.saveAndFlush(manual);
|
||||
|
||||
assertThat(repository.pileMoves(USER_ID, Instant.now().minusSeconds(3600)))
|
||||
|
||||
@@ -4,6 +4,7 @@ import core_service.modules.library.api.dto.GameDetailDto;
|
||||
import core_service.modules.library.domain.GameAchievementSchema;
|
||||
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||
@@ -94,7 +95,7 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
|
||||
|
||||
@Test
|
||||
void getGameDetail_GameAddedByHand_AsksSteamNothingAndShowsNoAchievements() {
|
||||
userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
|
||||
userGameRepository.save(UserGame.manual(USER_ID, 620L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.now()));
|
||||
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 5, true, Instant.now().minus(Duration.ofDays(31))));
|
||||
|
||||
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
|
||||
@@ -120,7 +121,7 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
|
||||
|
||||
@Test
|
||||
void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAndMoodsAgain() {
|
||||
userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
|
||||
userGameRepository.save(UserGame.manual(USER_ID, 620L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.now()));
|
||||
|
||||
when(igdbApiService.isEnabled()).thenReturn(true);
|
||||
when(igdbApiService.getTimeToBeat(Set.of(620L))).thenReturn(Optional.of(Map.of()));
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.InjectMocks;
|
||||
@@ -234,9 +235,10 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
assertEquals(expected, saved.getValue().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_GameAddedByHandIsNowOnSteam_BecomesASteamGameAndKeepsTheBacklog() {
|
||||
UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.FINISHED, 60, Instant.EPOCH);
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = GameSource.class, names = {"MANUAL", "PSN"})
|
||||
void runSync_GameAddedByHandOrFromPsnIsNowOnSteam_BecomesASteamGameAndKeepsTheBacklog(GameSource source) {
|
||||
UserGame manual = UserGame.manual(USER_ID, 220L, source, "PS5", GameStatus.FINISHED, 60, Instant.EPOCH);
|
||||
manual.setRating(5);
|
||||
manual.setNotes("Played on the couch");
|
||||
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual));
|
||||
@@ -254,7 +256,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void runSync_GameAddedByHandIsNowOnSteam_TheHandTypedHoursAreNoPlaytimeGrowth() {
|
||||
UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
|
||||
UserGame manual = UserGame.manual(USER_ID, 220L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
|
||||
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual));
|
||||
steamReturns(game(90, 30), STEAM_ID);
|
||||
|
||||
@@ -675,7 +677,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
UserGame gone = new UserGame(USER_ID, 300L, 10, Instant.EPOCH, Instant.EPOCH);
|
||||
UserGame alreadyRemoved = new UserGame(USER_ID, 400L, 10, Instant.EPOCH, Instant.EPOCH);
|
||||
alreadyRemoved.setRemovedAt(Instant.EPOCH);
|
||||
UserGame byHand = UserGame.manual(USER_ID, 500L, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
|
||||
UserGame byHand = UserGame.manual(USER_ID, 500L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
|
||||
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(grown, gone, alreadyRemoved, byHand));
|
||||
when(userSteamSyncRepository.findForClaim(USER_ID)).thenReturn(Optional.of(syncRow(STEAM_ID, STEAM_ID)));
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
|
||||
@@ -724,7 +726,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void runSync_GameAddedByHandIsNowOnSteam_IsNeitherAddedNorPlaytime() {
|
||||
UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
|
||||
UserGame manual = UserGame.manual(USER_ID, 220L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
|
||||
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual));
|
||||
steamReturns(game(90, 30), STEAM_ID);
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
package core_service.modules.library.service;
|
||||
|
||||
import core_service.modules.library.domain.GameImport;
|
||||
import core_service.modules.library.domain.PsnAccount;
|
||||
import core_service.modules.library.repo.PsnAccountRepository;
|
||||
import core_service.modules.psn_adapter.service.PsnApiService;
|
||||
import core_service.modules.psn_adapter.service.PsnAuthService;
|
||||
import core_service.shared.security.SecretBox;
|
||||
import core_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.boot.test.system.CapturedOutput;
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.client.HttpServerErrorException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(OutputCaptureExtension.class)
|
||||
class PsnAccountServiceTest extends AbstractUnitTest {
|
||||
|
||||
private static final String KEY = Base64.getEncoder().encodeToString("0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8));
|
||||
private static final String NPSSO = "NPSSO-SECRET-VALUE";
|
||||
private static final String OLD_REFRESH = "REFRESH-OLD-SECRET";
|
||||
private static final String NEW_REFRESH = "REFRESH-NEW-SECRET";
|
||||
private static final String ACCESS = "ACCESS-SECRET";
|
||||
private static final Instant EXPIRES = Instant.now().plus(Duration.ofDays(9));
|
||||
|
||||
@Mock
|
||||
private PsnAccountRepository repository;
|
||||
@Mock
|
||||
private PsnAuthService authService;
|
||||
@Mock
|
||||
private PsnApiService apiService;
|
||||
@Mock
|
||||
private GameImportService importService;
|
||||
|
||||
private final SecretBox box = SecretBox.fromBase64Key(KEY);
|
||||
private PsnAccountService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new PsnAccountService(repository, authService, apiService, importService, KEY);
|
||||
}
|
||||
|
||||
private PsnAuthService.Tokens tokens(String refresh, Instant refreshExpires) {
|
||||
return new PsnAuthService.Tokens(ACCESS, Instant.now().plusSeconds(3599), refresh, refreshExpires);
|
||||
}
|
||||
|
||||
private void linked(Instant expires) {
|
||||
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L, box.seal(OLD_REFRESH), expires, Instant.now())));
|
||||
}
|
||||
|
||||
private static void assertNoSecrets(CapturedOutput output) {
|
||||
assertThat(output.getAll()).doesNotContain(NPSSO).doesNotContain(OLD_REFRESH).doesNotContain(NEW_REFRESH)
|
||||
.doesNotContain(ACCESS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_SealsTheRefreshToken_AndLogsNoSecret(CapturedOutput output) {
|
||||
when(authService.exchangeNpsso(NPSSO)).thenReturn(Optional.of(tokens(NEW_REFRESH, EXPIRES)));
|
||||
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L, new byte[0], EXPIRES, Instant.now())));
|
||||
|
||||
PsnAccountService.LinkResult result = service.link(1L, NPSSO);
|
||||
|
||||
assertThat(result).isInstanceOf(PsnAccountService.LinkResult.Linked.class);
|
||||
ArgumentCaptor<byte[]> sealed = ArgumentCaptor.forClass(byte[].class);
|
||||
verify(repository).upsert(eq(1L), sealed.capture(), eq(EXPIRES));
|
||||
assertThat(new String(sealed.getValue(), StandardCharsets.ISO_8859_1)).doesNotContain(NEW_REFRESH);
|
||||
assertThat(box.open(sealed.getValue())).isEqualTo(NEW_REFRESH);
|
||||
assertNoSecrets(output);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_RejectedNpsso_StoresNothing_AndLogsNoSecret(CapturedOutput output) {
|
||||
when(authService.exchangeNpsso(NPSSO)).thenReturn(Optional.empty());
|
||||
|
||||
assertThat(service.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Rejected.class);
|
||||
|
||||
verify(repository, never()).upsert(anyLong(), any(), any());
|
||||
assertNoSecrets(output);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_PsnServerError_IsUnavailable_AndLogsNeitherBodyNorSecret(CapturedOutput output) {
|
||||
when(authService.exchangeNpsso(NPSSO)).thenThrow(HttpServerErrorException.create(HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"Internal", null, ("echo " + NPSSO + " " + OLD_REFRESH).getBytes(StandardCharsets.UTF_8), null));
|
||||
|
||||
assertThat(service.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Unavailable.class);
|
||||
|
||||
assertNoSecrets(output);
|
||||
assertThat(output.getAll()).contains("500");
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_WithoutAKey_IsOff_AndCallsNothing() {
|
||||
PsnAccountService off = new PsnAccountService(repository, authService, apiService, importService, "");
|
||||
|
||||
assertThat(off.isEnabled()).isFalse();
|
||||
assertThat(off.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Off.class);
|
||||
assertThat(off.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Off.class);
|
||||
assertThat(off.get(1L).enabled()).isFalse();
|
||||
verifyNoInteractions(authService, apiService, repository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_RejectsAKeyOfTheWrongLength() {
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
|
||||
new PsnAccountService(repository, authService, apiService, importService, "c2hvcnQ="))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void startImport_RefreshesReadsTitles_AndCreatesAPsnImport(CapturedOutput output) {
|
||||
linked(EXPIRES);
|
||||
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES.plusSeconds(5))));
|
||||
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of(new PsnApiService.Title("PPSA1_00", "Ghost",
|
||||
"ps5_native_game", Duration.ofMinutes(90), 7L)));
|
||||
when(importService.createLines(eq(1L), eq(GameImport.Source.PSN), any()))
|
||||
.thenReturn(new GameImportService.CreateResult.Created(42L));
|
||||
|
||||
PsnAccountService.ImportResult result = service.startImport(1L);
|
||||
|
||||
assertThat(result).isEqualTo(new PsnAccountService.ImportResult.Started(42L));
|
||||
ArgumentCaptor<List<GameImport.Line>> lines = ArgumentCaptor.captor();
|
||||
verify(importService).createLines(eq(1L), eq(GameImport.Source.PSN), lines.capture());
|
||||
assertThat(lines.getValue()).containsExactly(new GameImport.Line(1, "Ghost", "PS5", 90,
|
||||
core_service.modules.library.domain.GameStatus.PLAYING, null));
|
||||
assertNoSecrets(output);
|
||||
}
|
||||
|
||||
@Test
|
||||
void startImport_SameRefreshToken_KeepsTheStoredExpiry() {
|
||||
linked(EXPIRES);
|
||||
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES.plusSeconds(999))));
|
||||
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
|
||||
|
||||
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.NoTitles.class);
|
||||
|
||||
verify(repository, never()).updateToken(anyLong(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void startImport_NewRefreshToken_StoresItSealedWithItsExpiry(CapturedOutput output) {
|
||||
linked(EXPIRES);
|
||||
Instant newExpiry = Instant.now().plus(Duration.ofDays(10));
|
||||
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(NEW_REFRESH, newExpiry)));
|
||||
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
|
||||
|
||||
service.startImport(1L);
|
||||
|
||||
ArgumentCaptor<byte[]> sealed = ArgumentCaptor.forClass(byte[].class);
|
||||
verify(repository).updateToken(eq(1L), sealed.capture(), eq(newExpiry));
|
||||
assertThat(box.open(sealed.getValue())).isEqualTo(NEW_REFRESH);
|
||||
assertNoSecrets(output);
|
||||
}
|
||||
|
||||
@Test
|
||||
void startImport_ExpiredLink_IsRelink_WithoutAPsnCall() {
|
||||
linked(Instant.now().minusSeconds(1));
|
||||
|
||||
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Relink.class);
|
||||
|
||||
verifyNoInteractions(authService, apiService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void startImport_RefreshRefused_ExpiresTheLink_AndIsRelink() {
|
||||
linked(EXPIRES);
|
||||
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.empty());
|
||||
|
||||
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Relink.class);
|
||||
|
||||
verify(repository).expireNow(1L);
|
||||
verify(apiService, never()).getPlayedTitles(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void startImport_TokenSealedWithAnotherKey_IsRelink(CapturedOutput output) {
|
||||
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L,
|
||||
SecretBox.fromBase64Key(Base64.getEncoder().encodeToString(new byte[32])).seal(OLD_REFRESH), EXPIRES,
|
||||
Instant.now())));
|
||||
|
||||
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Relink.class);
|
||||
|
||||
verify(repository).expireNow(1L);
|
||||
assertNoSecrets(output);
|
||||
}
|
||||
|
||||
@Test
|
||||
void startImport_NotLinked() {
|
||||
when(repository.find(1L)).thenReturn(Optional.empty());
|
||||
|
||||
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.NotLinked.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void startImport_PsnServerError_IsUnavailable_AndLogsNoSecret(CapturedOutput output) {
|
||||
linked(EXPIRES);
|
||||
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES)));
|
||||
when(apiService.getPlayedTitles(ACCESS)).thenThrow(HttpServerErrorException.create(
|
||||
HttpStatus.BAD_GATEWAY, "Bad", null, (ACCESS + OLD_REFRESH).getBytes(StandardCharsets.UTF_8), null));
|
||||
|
||||
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Unavailable.class);
|
||||
|
||||
verify(importService, never()).createLines(anyLong(), any(), any());
|
||||
assertNoSecrets(output);
|
||||
}
|
||||
|
||||
@Test
|
||||
void startImport_MapsTheImportResults() {
|
||||
linked(EXPIRES);
|
||||
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES)));
|
||||
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of(new PsnApiService.Title("A_00", "A", "ps4_game",
|
||||
Duration.ZERO, null)));
|
||||
when(importService.createLines(eq(1L), any(), any()))
|
||||
.thenReturn(new GameImportService.CreateResult.AlreadyMatching())
|
||||
.thenReturn(new GameImportService.CreateResult.SearchUnavailable());
|
||||
|
||||
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.AlreadyMatching.class);
|
||||
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.SearchUnavailable.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_WhileAnotherCallOfTheSameUserRuns_IsBusy_WithoutTakingAPermit() throws Exception {
|
||||
java.util.concurrent.CountDownLatch entered = new java.util.concurrent.CountDownLatch(1);
|
||||
java.util.concurrent.CountDownLatch release = new java.util.concurrent.CountDownLatch(1);
|
||||
when(authService.exchangeNpsso(NPSSO)).thenAnswer(call -> {
|
||||
entered.countDown();
|
||||
release.await();
|
||||
return Optional.empty();
|
||||
});
|
||||
when(repository.find(2L)).thenReturn(Optional.empty());
|
||||
java.util.concurrent.CompletableFuture<PsnAccountService.LinkResult> first =
|
||||
java.util.concurrent.CompletableFuture.supplyAsync(() -> service.link(1L, NPSSO));
|
||||
entered.await();
|
||||
|
||||
assertThat(service.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Busy.class);
|
||||
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Busy.class);
|
||||
assertThat(service.startImport(2L)).isInstanceOf(PsnAccountService.ImportResult.NotLinked.class);
|
||||
release.countDown();
|
||||
|
||||
assertThat(first.get()).isInstanceOf(PsnAccountService.LinkResult.Rejected.class);
|
||||
verify(authService, org.mockito.Mockito.times(1)).exchangeNpsso(NPSSO);
|
||||
// The guard is free again.
|
||||
assertThat(service.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Rejected.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unlink_DeletesTheRow() {
|
||||
service.unlink(1L);
|
||||
|
||||
verify(repository).delete(1L);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package core_service.modules.library.service;
|
||||
|
||||
import core_service.modules.library.api.dto.ManualGameUpdate;
|
||||
import core_service.modules.library.domain.GameImport;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.psn_adapter.service.PsnApiService;
|
||||
import core_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PsnTitlesTest extends AbstractUnitTest {
|
||||
|
||||
private static PsnApiService.Title title(String id, String name, String category, String duration, Long concept) {
|
||||
return new PsnApiService.Title(id, name, category, Duration.parse(duration), concept);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(delimiter = '|', value = {
|
||||
"Celeste™|Celeste",
|
||||
"Ghost of Tsushima DIRECTORS CUT®|Ghost of Tsushima DIRECTORS CUT",
|
||||
"Hades PS4 & PS5|Hades",
|
||||
"Hades PS5 & PS4|Hades",
|
||||
"Control (PS5)|Control",
|
||||
"Control (ps4)|Control",
|
||||
"Returnal PS5 Edition|Returnal",
|
||||
"Doom - PS4 Version|Doom",
|
||||
"Spider-Man: Miles Morales|Spider-Man: Miles Morales",
|
||||
"Fez: PS4 Edition|Fez",
|
||||
"™|™"})
|
||||
void cleanName_StripsMarksAndPlatformSuffixes(String raw, String expected) {
|
||||
assertThat(PsnTitles.cleanName(raw)).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanName_CutsTo100Characters() {
|
||||
assertThat(PsnTitles.cleanName("x".repeat(150))).hasSize(100);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platform_Ps5NativeIsPs5_ElsePs4() {
|
||||
assertThat(PsnTitles.platform("ps5_native_game")).isEqualTo("PS5");
|
||||
assertThat(PsnTitles.platform("ps4_game")).isEqualTo("PS4");
|
||||
assertThat(PsnTitles.platform(null)).isEqualTo("PS4");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toLines_OneConceptIsOneLine_WithTheMostPlayedNameAndPlatform_AndTheSummedMinutes() {
|
||||
List<GameImport.Line> lines = PsnTitles.toLines(List.of(
|
||||
title("CUSA1_00", "Hades PS4", "ps4_game", "PT2H", 1L),
|
||||
title("PPSA1_00", "Hades™", "ps5_native_game", "PT65H9M15S", 1L),
|
||||
title("PPSA2_00", "Celeste", "ps5_native_game", "PT0S", 2L)));
|
||||
|
||||
assertThat(lines).hasSize(2);
|
||||
assertThat(lines.get(0)).isEqualTo(new GameImport.Line(1, "Hades", "PS5", 65 * 60 + 9 + 120,
|
||||
GameStatus.PLAYING, null));
|
||||
assertThat(lines.get(1)).isEqualTo(new GameImport.Line(2, "Celeste", "PS5", 0, GameStatus.BACKLOG, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void toLines_TitlesWithoutAConceptStaySeparate() {
|
||||
List<GameImport.Line> lines = PsnTitles.toLines(List.of(
|
||||
title("A_00", "A", "ps4_game", "PT1H", null), title("B_00", "B", "ps4_game", "PT1H", null)));
|
||||
|
||||
assertThat(lines).extracting(GameImport.Line::title).containsExactly("A", "B");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toLines_CapsTheMinutes_AndTheLineCount() {
|
||||
assertThat(PsnTitles.toLines(List.of(title("A_00", "A", "ps4_game", "PT999999H", 1L))).getFirst()
|
||||
.playtimeMinutes()).isEqualTo(ManualGameUpdate.MAX_MINUTES);
|
||||
|
||||
List<PsnApiService.Title> many = IntStream.range(0, 1200)
|
||||
.mapToObj(i -> title("T" + i, "Game " + i, "ps4_game", "PT1H", (long) i)).toList();
|
||||
assertThat(PsnTitles.toLines(many)).hasSize(1000);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package core_service.modules.library.service;
|
||||
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.StatGroup;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
@@ -61,7 +62,7 @@ class UserStatsServiceTrendTest extends AbstractPersistenceTest {
|
||||
@Test
|
||||
void thisMonth_FirstSteamImportAfterManualGames_IsNoAdd() {
|
||||
// Last month the user had only a game added by hand, so the nightly job wrote a snapshot then.
|
||||
userGameRepository.saveAndFlush(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
|
||||
userGameRepository.saveAndFlush(UserGame.manual(USER_ID, -1942L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
|
||||
jdbc.update("update user_game_status_change set changed_at = now() - interval '40 days' where appid = -1942");
|
||||
LocalDate today = LocalDate.now(ZoneId.of("Europe/Berlin"));
|
||||
jdbc.update("insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, " +
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package core_service.modules.psn_adapter.config;
|
||||
|
||||
import core_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PsnClientConfigTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void psnHttpClient_NeverFollowsARedirect() {
|
||||
assertThat(new PsnClientConfig().psnHttpClient().followRedirects()).isEqualTo(HttpClient.Redirect.NEVER);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package core_service.modules.psn_adapter.service;
|
||||
|
||||
import core_service.shared.test.AbstractUnitTest;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
class PsnApiServiceTest extends AbstractUnitTest {
|
||||
|
||||
private MockRestServiceServer server;
|
||||
private PsnApiService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
RestClient.Builder builder = RestClient.builder().baseUrl("http://psn.test/api");
|
||||
server = MockRestServiceServer.bindTo(builder).build();
|
||||
service = new PsnApiService(builder.build(), RateLimiterRegistry.ofDefaults());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPlayedTitles_FollowsNextOffset_WithBearerAndLimit200() {
|
||||
server.expect(requestTo(containsString("/gamelist/v2/users/me/titles?")))
|
||||
.andExpect(requestTo(containsString("limit=200")))
|
||||
.andExpect(requestTo(containsString("offset=0")))
|
||||
.andExpect(requestTo(containsString("categories=ps4_game,ps5_native_game")))
|
||||
.andExpect(header("Authorization", "Bearer ACCESS-1"))
|
||||
.andRespond(withSuccess("""
|
||||
{"titles":[{"titleId":"PPSA1_00","name":"Ghost","category":"ps5_native_game",
|
||||
"playDuration":"PT65H9M15S","concept":{"id":10,"titleIds":["PPSA1_00"]},"extra":1}],
|
||||
"totalItemCount":2,"nextOffset":200}""", MediaType.APPLICATION_JSON));
|
||||
server.expect(requestTo(containsString("offset=200")))
|
||||
.andRespond(withSuccess("""
|
||||
{"titles":[{"titleId":"CUSA2_00","name":"Mega","category":"ps4_game","playDuration":"PT2H"}],
|
||||
"totalItemCount":2,"nextOffset":null}""", MediaType.APPLICATION_JSON));
|
||||
|
||||
List<PsnApiService.Title> titles = service.getPlayedTitles("ACCESS-1");
|
||||
|
||||
assertThat(titles).extracting(PsnApiService.Title::titleId).containsExactly("PPSA1_00", "CUSA2_00");
|
||||
assertThat(titles.get(0).playDuration()).isEqualTo(Duration.parse("PT65H9M15S"));
|
||||
assertThat(titles.get(0).conceptId()).isEqualTo(10L);
|
||||
assertThat(titles.get(1).conceptId()).isNull();
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPlayedTitles_BadDuration_IsZero_AndTitlesWithoutIdOrNameAreSkipped() {
|
||||
server.expect(requestTo(containsString("/titles")))
|
||||
.andRespond(withSuccess("""
|
||||
{"titles":[{"titleId":"A_00","name":"A","playDuration":"nonsense"},
|
||||
{"name":"No id"},{"titleId":"B_00"}],"nextOffset":null}""",
|
||||
MediaType.APPLICATION_JSON));
|
||||
|
||||
List<PsnApiService.Title> titles = service.getPlayedTitles("t");
|
||||
|
||||
assertThat(titles).hasSize(1);
|
||||
assertThat(titles.getFirst().playDuration()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPlayedTitles_ServerErrorPropagates() {
|
||||
server.expect(requestTo(containsString("/titles"))).andRespond(withServerError());
|
||||
|
||||
assertThatThrownBy(() -> service.getPlayedTitles("t")).isInstanceOf(RestClientException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package core_service.modules.psn_adapter.service;
|
||||
|
||||
import core_service.modules.psn_adapter.api.dto.response.PsnTokenResponse;
|
||||
import core_service.shared.test.AbstractUnitTest;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
class PsnAuthServiceTest extends AbstractUnitTest {
|
||||
|
||||
private static final String BASIC = "Basic dGVzdC1pZDp0ZXN0LXNlY3JldA==";
|
||||
private static final String TOKENS = """
|
||||
{"access_token":"ACCESS-1","token_type":"bearer","expires_in":3599,"scope":"x","id_token":"ID-1",
|
||||
"refresh_token":"REFRESH-1","refresh_token_expires_in":863999}""";
|
||||
|
||||
private MockRestServiceServer server;
|
||||
private PsnAuthService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
RestClient.Builder builder = RestClient.builder().baseUrl("http://psn.test/oauth");
|
||||
server = MockRestServiceServer.bindTo(builder).build();
|
||||
service = new PsnAuthService(builder.build(), RateLimiterRegistry.ofDefaults(), "test-id", "test-secret",
|
||||
"app://redirect");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exchangeNpsso_SendsTheCookie_ReadsTheCode_AndPostsTheForm() {
|
||||
server.expect(requestTo(containsString("/oauth/authorize?")))
|
||||
.andExpect(method(org.springframework.http.HttpMethod.GET))
|
||||
.andExpect(header("Cookie", "npsso=NPSSO-1"))
|
||||
.andExpect(requestTo(containsString("access_type=offline")))
|
||||
.andExpect(requestTo(containsString("client_id=test-id")))
|
||||
.andExpect(requestTo(containsString("response_type=code")))
|
||||
.andRespond(withStatus(HttpStatus.FOUND).location(URI.create("app://redirect/?code=CODE-1&cid=x")));
|
||||
server.expect(requestTo("http://psn.test/oauth/token"))
|
||||
.andExpect(method(org.springframework.http.HttpMethod.POST))
|
||||
.andExpect(header("Authorization", BASIC))
|
||||
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_FORM_URLENCODED))
|
||||
.andExpect(content().string(containsString("code=CODE-1")))
|
||||
.andExpect(content().string(containsString("grant_type=authorization_code")))
|
||||
.andExpect(content().string(containsString("token_format=jwt")))
|
||||
.andRespond(withSuccess(TOKENS, MediaType.APPLICATION_JSON));
|
||||
|
||||
Optional<PsnAuthService.Tokens> tokens = service.exchangeNpsso("NPSSO-1");
|
||||
|
||||
assertThat(tokens).isPresent();
|
||||
assertThat(tokens.get().accessToken()).isEqualTo("ACCESS-1");
|
||||
assertThat(tokens.get().refreshToken()).isEqualTo("REFRESH-1");
|
||||
assertThat(tokens.get().refreshExpiresAt()).isBetween(Instant.now().plusSeconds(863_990),
|
||||
Instant.now().plusSeconds(864_010));
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exchangeNpsso_NoCodeInTheRedirect_IsEmpty() {
|
||||
server.expect(requestTo(containsString("/authorize")))
|
||||
.andRespond(withStatus(HttpStatus.FOUND).location(URI.create("app://redirect/?error=invalid_grant")));
|
||||
|
||||
assertThat(service.exchangeNpsso("bad")).isEmpty();
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exchangeNpsso_NoRedirect_IsEmpty() {
|
||||
server.expect(requestTo(containsString("/authorize"))).andRespond(withSuccess());
|
||||
|
||||
assertThat(service.exchangeNpsso("bad")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exchangeNpsso_TokenRefusedWith4xx_IsEmpty() {
|
||||
server.expect(requestTo(containsString("/authorize")))
|
||||
.andRespond(withStatus(HttpStatus.FOUND).location(URI.create("app://redirect/?code=CODE-1")));
|
||||
server.expect(requestTo(containsString("/token"))).andRespond(withStatus(HttpStatus.UNAUTHORIZED));
|
||||
|
||||
assertThat(service.exchangeNpsso("npsso")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exchangeNpsso_ServerErrorPropagates() {
|
||||
server.expect(requestTo(containsString("/authorize")))
|
||||
.andRespond(withStatus(HttpStatus.FOUND).location(URI.create("app://redirect/?code=CODE-1")));
|
||||
server.expect(requestTo(containsString("/token"))).andRespond(withServerError());
|
||||
|
||||
assertThatThrownBy(() -> service.exchangeNpsso("npsso")).isInstanceOf(RestClientException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exchangeNpsso_ValueTheJdkRefusesInAHeader_ThrowsWithoutTheValue() {
|
||||
String npsso = "SECRET\u200bNPSSO\nX";
|
||||
server.expect(requestTo(containsString("/authorize"))).andRespond(request -> {
|
||||
throw new IllegalArgumentException("invalid header value: \"npsso=" + npsso + "\"");
|
||||
});
|
||||
|
||||
assertThatThrownBy(() -> service.exchangeNpsso(npsso)).isInstanceOf(RestClientException.class)
|
||||
.hasMessageNotContaining("SECRET").hasNoCause();
|
||||
}
|
||||
|
||||
@Test
|
||||
void refresh_PostsTheRefreshToken_AndReturnsTheTokens() {
|
||||
server.expect(requestTo("http://psn.test/oauth/token"))
|
||||
.andExpect(header("Authorization", BASIC))
|
||||
.andExpect(content().string(containsString("refresh_token=REFRESH-0")))
|
||||
.andExpect(content().string(containsString("grant_type=refresh_token")))
|
||||
.andRespond(withSuccess(TOKENS, MediaType.APPLICATION_JSON));
|
||||
|
||||
Optional<PsnAuthService.Tokens> tokens = service.refresh("REFRESH-0");
|
||||
|
||||
assertThat(tokens).isPresent();
|
||||
assertThat(tokens.get().accessToken()).isEqualTo("ACCESS-1");
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void refresh_Refused_IsEmpty() {
|
||||
server.expect(requestTo(containsString("/token"))).andRespond(withStatus(HttpStatus.BAD_REQUEST));
|
||||
|
||||
assertThat(service.refresh("old")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void refresh_RateLimited_IsAFailureNotARefusal() {
|
||||
server.expect(requestTo(containsString("/token"))).andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS));
|
||||
|
||||
assertThatThrownBy(() -> service.refresh("old")).isInstanceOf(RestClientException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toString_OfTheTokenTypes_HoldsNoToken() {
|
||||
PsnTokenResponse response = new PsnTokenResponse("ACCESS-1", 3599, "REFRESH-1", 863999L);
|
||||
PsnAuthService.Tokens tokens = new PsnAuthService.Tokens("ACCESS-1", Instant.now(), "REFRESH-1", Instant.now());
|
||||
|
||||
assertThat(response.toString()).doesNotContain("ACCESS-1").doesNotContain("REFRESH-1").contains("863999");
|
||||
assertThat(tokens.toString()).doesNotContain("ACCESS-1").doesNotContain("REFRESH-1");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package core_service.modules.stats.repo;
|
||||
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
@@ -97,9 +98,9 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
|
||||
steamGameRepository.saveAllAndFlush(List.of(new SteamGame(8001L, "A", null, Instant.now()),
|
||||
new SteamGame(8002L, "B", null, Instant.now()), new SteamGame(8003L, "C", null, Instant.now())));
|
||||
userGameRepository.saveAllAndFlush(List.of(
|
||||
UserGame.manual(1L, 8001L, "ps5", GameStatus.BACKLOG, 10, Instant.now()),
|
||||
UserGame.manual(2L, 8002L, "PS5", GameStatus.BACKLOG, 20, Instant.now()),
|
||||
UserGame.manual(3L, 8003L, "Switch", GameStatus.BACKLOG, 20, Instant.now())));
|
||||
UserGame.manual(1L, 8001L, GameSource.MANUAL, "ps5", GameStatus.BACKLOG, 10, Instant.now()),
|
||||
UserGame.manual(2L, 8002L, GameSource.MANUAL, "PS5", GameStatus.BACKLOG, 20, Instant.now()),
|
||||
UserGame.manual(3L, 8003L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 20, Instant.now())));
|
||||
jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)");
|
||||
|
||||
List<Map<String, Object>> rows = statsRepository.gamesPerPlatform();
|
||||
@@ -154,8 +155,8 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
|
||||
new SteamGame(8003L, "C", null, Instant.now())));
|
||||
userGameRepository.saveAllAndFlush(List.of(
|
||||
new UserGame(5L, 220L, 100, Instant.now(), Instant.now()),
|
||||
UserGame.manual(5L, 8001L, "ps5", GameStatus.BACKLOG, 10, Instant.now()),
|
||||
UserGame.manual(3L, 8003L, "ps5", GameStatus.BACKLOG, 20, Instant.now())));
|
||||
UserGame.manual(5L, 8001L, GameSource.MANUAL, "ps5", GameStatus.BACKLOG, 10, Instant.now()),
|
||||
UserGame.manual(3L, 8003L, GameSource.MANUAL, "ps5", GameStatus.BACKLOG, 20, Instant.now())));
|
||||
jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)");
|
||||
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 5 AND appid = 220");
|
||||
gameMoodsRepository.upsert(220L, new String[]{}, new String[]{"RPG"}, false, Instant.now());
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package core_service.shared.security;
|
||||
|
||||
import core_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class SecretBoxTest extends AbstractUnitTest {
|
||||
|
||||
private static String newKey() {
|
||||
byte[] key = new byte[32];
|
||||
new SecureRandom().nextBytes(key);
|
||||
return Base64.getEncoder().encodeToString(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void open_GivesBackWhatSealSealed() {
|
||||
SecretBox box = SecretBox.fromBase64Key(newKey());
|
||||
|
||||
assertThat(box.open(box.seal("refresh-token-ä"))).isEqualTo("refresh-token-ä");
|
||||
}
|
||||
|
||||
@Test
|
||||
void seal_UsesAFreshNonceEachTime() {
|
||||
SecretBox box = SecretBox.fromBase64Key(newKey());
|
||||
|
||||
assertThat(box.seal("same")).isNotEqualTo(box.seal("same"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void seal_DoesNotHoldThePlainText() {
|
||||
byte[] sealed = SecretBox.fromBase64Key(newKey()).seal("a-very-recognizable-token");
|
||||
|
||||
assertThat(new String(sealed, java.nio.charset.StandardCharsets.ISO_8859_1)).doesNotContain("recognizable");
|
||||
assertThat(sealed[0]).isEqualTo((byte) 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void open_ThrowsForAChangedByte() {
|
||||
SecretBox box = SecretBox.fromBase64Key(newKey());
|
||||
byte[] sealed = box.seal("token");
|
||||
sealed[sealed.length - 1] ^= 1;
|
||||
|
||||
assertThatThrownBy(() -> box.open(sealed)).isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void open_ThrowsForAnotherKey() {
|
||||
byte[] sealed = SecretBox.fromBase64Key(newKey()).seal("token");
|
||||
|
||||
assertThatThrownBy(() -> SecretBox.fromBase64Key(newKey()).open(sealed))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void open_ThrowsForAnUnknownVersionOrShortBytes() {
|
||||
SecretBox box = SecretBox.fromBase64Key(newKey());
|
||||
byte[] sealed = box.seal("token");
|
||||
sealed[0] = 2;
|
||||
|
||||
assertThatThrownBy(() -> box.open(sealed)).isInstanceOf(IllegalStateException.class);
|
||||
assertThatThrownBy(() -> box.open(Arrays.copyOf(sealed, 5))).isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromBase64Key_RejectsAKeyThatIsNot32Bytes() {
|
||||
String shortKey = Base64.getEncoder().encodeToString(new byte[16]);
|
||||
|
||||
assertThatThrownBy(() -> SecretBox.fromBase64Key(shortKey)).isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("APP_PSN_TOKEN_KEY must be 32 bytes, base64")
|
||||
.hasMessageNotContaining(shortKey);
|
||||
assertThatThrownBy(() -> SecretBox.fromBase64Key("not base64 !!")).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void errorsAndToString_NeverHoldTheKeyOrTheBytes() {
|
||||
String key = newKey();
|
||||
SecretBox box = SecretBox.fromBase64Key(key);
|
||||
byte[] sealed = box.seal("token");
|
||||
sealed[sealed.length - 1] ^= 1;
|
||||
|
||||
assertThat(box.toString()).isEqualTo("SecretBox").doesNotContain(key);
|
||||
assertThatThrownBy(() -> box.open(sealed)).hasMessageNotContaining(key)
|
||||
.hasMessageNotContaining(Base64.getEncoder().encodeToString(sealed));
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,8 @@ public abstract class AbstractIntegrationTest {
|
||||
static void secrets(DynamicPropertyRegistry registry) {
|
||||
registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(TEST_KEYS.getPublic().getEncoded()));
|
||||
registry.add("app.steam.api-key", () -> "test-key");
|
||||
// 32 bytes, base64: a key for tests only.
|
||||
registry.add("app.psn.token-key", () -> "dGVzdC1vbmx5LXBzbi1rZXktMzItYnl0ZXMtISEhISE=");
|
||||
registry.add("app.library.achievement-sync-pause-ms", () -> "0");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package core_service.shared.test;
|
||||
|
||||
import core_service.modules.igdb_adapter.service.IgdbApiService;
|
||||
import core_service.modules.psn_adapter.service.PsnApiService;
|
||||
import core_service.modules.psn_adapter.service.PsnAuthService;
|
||||
import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
|
||||
import core_service.modules.steam_adapter.service.SteamApiService;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
/**
|
||||
* Integration tests with the three external APIs mocked, in one shared context. A mock answers empty, so a sync a
|
||||
* Integration tests with the external APIs mocked, in one shared context. A mock answers empty, so a sync a
|
||||
* request starts finds no games and leaves the library as it is.
|
||||
*/
|
||||
public abstract class AbstractMockedAdaptersTest extends AbstractIntegrationTest {
|
||||
@@ -19,4 +21,10 @@ public abstract class AbstractMockedAdaptersTest extends AbstractIntegrationTest
|
||||
|
||||
@MockitoBean
|
||||
protected SpeedrunApiService speedrunApiService;
|
||||
|
||||
@MockitoBean
|
||||
protected PsnAuthService psnAuthService;
|
||||
|
||||
@MockitoBean
|
||||
protected PsnApiService psnApiService;
|
||||
}
|
||||
Reference in new issue
Block a user