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

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

No files matched your search

@@ -27,7 +27,7 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
* Notes on Core-specific structure (differs from auth-service):
* - JPA entities live in `..domain..` (not `..persistence..`).
* - Repository interfaces live in `..repo..`.
* - `steam_adapter`, `igdb_adapter` and `speedrun_adapter` are outbound ports consumed by feature modules
* - `steam_adapter`, `igdb_adapter`, `speedrun_adapter` and `psn_adapter` are outbound ports consumed by feature modules
* (e.g. `library`), so strict slice independence is relaxed for it.
*/
@AnalyzeClasses(packages = "core_service", importOptions = ImportOption.DoNotIncludeTests.class)
@@ -44,8 +44,8 @@ class ArchitectureTest extends AbstractArchitectureTest {
.ignoreDependency(
JavaClass.Predicates.resideInAPackage("core_service.modules.library.."),
JavaClass.Predicates.resideInAnyPackage("core_service.modules.steam_adapter..", "core_service.modules.igdb_adapter..",
"core_service.modules.speedrun_adapter.."))
.because("Feature modules should be isolated; steam_adapter, igdb_adapter and speedrun_adapter are explicit shared ports.");
"core_service.modules.speedrun_adapter..", "core_service.modules.psn_adapter.."))
.because("Feature modules should be isolated; steam_adapter, igdb_adapter, speedrun_adapter and psn_adapter are explicit shared ports.");
@ArchTest
static final ArchRule no_cyclic_dependencies = SlicesRuleDefinition.slices()
@@ -74,7 +74,7 @@ class ArchitectureTest extends AbstractArchitectureTest {
.that().resideInAPackage("core_service.modules..domain..")
.should().dependOnClassesThat().resideInAnyPackage("..api..", "..service..", "..repo..",
"core_service.modules.steam_adapter..", "core_service.modules.igdb_adapter..",
"core_service.modules.speedrun_adapter..")
"core_service.modules.speedrun_adapter..", "core_service.modules.psn_adapter..")
.because("Entities and stored shapes must not change when a controller, service or external API changes.");
// The listed services build their module's response DTOs; the list only gets shorter.
@@ -93,7 +93,8 @@ class ArchitectureTest extends AbstractArchitectureTest {
.and(DescribedPredicate.not(JavaClass.Predicates.resideInAnyPackage(
"core_service.modules.steam_adapter.api.dto.response..",
"core_service.modules.igdb_adapter.api.dto.response..",
"core_service.modules.speedrun_adapter.api.dto.response.."))))
"core_service.modules.speedrun_adapter.api.dto.response..",
"core_service.modules.psn_adapter.api.dto.response.."))))
.because("Services must not depend on the HTTP layer; the adapters' response DTOs are the external APIs' shapes.");
@ArchTest
@@ -1,6 +1,8 @@
package core_service.configuration;
import core_service.modules.igdb_adapter.api.dto.response.IgdbResponses;
import core_service.modules.psn_adapter.api.dto.response.PsnGameListResponse;
import core_service.modules.psn_adapter.api.dto.response.PsnTokenResponse;
import core_service.modules.speedrun_adapter.api.dto.response.SpeedrunResponses;
import core_service.modules.steam_adapter.api.dto.response.SteamGlobalAchievementPercentagesResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
@@ -20,6 +22,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
@@ -47,9 +50,14 @@ class LocalMocksParseTest extends AbstractUnitTest {
Map.entry("igdb-tags", IgdbResponses.GameTags[].class),
Map.entry("igdb-editions", IgdbResponses.Edition[].class),
Map.entry("igdb-game-", IgdbResponses.Game[].class),
Map.entry("psn-token", PsnTokenResponse.class),
Map.entry("psn-gamelist", PsnGameListResponse.class),
Map.entry("speedrun-games-", SpeedrunResponses.Games.class),
Map.entry("speedrun-runs-", SpeedrunResponses.Runs.class));
// null is the answer's own meaning: PlayStation's last page has no nextOffset.
private static final Set<String> OPTIONAL_FIELDS = Set.of("nextOffset");
private final JsonMapper jsonMapper = JsonMapper.builder().build();
@Test
@@ -62,7 +70,9 @@ class LocalMocksParseTest extends AbstractUnitTest {
for (Path mapping : mappings) {
JsonNode response = jsonMapper.readTree(mapping.toFile()).path("response");
assertThat(response.has("bodyFileName") || response.has("jsonBody")).as(mapping.toString()).isTrue();
// The PlayStation authorize answer is a redirect with no body.
assertThat(response.has("bodyFileName") || response.has("jsonBody") || response.path("headers").has("Location"))
.as(mapping.toString()).isTrue();
if (response.has("bodyFileName")) {
assertThat(FILES.resolve(response.get("bodyFileName").asString())).as(mapping.toString()).exists();
}
@@ -80,6 +90,9 @@ class LocalMocksParseTest extends AbstractUnitTest {
return;
}
for (RecordComponent component : parsed.getClass().getRecordComponents()) {
if (OPTIONAL_FIELDS.contains(component.getName())) {
continue;
}
assertThat(component.getAccessor().invoke(parsed)).as(name + " " + component.getName()).isNotNull();
}
}
@@ -18,7 +18,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
"reward_equipped", "listed_user", "public_user", "follow", "follow_digest", "inbox_note");
"reward_equipped", "user_psn_account", "listed_user", "public_user", "follow", "follow_digest", "inbox_note");
@Autowired
private AccountCleanupRepository repository;
@@ -57,6 +57,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
jdbc.update("insert into inbox_note (user_id, kind, subject_user_id) values (?, 'DIGEST', ?)", userId, userId + 100);
jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'SAVED', 0)", userId);
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 20, now())", userId);
jdbc.update("insert into user_psn_account (user_id, refresh_token_enc, refresh_expires_at) values (?, '\\x01'::bytea, now())", userId);
jdbc.update("insert into reward_equipped (user_id, slot, item_code) values (?, 'BADGE', 'BADGE_PILE_SLAYER')", userId);
}
}
@@ -93,6 +94,14 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
assertThat(jdbc.queryForObject("select count(*) from inbox_note where kind = 'VISIBILITY'", Integer.class)).isZero();
}
@Test
void addDeleted_DropsTheStoredPsnTokenAtOnce() {
repository.addDeleted(List.of(1L));
assertThat(rows("user_psn_account", 1L)).isZero();
assertThat(rows("user_psn_account", 2L)).isEqualTo(1);
}
@Test
void deleteRows_AlsoDropsTheNotesAboutTheUser() {
jdbc.update("insert into inbox_note (user_id, kind, subject_user_id) values (2, 'VISIBILITY', 1)");
@@ -138,7 +147,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
Map<String, Integer> deletedRows = repository.deleteRows(List.of(1L));
// addDeleted already ended the follows and dropped the public profile.
List<String> endedByAddDeleted = List.of("public_user", "follow", "follow_digest");
List<String> endedByAddDeleted = List.of("public_user", "follow", "follow_digest", "user_psn_account");
USER_TABLES.stream().filter(table -> !endedByAddDeleted.contains(table))
.forEach(table -> assertThat(deletedRows.get(table)).as(table).isEqualTo(1));
assertThat(deletedRows.get("deleted_user")).isEqualTo(1);
@@ -496,6 +496,20 @@ class IgdbApiServiceTest extends AbstractUnitTest {
"https://images.igdb.com/igdb/image/upload/t_screenshot_med/ar1.jpg", List.of("PS5", "PC"), 1245620L));
}
@Test
void searchGames_SendsTheBodyAsUtf8() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(header("Content-Type", "text/plain;charset=UTF-8"))
.andExpect(request -> assertThat(((org.springframework.mock.http.client.MockClientHttpRequest) request)
.getBodyAsBytes()).containsSequence("Yōtei".getBytes(java.nio.charset.StandardCharsets.UTF_8)))
.andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
service.searchGames("Ghost of Yōtei");
igdb.verify();
}
@Test
void searchGames_PutsTheExactNameFirstThenTheGamesWithMoreRatings() {
expectToken();
@@ -169,7 +169,7 @@ class GameImportControllerTest extends AbstractMockedAdaptersTest {
@Test
void create_MatchesInTheBackground_SameNameFirstAndMarksGamesInTheLibrary() throws Exception {
jdbc.update("insert into steam_game (appid, name) values (1245620, 'ELDEN RING') on conflict do nothing");
userGameRepository.save(UserGame.manual(USER_ID, 1245620L, "PC", GameStatus.BACKLOG, 0, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, 1245620L, GameSource.MANUAL, "PC", GameStatus.BACKLOG, 0, Instant.now()));
long id = createReady("{\"source\": \"TEXT\", \"rows\": [{\"title\": \" Elden Ring \", \"platform\": \"PS5\"}, "
+ "{\"title\": \"Unknown Game\"}]}");
@@ -218,6 +218,11 @@ class GameImportControllerTest extends AbstractMockedAdaptersTest {
create("{\"source\": \"TEXT\", \"rows\": [{\"platform\": \"PC\"}]}").andExpect(status().isBadRequest());
}
@Test
void create_PsnSourceFromTheClient_Is400() throws Exception {
create("{\"source\": \"PSN\", \"rows\": [{\"title\": \"Elden Ring\"}]}").andExpect(status().isBadRequest());
}
@Test
void create_WhileAnotherImportIsMatching_Is409() throws Exception {
jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'MATCHING', 0)", USER_ID);
@@ -1,6 +1,7 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
@@ -90,7 +91,7 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
}
private void own(long userId, long appid) {
userGameRepository.save(UserGame.manual(userId, appid, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
userGameRepository.save(UserGame.manual(userId, appid, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
}
private MockHttpServletRequestBuilder as(MockHttpServletRequestBuilder request, long userId, Role role) {
@@ -105,7 +105,7 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
}
private UserGame manualRow(long userId, long appid, GameStatus status) {
UserGame game = UserGame.manual(userId, appid, "Switch", status, 600, Instant.now());
UserGame game = UserGame.manual(userId, appid, GameSource.MANUAL, "Switch", status, 600, Instant.now());
return userGameRepository.save(game);
}
@@ -136,7 +136,7 @@ class GameMergeUndoFlowTest extends AbstractIntegrationTest {
}
private UserGame manualRow(long userId, long appid, GameStatus status) {
return userGameRepository.save(UserGame.manual(userId, appid, "Switch", status, 600, Instant.now()));
return userGameRepository.save(UserGame.manual(userId, appid, GameSource.MANUAL, "Switch", status, 600, Instant.now()));
}
private UserGame steamRow(long userId, long appid, GameStatus status) {
@@ -1,5 +1,6 @@
package core_service.modules.library.api;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
@@ -57,7 +58,7 @@ class LibraryControllerComingTest extends AbstractMockedAdaptersTest {
gameReleaseRepository.deleteAll();
for (long appid : new long[]{COMING, COMING_SOON, RELEASED}) {
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, appid, "PC", GameStatus.BACKLOG, 0, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, appid, GameSource.MANUAL, "PC", GameStatus.BACKLOG, 0, Instant.now()));
}
gameReleaseRepository.upsert(COMING, TOMORROW, false, Instant.now());
gameReleaseRepository.upsert(COMING_SOON, null, true, Instant.now());
@@ -4,6 +4,7 @@ import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.domain.FinishedHintType;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame;
@@ -146,7 +147,7 @@ class LibraryControllerFinishedHintTest extends AbstractIntegrationTest {
@Test
void detail_GameAddedByHand_GetsThePlaytimeHint() throws Exception {
userGameRepository.save(UserGame.manual(USER_ID, MANUAL_GAME, "Switch", GameStatus.PLAYING, 600, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, MANUAL_GAME, GameSource.MANUAL, "Switch", GameStatus.PLAYING, 600, Instant.now()));
mockMvc.perform(get("/api/library/games/" + MANUAL_GAME))
.andExpect(jsonPath("$.finishedHint.type").value("PLAYTIME"))
@@ -1,6 +1,7 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
@@ -56,7 +57,7 @@ class LibraryControllerHiddenTest extends AbstractMockedAdaptersTest {
steamGameRepository.save(new SteamGame(-1942L, "Zelda", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 1000L, 0, Instant.now(), Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 60, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, -1942L, GameSource.MANUAL, "Switch", GameStatus.PLAYING, 60, Instant.now()));
}
private ResultActions setHidden(long appid, String body) throws Exception {
@@ -241,7 +241,7 @@ class ManualGameControllerTest extends AbstractMockedAdaptersTest {
@Test
void search_GameAddedBeforeIgdbKnewItOnSteam_IsInTheLibrary() throws Exception {
userGameRepository.save(UserGame.manual(USER_ID, -119388L, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, -119388L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
when(igdbApiService.searchGames("zelda")).thenReturn(Optional.of(List.of(new IgdbApiService.Game(119388,
ZELDA.name(), 2023, null, List.of("Switch"), 2_000_000L))));
@@ -404,7 +404,7 @@ class ManualGameControllerTest extends AbstractMockedAdaptersTest {
void add_MergedGameIgdbLaterFindsOnSteam_IsStillInTheLibrary() throws Exception {
steamGameRepository.save(new SteamGame(-1_900_400L, "Zelda TotK", null, Instant.now()));
jdbc.update("insert into game_alias (from_appid, to_appid) values (?, ?)", -119388L, -1_900_400L);
userGameRepository.save(UserGame.manual(USER_ID, -1_900_400L, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, -1_900_400L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
when(igdbApiService.getGame(119388)).thenReturn(Optional.of(new IgdbApiService.Game(119388, ZELDA.name(),
2023, null, List.of("Switch"), 2_000_000L)));
@@ -0,0 +1,276 @@
package core_service.modules.library.api;
import com.jayway.jsonpath.JsonPath;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.modules.psn_adapter.service.PsnAuthService;
import core_service.shared.security.SecretBox;
import core_service.shared.test.AbstractMockedAdaptersTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The dev profile authenticates every request as user 1. PlayStation is mocked at the two adapter services.
*/
@AutoConfigureMockMvc
class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
private static final String NPSSO = "NPSSO-SECRET-VALUE";
private static final String REFRESH = "REFRESH-SECRET-VALUE";
private static final String ACCESS = "ACCESS-SECRET-VALUE";
private static final IgdbApiService.Game GHOST = new IgdbApiService.Game(300, "Ghost of Yotei", 2025, null,
List.of("PS5"), null);
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private UserGameRepository userGameRepository;
@Value("${app.psn.token-key}")
private String tokenKey;
@BeforeEach
void seed() {
jdbc.update("delete from user_psn_account");
jdbc.update("delete from game_alias");
userGameRepository.deleteAll();
jdbc.update("delete from game_import");
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.searchGames("Ghost of Yotei")).thenReturn(Optional.of(List.of(GHOST)));
when(igdbApiService.getTimeToBeat(any())).thenReturn(Optional.of(Map.of()));
when(igdbApiService.getTimeToBeatByGameIds(any())).thenReturn(Optional.of(Map.of()));
when(psnAuthService.exchangeNpsso(NPSSO)).thenReturn(Optional.of(tokens(Instant.now().plus(Duration.ofDays(10)))));
when(psnAuthService.refresh(REFRESH)).thenReturn(Optional.of(tokens(Instant.now().plus(Duration.ofDays(10)))));
when(psnApiService.getPlayedTitles(ACCESS)).thenReturn(List.of(new PsnApiService.Title("PPSA1_00",
"Ghost of Yotei™", "ps5_native_game", Duration.ofMinutes(125), 10L)));
}
private static PsnAuthService.Tokens tokens(Instant refreshExpires) {
return new PsnAuthService.Tokens(ACCESS, Instant.now().plusSeconds(3599), REFRESH, refreshExpires);
}
private ResultActions link(String npsso) throws Exception {
return mockMvc.perform(post("/api/library/psn-account")
.header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"npsso\": \"" + npsso + "\"}"));
}
private ResultActions startImport() throws Exception {
return mockMvc.perform(post("/api/library/psn-account/imports").header(ClientHeaderFilter.CLIENT_HEADER, "test"));
}
private void awaitStatus(long id, String expected) throws InterruptedException {
for (int attempt = 0; attempt < 50; attempt++) {
if (expected.equals(jdbc.queryForObject("select status from game_import where id = ?", String.class, id))) {
return;
}
Thread.sleep(100);
}
throw new AssertionError("Import " + id + " not " + expected);
}
private long startedImport() throws Exception {
return ((Number) JsonPath.read(startImport().andExpect(status().isAccepted()).andReturn().getResponse()
.getContentAsString(), "$.id")).longValue();
}
@Test
void link_StoresOnlyTheSealedRefreshToken_AndTheAnswerHoldsNoSecret() throws Exception {
String body = link(NPSSO).andExpect(status().isOk())
.andExpect(jsonPath("$.enabled").value(true))
.andExpect(jsonPath("$.linked").value(true))
.andExpect(jsonPath("$.expiresAt").isNotEmpty())
.andReturn().getResponse().getContentAsString();
assertThat(body).doesNotContain(NPSSO).doesNotContain(REFRESH).doesNotContain(ACCESS);
byte[] stored = jdbc.queryForObject("select refresh_token_enc from user_psn_account where user_id = 1", byte[].class);
assertThat(new String(stored, StandardCharsets.ISO_8859_1)).doesNotContain(REFRESH).doesNotContain(NPSSO);
assertThat(SecretBox.fromBase64Key(tokenKey).open(stored)).isEqualTo(REFRESH);
}
@Test
void link_RejectedNpsso_Is400_AndStoresNothing() throws Exception {
link("bad-npsso").andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("NPSSO_INVALID"))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("bad-npsso"))));
mockMvc.perform(get("/api/library/psn-account")).andExpect(jsonPath("$.linked").value(false));
}
@Test
void link_BlankOrTooLongNpsso_Is400_WithoutEchoingIt() throws Exception {
link(" ").andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
String tooLong = "x".repeat(129);
String body = link(tooLong).andExpect(status().isBadRequest()).andReturn().getResponse().getContentAsString();
assertThat(body).doesNotContain(tooLong);
verify(psnAuthService, never()).exchangeNpsso(any());
}
@Test
void link_PsnDown_Is502() throws Exception {
when(psnAuthService.exchangeNpsso(NPSSO)).thenThrow(new org.springframework.web.client.ResourceAccessException("down"));
link(NPSSO).andExpect(status().isBadGateway()).andExpect(jsonPath("$.code").value("PSN_ERROR"));
}
@Test
void get_ThenDelete_UnlinksAndDeletesTheToken() throws Exception {
link(NPSSO).andExpect(status().isOk());
mockMvc.perform(get("/api/library/psn-account"))
.andExpect(jsonPath("$.linked").value(true))
.andExpect(jsonPath("$.linkedAt").isNotEmpty());
mockMvc.perform(delete("/api/library/psn-account").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isNoContent());
mockMvc.perform(get("/api/library/psn-account")).andExpect(jsonPath("$.linked").value(false));
assertThat(jdbc.queryForObject("select count(*) from user_psn_account", Integer.class)).isZero();
}
@Test
void postWithoutTheClientHeader_IsRefused() throws Exception {
mockMvc.perform(post("/api/library/psn-account").contentType(MediaType.APPLICATION_JSON)
.content("{\"npsso\": \"" + NPSSO + "\"}")).andExpect(status().isForbidden());
}
@Test
void import_StartsAPsnImportWithTheTitlesAsLines() throws Exception {
link(NPSSO).andExpect(status().isOk());
long id = startedImport();
assertThat(jdbc.queryForObject("select source from game_import where id = ?", String.class, id)).isEqualTo("PSN");
assertThat(jdbc.queryForMap("select title, platform, playtime_minutes from game_import_row where import_id = ?", id))
.containsEntry("title", "Ghost of Yotei").containsEntry("platform", "PS5")
.containsEntry("playtime_minutes", 125);
}
@Test
void import_NotLinked_Is404() throws Exception {
startImport().andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("PSN_NOT_LINKED"));
}
@Test
void import_ExpiredLink_Is409Relink_WithoutAPsnCall() throws Exception {
link(NPSSO).andExpect(status().isOk());
jdbc.update("update user_psn_account set refresh_expires_at = now() - interval '1 hour'");
startImport().andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("PSN_RELINK"));
verify(psnAuthService, never()).refresh(any());
}
@Test
void import_RefreshRefused_Is409Relink_AndTheLinkShowsAsExpired() throws Exception {
link(NPSSO).andExpect(status().isOk());
when(psnAuthService.refresh(REFRESH)).thenReturn(Optional.empty());
startImport().andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("PSN_RELINK"));
assertThat(jdbc.queryForObject("select refresh_expires_at <= now() from user_psn_account", Boolean.class)).isTrue();
}
@Test
void import_NoTitles_Is204() throws Exception {
link(NPSSO).andExpect(status().isOk());
when(psnApiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
startImport().andExpect(status().isNoContent());
}
@Test
void import_PsnDown_Is502_AndIgdbOff_Is503() throws Exception {
link(NPSSO).andExpect(status().isOk());
when(psnApiService.getPlayedTitles(ACCESS)).thenThrow(new org.springframework.web.client.ResourceAccessException("down"));
startImport().andExpect(status().isBadGateway()).andExpect(jsonPath("$.code").value("PSN_ERROR"));
doReturn(List.of(new PsnApiService.Title("A_00", "A", "ps4_game", Duration.ZERO, null)))
.when(psnApiService).getPlayedTitles(ACCESS);
when(igdbApiService.isEnabled()).thenReturn(false);
startImport().andExpect(status().isServiceUnavailable()).andExpect(jsonPath("$.code").value("IGDB_OFF"));
}
@Test
void savedPsnImport_AddsPsnRows_TheUserCanEditAndDelete_AndUndoRemoves() throws Exception {
link(NPSSO).andExpect(status().isOk());
long id = startedImport();
awaitStatus(id, "READY");
mockMvc.perform(post("/api/library/imports/" + id + "/save").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"rows\": [{\"lineNo\": 1, \"igdbId\": 300, \"platform\": \"PS5\", "
+ "\"status\": \"PLAYING\", \"playtimeMinutes\": 125}]}"))
.andExpect(status().isAccepted());
awaitStatus(id, "SAVED");
Map<String, Object> row = jdbc.queryForMap("select source, platform, playtime_minutes, import_id from user_game "
+ "where user_id = 1 and appid = -300");
assertThat(row).containsEntry("source", GameSource.PSN.name()).containsEntry("platform", "PS5")
.containsEntry("playtime_minutes", 125).containsEntry("import_id", id);
mockMvc.perform(get("/api/library/imports/" + id + "/undo-check")).andExpect(jsonPath("$.games").value(1));
mockMvc.perform(put("/api/library/games/-300/manual").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"platform\": \"PS4\", \"playtimeMinutes\": 200}"))
.andExpect(status().isNoContent());
assertThat(jdbc.queryForObject("select platform from user_game where appid = -300", String.class)).isEqualTo("PS4");
mockMvc.perform(delete("/api/library/imports/" + id + "/games").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isNoContent());
assertThat(jdbc.queryForObject("select count(*) from user_game where appid = -300", Integer.class)).isZero();
}
@Test
void psnRow_CanBeDeletedByHand() throws Exception {
jdbc.update("insert into steam_game (appid, name) values (-301, 'X') on conflict do nothing");
jdbc.update("insert into user_game (user_id, appid, playtime_minutes, first_seen_at, last_seen_at, source, platform, status, "
+ "status_set_by_user) values (1, -301, 0, now(), now(), 'PSN', 'PS5', 'BACKLOG', true)");
mockMvc.perform(delete("/api/library/games/-301").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isNoContent());
assertThat(jdbc.queryForObject("select count(*) from user_game where appid = -301", Integer.class)).isZero();
}
@Test
void npssoWithCharsNotAllowedInAHeader_Is400_AndNeverReachesTheAdapter() throws Exception {
link(NPSSO + "\\u200b").andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
link(NPSSO + "\\nX").andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
verify(psnAuthService, never()).exchangeNpsso(any());
}
}
@@ -1,5 +1,6 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame;
@@ -67,7 +68,7 @@ class BacklogSnapshotRepositoryTest extends AbstractPersistenceTest {
removed.setRemovedAt(Instant.now());
userGameRepository.saveAllAndFlush(List.of(
game(1L, 220L, 0, GameStatus.BACKLOG), hidden, removed,
UserGame.manual(2L, -1942L, "Switch", GameStatus.PLAYING, 0, Instant.now())));
UserGame.manual(2L, -1942L, GameSource.MANUAL, "Switch", GameStatus.PLAYING, 0, Instant.now())));
assertThat(repository.writeAll(DAY)).isEqualTo(2);
@@ -0,0 +1,65 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.PsnAccount;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import static org.assertj.core.api.Assertions.assertThat;
@Import(PsnAccountRepository.class)
class PsnAccountRepositoryTest extends AbstractPersistenceTest {
@Autowired
private PsnAccountRepository repository;
@Test
void upsert_StoresTheBytes_AndALaterLinkReplacesThem() {
Instant first = Instant.now().plus(10, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS);
Instant second = first.plus(1, ChronoUnit.DAYS);
repository.upsert(5L, new byte[]{1, 2, 3}, first);
repository.upsert(5L, new byte[]{4, 5}, second);
PsnAccount account = repository.find(5L).orElseThrow();
assertThat(account.refreshTokenEnc()).containsExactly(4, 5);
assertThat(account.refreshExpiresAt()).isEqualTo(second);
assertThat(account.linkedAt()).isNotNull();
}
@Test
void updateToken_ChangesTokenAndExpiry_AndKeepsLinkedAt() {
repository.upsert(6L, new byte[]{1}, Instant.now().plusSeconds(100));
Instant linkedAt = repository.find(6L).orElseThrow().linkedAt();
Instant expiry = Instant.now().plus(10, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS);
repository.updateToken(6L, new byte[]{9}, expiry);
PsnAccount account = repository.find(6L).orElseThrow();
assertThat(account.refreshTokenEnc()).containsExactly(9);
assertThat(account.refreshExpiresAt()).isEqualTo(expiry);
assertThat(account.linkedAt()).isEqualTo(linkedAt);
}
@Test
void expireNow_PutsTheExpiryInThePast() {
repository.upsert(7L, new byte[]{1}, Instant.now().plus(5, ChronoUnit.DAYS));
repository.expireNow(7L);
assertThat(repository.find(7L).orElseThrow().refreshExpiresAt()).isBeforeOrEqualTo(Instant.now());
}
@Test
void delete_RemovesTheRow() {
repository.upsert(8L, new byte[]{1}, Instant.now().plusSeconds(100));
assertThat(repository.delete(8L)).isEqualTo(1);
assertThat(repository.find(8L)).isEmpty();
assertThat(repository.delete(8L)).isZero();
}
}
@@ -1,5 +1,6 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
@@ -60,7 +61,7 @@ class StatusChangeLogTest extends AbstractPersistenceTest {
@Test
void manualAdd_LogsTheStatusByUser() {
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 0, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, -1942L, GameSource.MANUAL, "Switch", GameStatus.PLAYING, 0, Instant.now()));
assertThat(log()).containsExactly("null->PLAYING true");
}
@@ -101,7 +102,7 @@ class StatusChangeLogTest extends AbstractPersistenceTest {
@Test
void deletedGame_TakesItsLogRowsAlong() {
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 0, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, -1942L, GameSource.MANUAL, "Switch", GameStatus.PLAYING, 0, Instant.now()));
entityManager.flush();
userGameRepository.delete(game(-1942L));
@@ -117,7 +117,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Test
void softDeleteMissingSince_KeepsGamesAddedByHand() {
steamGameRepository.save(new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 600,
userGameRepository.save(UserGame.manual(USER_ID, -1942L, GameSource.MANUAL, "Switch", GameStatus.PLAYING, 600,
Instant.now().minusSeconds(3600)));
Instant syncStartedAt = Instant.now();
@@ -143,7 +143,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow().setHiddenAt(Instant.now());
userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow().setRemovedAt(Instant.now());
steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, 999L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, 999L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.now()));
userGameRepository.flush();
assertEquals(Set.of(220L, 620L),
@@ -164,7 +164,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Test
void findAchievementProgress_LeavesOutGamesAddedByHand() {
steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, 999L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, 999L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.now()));
assertTrue(userGameRepository.findAchievementProgress(USER_ID).stream().noneMatch(p -> p.appid() == 999L));
}
@@ -173,7 +173,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
void findRandomBacklogGameAndPicker_ExcludeNothingStillFindTheGameWithKeyMinusOne() {
userGameRepository.deleteAll();
steamGameRepository.save(new SteamGame(-1L, "IGDB game 1", null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, -1L, "PS2", GameStatus.BACKLOG, 0, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, -1L, GameSource.MANUAL, "PS2", GameStatus.BACKLOG, 0, Instant.now()));
assertEquals(-1L, userGameRepository.findRandomBacklogGame(USER_ID, UserGameRepository.NO_GAME).orElseThrow().getAppid());
assertEquals(1, userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 3).size());
@@ -1,6 +1,7 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame;
@@ -281,10 +282,10 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
Instant now = Instant.now();
userGameRepository.saveAllAndFlush(List.of(
game(220L, 100, GameStatus.BACKLOG, now), game(620L, 50, GameStatus.BACKLOG, now),
UserGame.manual(USER_ID, 8001L, "PS5", GameStatus.BACKLOG, 10, now),
UserGame.manual(USER_ID, 8002L, "PS5", GameStatus.BACKLOG, 20, now),
UserGame.manual(USER_ID, 8003L, "ps5 ", GameStatus.BACKLOG, 30, now),
UserGame.manual(USER_ID, 8004L, "Switch", GameStatus.BACKLOG, 5, now)));
UserGame.manual(USER_ID, 8001L, GameSource.MANUAL, "PS5", GameStatus.BACKLOG, 10, now),
UserGame.manual(USER_ID, 8002L, GameSource.MANUAL, "PS5", GameStatus.BACKLOG, 20, now),
UserGame.manual(USER_ID, 8003L, GameSource.MANUAL, "ps5 ", GameStatus.BACKLOG, 30, now),
UserGame.manual(USER_ID, 8004L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 5, now)));
seedHiddenAndRemoved();
assertThat(repository.gamesPerPlatform(USER_ID)).containsExactly(
@@ -332,7 +333,7 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
@Test
void pileMoves_GamesAddedByHandAreAdded() {
UserGame manual = UserGame.manual(USER_ID, 700L, "Switch", GameStatus.BACKLOG, 0, Instant.now());
UserGame manual = UserGame.manual(USER_ID, 700L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 0, Instant.now());
userGameRepository.saveAndFlush(manual);
assertThat(repository.pileMoves(USER_ID, Instant.now().minusSeconds(3600)))
@@ -4,6 +4,7 @@ import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
@@ -94,7 +95,7 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
@Test
void getGameDetail_GameAddedByHand_AsksSteamNothingAndShowsNoAchievements() {
userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, 620L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.now()));
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 5, true, Instant.now().minus(Duration.ofDays(31))));
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
@@ -120,7 +121,7 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
@Test
void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAndMoodsAgain() {
userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, 620L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.now()));
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getTimeToBeat(Set.of(620L))).thenReturn(Optional.of(Map.of()));
@@ -23,6 +23,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.InjectMocks;
@@ -234,9 +235,10 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
assertEquals(expected, saved.getValue().getStatus());
}
@Test
void runSync_GameAddedByHandIsNowOnSteam_BecomesASteamGameAndKeepsTheBacklog() {
UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.FINISHED, 60, Instant.EPOCH);
@ParameterizedTest
@EnumSource(value = GameSource.class, names = {"MANUAL", "PSN"})
void runSync_GameAddedByHandOrFromPsnIsNowOnSteam_BecomesASteamGameAndKeepsTheBacklog(GameSource source) {
UserGame manual = UserGame.manual(USER_ID, 220L, source, "PS5", GameStatus.FINISHED, 60, Instant.EPOCH);
manual.setRating(5);
manual.setNotes("Played on the couch");
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual));
@@ -254,7 +256,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Test
void runSync_GameAddedByHandIsNowOnSteam_TheHandTypedHoursAreNoPlaytimeGrowth() {
UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
UserGame manual = UserGame.manual(USER_ID, 220L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual));
steamReturns(game(90, 30), STEAM_ID);
@@ -675,7 +677,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
UserGame gone = new UserGame(USER_ID, 300L, 10, Instant.EPOCH, Instant.EPOCH);
UserGame alreadyRemoved = new UserGame(USER_ID, 400L, 10, Instant.EPOCH, Instant.EPOCH);
alreadyRemoved.setRemovedAt(Instant.EPOCH);
UserGame byHand = UserGame.manual(USER_ID, 500L, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
UserGame byHand = UserGame.manual(USER_ID, 500L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(grown, gone, alreadyRemoved, byHand));
when(userSteamSyncRepository.findForClaim(USER_ID)).thenReturn(Optional.of(syncRow(STEAM_ID, STEAM_ID)));
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
@@ -724,7 +726,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Test
void runSync_GameAddedByHandIsNowOnSteam_IsNeitherAddedNorPlaytime() {
UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
UserGame manual = UserGame.manual(USER_ID, 220L, GameSource.MANUAL, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual));
steamReturns(game(90, 30), STEAM_ID);
@@ -0,0 +1,272 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.PsnAccount;
import core_service.modules.library.repo.PsnAccountRepository;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.modules.psn_adapter.service.PsnAuthService;
import core_service.shared.security.SecretBox;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.http.HttpStatus;
import org.springframework.web.client.HttpServerErrorException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(OutputCaptureExtension.class)
class PsnAccountServiceTest extends AbstractUnitTest {
private static final String KEY = Base64.getEncoder().encodeToString("0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8));
private static final String NPSSO = "NPSSO-SECRET-VALUE";
private static final String OLD_REFRESH = "REFRESH-OLD-SECRET";
private static final String NEW_REFRESH = "REFRESH-NEW-SECRET";
private static final String ACCESS = "ACCESS-SECRET";
private static final Instant EXPIRES = Instant.now().plus(Duration.ofDays(9));
@Mock
private PsnAccountRepository repository;
@Mock
private PsnAuthService authService;
@Mock
private PsnApiService apiService;
@Mock
private GameImportService importService;
private final SecretBox box = SecretBox.fromBase64Key(KEY);
private PsnAccountService service;
@BeforeEach
void setUp() {
service = new PsnAccountService(repository, authService, apiService, importService, KEY);
}
private PsnAuthService.Tokens tokens(String refresh, Instant refreshExpires) {
return new PsnAuthService.Tokens(ACCESS, Instant.now().plusSeconds(3599), refresh, refreshExpires);
}
private void linked(Instant expires) {
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L, box.seal(OLD_REFRESH), expires, Instant.now())));
}
private static void assertNoSecrets(CapturedOutput output) {
assertThat(output.getAll()).doesNotContain(NPSSO).doesNotContain(OLD_REFRESH).doesNotContain(NEW_REFRESH)
.doesNotContain(ACCESS);
}
@Test
void link_SealsTheRefreshToken_AndLogsNoSecret(CapturedOutput output) {
when(authService.exchangeNpsso(NPSSO)).thenReturn(Optional.of(tokens(NEW_REFRESH, EXPIRES)));
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L, new byte[0], EXPIRES, Instant.now())));
PsnAccountService.LinkResult result = service.link(1L, NPSSO);
assertThat(result).isInstanceOf(PsnAccountService.LinkResult.Linked.class);
ArgumentCaptor<byte[]> sealed = ArgumentCaptor.forClass(byte[].class);
verify(repository).upsert(eq(1L), sealed.capture(), eq(EXPIRES));
assertThat(new String(sealed.getValue(), StandardCharsets.ISO_8859_1)).doesNotContain(NEW_REFRESH);
assertThat(box.open(sealed.getValue())).isEqualTo(NEW_REFRESH);
assertNoSecrets(output);
}
@Test
void link_RejectedNpsso_StoresNothing_AndLogsNoSecret(CapturedOutput output) {
when(authService.exchangeNpsso(NPSSO)).thenReturn(Optional.empty());
assertThat(service.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Rejected.class);
verify(repository, never()).upsert(anyLong(), any(), any());
assertNoSecrets(output);
}
@Test
void link_PsnServerError_IsUnavailable_AndLogsNeitherBodyNorSecret(CapturedOutput output) {
when(authService.exchangeNpsso(NPSSO)).thenThrow(HttpServerErrorException.create(HttpStatus.INTERNAL_SERVER_ERROR,
"Internal", null, ("echo " + NPSSO + " " + OLD_REFRESH).getBytes(StandardCharsets.UTF_8), null));
assertThat(service.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Unavailable.class);
assertNoSecrets(output);
assertThat(output.getAll()).contains("500");
}
@Test
void link_WithoutAKey_IsOff_AndCallsNothing() {
PsnAccountService off = new PsnAccountService(repository, authService, apiService, importService, "");
assertThat(off.isEnabled()).isFalse();
assertThat(off.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Off.class);
assertThat(off.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Off.class);
assertThat(off.get(1L).enabled()).isFalse();
verifyNoInteractions(authService, apiService, repository);
}
@Test
void constructor_RejectsAKeyOfTheWrongLength() {
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
new PsnAccountService(repository, authService, apiService, importService, "c2hvcnQ="))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void startImport_RefreshesReadsTitles_AndCreatesAPsnImport(CapturedOutput output) {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES.plusSeconds(5))));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of(new PsnApiService.Title("PPSA1_00", "Ghost",
"ps5_native_game", Duration.ofMinutes(90), 7L)));
when(importService.createLines(eq(1L), eq(GameImport.Source.PSN), any()))
.thenReturn(new GameImportService.CreateResult.Created(42L));
PsnAccountService.ImportResult result = service.startImport(1L);
assertThat(result).isEqualTo(new PsnAccountService.ImportResult.Started(42L));
ArgumentCaptor<List<GameImport.Line>> lines = ArgumentCaptor.captor();
verify(importService).createLines(eq(1L), eq(GameImport.Source.PSN), lines.capture());
assertThat(lines.getValue()).containsExactly(new GameImport.Line(1, "Ghost", "PS5", 90,
core_service.modules.library.domain.GameStatus.PLAYING, null));
assertNoSecrets(output);
}
@Test
void startImport_SameRefreshToken_KeepsTheStoredExpiry() {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES.plusSeconds(999))));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.NoTitles.class);
verify(repository, never()).updateToken(anyLong(), any(), any());
}
@Test
void startImport_NewRefreshToken_StoresItSealedWithItsExpiry(CapturedOutput output) {
linked(EXPIRES);
Instant newExpiry = Instant.now().plus(Duration.ofDays(10));
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(NEW_REFRESH, newExpiry)));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
service.startImport(1L);
ArgumentCaptor<byte[]> sealed = ArgumentCaptor.forClass(byte[].class);
verify(repository).updateToken(eq(1L), sealed.capture(), eq(newExpiry));
assertThat(box.open(sealed.getValue())).isEqualTo(NEW_REFRESH);
assertNoSecrets(output);
}
@Test
void startImport_ExpiredLink_IsRelink_WithoutAPsnCall() {
linked(Instant.now().minusSeconds(1));
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Relink.class);
verifyNoInteractions(authService, apiService);
}
@Test
void startImport_RefreshRefused_ExpiresTheLink_AndIsRelink() {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.empty());
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Relink.class);
verify(repository).expireNow(1L);
verify(apiService, never()).getPlayedTitles(any());
}
@Test
void startImport_TokenSealedWithAnotherKey_IsRelink(CapturedOutput output) {
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L,
SecretBox.fromBase64Key(Base64.getEncoder().encodeToString(new byte[32])).seal(OLD_REFRESH), EXPIRES,
Instant.now())));
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Relink.class);
verify(repository).expireNow(1L);
assertNoSecrets(output);
}
@Test
void startImport_NotLinked() {
when(repository.find(1L)).thenReturn(Optional.empty());
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.NotLinked.class);
}
@Test
void startImport_PsnServerError_IsUnavailable_AndLogsNoSecret(CapturedOutput output) {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES)));
when(apiService.getPlayedTitles(ACCESS)).thenThrow(HttpServerErrorException.create(
HttpStatus.BAD_GATEWAY, "Bad", null, (ACCESS + OLD_REFRESH).getBytes(StandardCharsets.UTF_8), null));
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Unavailable.class);
verify(importService, never()).createLines(anyLong(), any(), any());
assertNoSecrets(output);
}
@Test
void startImport_MapsTheImportResults() {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES)));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of(new PsnApiService.Title("A_00", "A", "ps4_game",
Duration.ZERO, null)));
when(importService.createLines(eq(1L), any(), any()))
.thenReturn(new GameImportService.CreateResult.AlreadyMatching())
.thenReturn(new GameImportService.CreateResult.SearchUnavailable());
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.AlreadyMatching.class);
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.SearchUnavailable.class);
}
@Test
void link_WhileAnotherCallOfTheSameUserRuns_IsBusy_WithoutTakingAPermit() throws Exception {
java.util.concurrent.CountDownLatch entered = new java.util.concurrent.CountDownLatch(1);
java.util.concurrent.CountDownLatch release = new java.util.concurrent.CountDownLatch(1);
when(authService.exchangeNpsso(NPSSO)).thenAnswer(call -> {
entered.countDown();
release.await();
return Optional.empty();
});
when(repository.find(2L)).thenReturn(Optional.empty());
java.util.concurrent.CompletableFuture<PsnAccountService.LinkResult> first =
java.util.concurrent.CompletableFuture.supplyAsync(() -> service.link(1L, NPSSO));
entered.await();
assertThat(service.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Busy.class);
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Busy.class);
assertThat(service.startImport(2L)).isInstanceOf(PsnAccountService.ImportResult.NotLinked.class);
release.countDown();
assertThat(first.get()).isInstanceOf(PsnAccountService.LinkResult.Rejected.class);
verify(authService, org.mockito.Mockito.times(1)).exchangeNpsso(NPSSO);
// The guard is free again.
assertThat(service.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Rejected.class);
}
@Test
void unlink_DeletesTheRow() {
service.unlink(1L);
verify(repository).delete(1L);
}
}
@@ -0,0 +1,83 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.ManualGameUpdate;
import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.time.Duration;
import java.util.List;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
class PsnTitlesTest extends AbstractUnitTest {
private static PsnApiService.Title title(String id, String name, String category, String duration, Long concept) {
return new PsnApiService.Title(id, name, category, Duration.parse(duration), concept);
}
@ParameterizedTest
@CsvSource(delimiter = '|', value = {
"Celeste™|Celeste",
"Ghost of Tsushima DIRECTORS CUT®|Ghost of Tsushima DIRECTORS CUT",
"Hades PS4 & PS5|Hades",
"Hades PS5 & PS4|Hades",
"Control (PS5)|Control",
"Control (ps4)|Control",
"Returnal PS5 Edition|Returnal",
"Doom - PS4 Version|Doom",
"Spider-Man: Miles Morales|Spider-Man: Miles Morales",
"Fez: PS4 Edition|Fez",
"™|™"})
void cleanName_StripsMarksAndPlatformSuffixes(String raw, String expected) {
assertThat(PsnTitles.cleanName(raw)).isEqualTo(expected);
}
@Test
void cleanName_CutsTo100Characters() {
assertThat(PsnTitles.cleanName("x".repeat(150))).hasSize(100);
}
@Test
void platform_Ps5NativeIsPs5_ElsePs4() {
assertThat(PsnTitles.platform("ps5_native_game")).isEqualTo("PS5");
assertThat(PsnTitles.platform("ps4_game")).isEqualTo("PS4");
assertThat(PsnTitles.platform(null)).isEqualTo("PS4");
}
@Test
void toLines_OneConceptIsOneLine_WithTheMostPlayedNameAndPlatform_AndTheSummedMinutes() {
List<GameImport.Line> lines = PsnTitles.toLines(List.of(
title("CUSA1_00", "Hades PS4", "ps4_game", "PT2H", 1L),
title("PPSA1_00", "Hades™", "ps5_native_game", "PT65H9M15S", 1L),
title("PPSA2_00", "Celeste", "ps5_native_game", "PT0S", 2L)));
assertThat(lines).hasSize(2);
assertThat(lines.get(0)).isEqualTo(new GameImport.Line(1, "Hades", "PS5", 65 * 60 + 9 + 120,
GameStatus.PLAYING, null));
assertThat(lines.get(1)).isEqualTo(new GameImport.Line(2, "Celeste", "PS5", 0, GameStatus.BACKLOG, null));
}
@Test
void toLines_TitlesWithoutAConceptStaySeparate() {
List<GameImport.Line> lines = PsnTitles.toLines(List.of(
title("A_00", "A", "ps4_game", "PT1H", null), title("B_00", "B", "ps4_game", "PT1H", null)));
assertThat(lines).extracting(GameImport.Line::title).containsExactly("A", "B");
}
@Test
void toLines_CapsTheMinutes_AndTheLineCount() {
assertThat(PsnTitles.toLines(List.of(title("A_00", "A", "ps4_game", "PT999999H", 1L))).getFirst()
.playtimeMinutes()).isEqualTo(ManualGameUpdate.MAX_MINUTES);
List<PsnApiService.Title> many = IntStream.range(0, 1200)
.mapToObj(i -> title("T" + i, "Game " + i, "ps4_game", "PT1H", (long) i)).toList();
assertThat(PsnTitles.toLines(many)).hasSize(1000);
}
}
@@ -1,5 +1,6 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.domain.SteamGame;
@@ -61,7 +62,7 @@ class UserStatsServiceTrendTest extends AbstractPersistenceTest {
@Test
void thisMonth_FirstSteamImportAfterManualGames_IsNoAdd() {
// Last month the user had only a game added by hand, so the nightly job wrote a snapshot then.
userGameRepository.saveAndFlush(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
userGameRepository.saveAndFlush(UserGame.manual(USER_ID, -1942L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
jdbc.update("update user_game_status_change set changed_at = now() - interval '40 days' where appid = -1942");
LocalDate today = LocalDate.now(ZoneId.of("Europe/Berlin"));
jdbc.update("insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, " +
@@ -0,0 +1,16 @@
package core_service.modules.psn_adapter.config;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.net.http.HttpClient;
import static org.assertj.core.api.Assertions.assertThat;
class PsnClientConfigTest extends AbstractUnitTest {
@Test
void psnHttpClient_NeverFollowsARedirect() {
assertThat(new PsnClientConfig().psnHttpClient().followRedirects()).isEqualTo(HttpClient.Redirect.NEVER);
}
}
@@ -0,0 +1,80 @@
package core_service.modules.psn_adapter.service;
import core_service.shared.test.AbstractUnitTest;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.time.Duration;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class PsnApiServiceTest extends AbstractUnitTest {
private MockRestServiceServer server;
private PsnApiService service;
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder().baseUrl("http://psn.test/api");
server = MockRestServiceServer.bindTo(builder).build();
service = new PsnApiService(builder.build(), RateLimiterRegistry.ofDefaults());
}
@Test
void getPlayedTitles_FollowsNextOffset_WithBearerAndLimit200() {
server.expect(requestTo(containsString("/gamelist/v2/users/me/titles?")))
.andExpect(requestTo(containsString("limit=200")))
.andExpect(requestTo(containsString("offset=0")))
.andExpect(requestTo(containsString("categories=ps4_game,ps5_native_game")))
.andExpect(header("Authorization", "Bearer ACCESS-1"))
.andRespond(withSuccess("""
{"titles":[{"titleId":"PPSA1_00","name":"Ghost","category":"ps5_native_game",
"playDuration":"PT65H9M15S","concept":{"id":10,"titleIds":["PPSA1_00"]},"extra":1}],
"totalItemCount":2,"nextOffset":200}""", MediaType.APPLICATION_JSON));
server.expect(requestTo(containsString("offset=200")))
.andRespond(withSuccess("""
{"titles":[{"titleId":"CUSA2_00","name":"Mega","category":"ps4_game","playDuration":"PT2H"}],
"totalItemCount":2,"nextOffset":null}""", MediaType.APPLICATION_JSON));
List<PsnApiService.Title> titles = service.getPlayedTitles("ACCESS-1");
assertThat(titles).extracting(PsnApiService.Title::titleId).containsExactly("PPSA1_00", "CUSA2_00");
assertThat(titles.get(0).playDuration()).isEqualTo(Duration.parse("PT65H9M15S"));
assertThat(titles.get(0).conceptId()).isEqualTo(10L);
assertThat(titles.get(1).conceptId()).isNull();
server.verify();
}
@Test
void getPlayedTitles_BadDuration_IsZero_AndTitlesWithoutIdOrNameAreSkipped() {
server.expect(requestTo(containsString("/titles")))
.andRespond(withSuccess("""
{"titles":[{"titleId":"A_00","name":"A","playDuration":"nonsense"},
{"name":"No id"},{"titleId":"B_00"}],"nextOffset":null}""",
MediaType.APPLICATION_JSON));
List<PsnApiService.Title> titles = service.getPlayedTitles("t");
assertThat(titles).hasSize(1);
assertThat(titles.getFirst().playDuration()).isZero();
}
@Test
void getPlayedTitles_ServerErrorPropagates() {
server.expect(requestTo(containsString("/titles"))).andRespond(withServerError());
assertThatThrownBy(() -> service.getPlayedTitles("t")).isInstanceOf(RestClientException.class);
}
}
@@ -0,0 +1,157 @@
package core_service.modules.psn_adapter.service;
import core_service.modules.psn_adapter.api.dto.response.PsnTokenResponse;
import core_service.shared.test.AbstractUnitTest;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.net.URI;
import java.time.Instant;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class PsnAuthServiceTest extends AbstractUnitTest {
private static final String BASIC = "Basic dGVzdC1pZDp0ZXN0LXNlY3JldA==";
private static final String TOKENS = """
{"access_token":"ACCESS-1","token_type":"bearer","expires_in":3599,"scope":"x","id_token":"ID-1",
"refresh_token":"REFRESH-1","refresh_token_expires_in":863999}""";
private MockRestServiceServer server;
private PsnAuthService service;
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder().baseUrl("http://psn.test/oauth");
server = MockRestServiceServer.bindTo(builder).build();
service = new PsnAuthService(builder.build(), RateLimiterRegistry.ofDefaults(), "test-id", "test-secret",
"app://redirect");
}
@Test
void exchangeNpsso_SendsTheCookie_ReadsTheCode_AndPostsTheForm() {
server.expect(requestTo(containsString("/oauth/authorize?")))
.andExpect(method(org.springframework.http.HttpMethod.GET))
.andExpect(header("Cookie", "npsso=NPSSO-1"))
.andExpect(requestTo(containsString("access_type=offline")))
.andExpect(requestTo(containsString("client_id=test-id")))
.andExpect(requestTo(containsString("response_type=code")))
.andRespond(withStatus(HttpStatus.FOUND).location(URI.create("app://redirect/?code=CODE-1&cid=x")));
server.expect(requestTo("http://psn.test/oauth/token"))
.andExpect(method(org.springframework.http.HttpMethod.POST))
.andExpect(header("Authorization", BASIC))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_FORM_URLENCODED))
.andExpect(content().string(containsString("code=CODE-1")))
.andExpect(content().string(containsString("grant_type=authorization_code")))
.andExpect(content().string(containsString("token_format=jwt")))
.andRespond(withSuccess(TOKENS, MediaType.APPLICATION_JSON));
Optional<PsnAuthService.Tokens> tokens = service.exchangeNpsso("NPSSO-1");
assertThat(tokens).isPresent();
assertThat(tokens.get().accessToken()).isEqualTo("ACCESS-1");
assertThat(tokens.get().refreshToken()).isEqualTo("REFRESH-1");
assertThat(tokens.get().refreshExpiresAt()).isBetween(Instant.now().plusSeconds(863_990),
Instant.now().plusSeconds(864_010));
server.verify();
}
@Test
void exchangeNpsso_NoCodeInTheRedirect_IsEmpty() {
server.expect(requestTo(containsString("/authorize")))
.andRespond(withStatus(HttpStatus.FOUND).location(URI.create("app://redirect/?error=invalid_grant")));
assertThat(service.exchangeNpsso("bad")).isEmpty();
server.verify();
}
@Test
void exchangeNpsso_NoRedirect_IsEmpty() {
server.expect(requestTo(containsString("/authorize"))).andRespond(withSuccess());
assertThat(service.exchangeNpsso("bad")).isEmpty();
}
@Test
void exchangeNpsso_TokenRefusedWith4xx_IsEmpty() {
server.expect(requestTo(containsString("/authorize")))
.andRespond(withStatus(HttpStatus.FOUND).location(URI.create("app://redirect/?code=CODE-1")));
server.expect(requestTo(containsString("/token"))).andRespond(withStatus(HttpStatus.UNAUTHORIZED));
assertThat(service.exchangeNpsso("npsso")).isEmpty();
}
@Test
void exchangeNpsso_ServerErrorPropagates() {
server.expect(requestTo(containsString("/authorize")))
.andRespond(withStatus(HttpStatus.FOUND).location(URI.create("app://redirect/?code=CODE-1")));
server.expect(requestTo(containsString("/token"))).andRespond(withServerError());
assertThatThrownBy(() -> service.exchangeNpsso("npsso")).isInstanceOf(RestClientException.class);
}
@Test
void exchangeNpsso_ValueTheJdkRefusesInAHeader_ThrowsWithoutTheValue() {
String npsso = "SECRET\u200bNPSSO\nX";
server.expect(requestTo(containsString("/authorize"))).andRespond(request -> {
throw new IllegalArgumentException("invalid header value: \"npsso=" + npsso + "\"");
});
assertThatThrownBy(() -> service.exchangeNpsso(npsso)).isInstanceOf(RestClientException.class)
.hasMessageNotContaining("SECRET").hasNoCause();
}
@Test
void refresh_PostsTheRefreshToken_AndReturnsTheTokens() {
server.expect(requestTo("http://psn.test/oauth/token"))
.andExpect(header("Authorization", BASIC))
.andExpect(content().string(containsString("refresh_token=REFRESH-0")))
.andExpect(content().string(containsString("grant_type=refresh_token")))
.andRespond(withSuccess(TOKENS, MediaType.APPLICATION_JSON));
Optional<PsnAuthService.Tokens> tokens = service.refresh("REFRESH-0");
assertThat(tokens).isPresent();
assertThat(tokens.get().accessToken()).isEqualTo("ACCESS-1");
server.verify();
}
@Test
void refresh_Refused_IsEmpty() {
server.expect(requestTo(containsString("/token"))).andRespond(withStatus(HttpStatus.BAD_REQUEST));
assertThat(service.refresh("old")).isEmpty();
}
@Test
void refresh_RateLimited_IsAFailureNotARefusal() {
server.expect(requestTo(containsString("/token"))).andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS));
assertThatThrownBy(() -> service.refresh("old")).isInstanceOf(RestClientException.class);
}
@Test
void toString_OfTheTokenTypes_HoldsNoToken() {
PsnTokenResponse response = new PsnTokenResponse("ACCESS-1", 3599, "REFRESH-1", 863999L);
PsnAuthService.Tokens tokens = new PsnAuthService.Tokens("ACCESS-1", Instant.now(), "REFRESH-1", Instant.now());
assertThat(response.toString()).doesNotContain("ACCESS-1").doesNotContain("REFRESH-1").contains("863999");
assertThat(tokens.toString()).doesNotContain("ACCESS-1").doesNotContain("REFRESH-1");
}
}
@@ -1,5 +1,6 @@
package core_service.modules.stats.repo;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
@@ -97,9 +98,9 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
steamGameRepository.saveAllAndFlush(List.of(new SteamGame(8001L, "A", null, Instant.now()),
new SteamGame(8002L, "B", null, Instant.now()), new SteamGame(8003L, "C", null, Instant.now())));
userGameRepository.saveAllAndFlush(List.of(
UserGame.manual(1L, 8001L, "ps5", GameStatus.BACKLOG, 10, Instant.now()),
UserGame.manual(2L, 8002L, "PS5", GameStatus.BACKLOG, 20, Instant.now()),
UserGame.manual(3L, 8003L, "Switch", GameStatus.BACKLOG, 20, Instant.now())));
UserGame.manual(1L, 8001L, GameSource.MANUAL, "ps5", GameStatus.BACKLOG, 10, Instant.now()),
UserGame.manual(2L, 8002L, GameSource.MANUAL, "PS5", GameStatus.BACKLOG, 20, Instant.now()),
UserGame.manual(3L, 8003L, GameSource.MANUAL, "Switch", GameStatus.BACKLOG, 20, Instant.now())));
jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)");
List<Map<String, Object>> rows = statsRepository.gamesPerPlatform();
@@ -154,8 +155,8 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
new SteamGame(8003L, "C", null, Instant.now())));
userGameRepository.saveAllAndFlush(List.of(
new UserGame(5L, 220L, 100, Instant.now(), Instant.now()),
UserGame.manual(5L, 8001L, "ps5", GameStatus.BACKLOG, 10, Instant.now()),
UserGame.manual(3L, 8003L, "ps5", GameStatus.BACKLOG, 20, Instant.now())));
UserGame.manual(5L, 8001L, GameSource.MANUAL, "ps5", GameStatus.BACKLOG, 10, Instant.now()),
UserGame.manual(3L, 8003L, GameSource.MANUAL, "ps5", GameStatus.BACKLOG, 20, Instant.now())));
jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)");
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 5 AND appid = 220");
gameMoodsRepository.upsert(220L, new String[]{}, new String[]{"RPG"}, false, Instant.now());
@@ -0,0 +1,91 @@
package core_service.shared.security;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class SecretBoxTest extends AbstractUnitTest {
private static String newKey() {
byte[] key = new byte[32];
new SecureRandom().nextBytes(key);
return Base64.getEncoder().encodeToString(key);
}
@Test
void open_GivesBackWhatSealSealed() {
SecretBox box = SecretBox.fromBase64Key(newKey());
assertThat(box.open(box.seal("refresh-token-ä"))).isEqualTo("refresh-token-ä");
}
@Test
void seal_UsesAFreshNonceEachTime() {
SecretBox box = SecretBox.fromBase64Key(newKey());
assertThat(box.seal("same")).isNotEqualTo(box.seal("same"));
}
@Test
void seal_DoesNotHoldThePlainText() {
byte[] sealed = SecretBox.fromBase64Key(newKey()).seal("a-very-recognizable-token");
assertThat(new String(sealed, java.nio.charset.StandardCharsets.ISO_8859_1)).doesNotContain("recognizable");
assertThat(sealed[0]).isEqualTo((byte) 1);
}
@Test
void open_ThrowsForAChangedByte() {
SecretBox box = SecretBox.fromBase64Key(newKey());
byte[] sealed = box.seal("token");
sealed[sealed.length - 1] ^= 1;
assertThatThrownBy(() -> box.open(sealed)).isInstanceOf(IllegalStateException.class);
}
@Test
void open_ThrowsForAnotherKey() {
byte[] sealed = SecretBox.fromBase64Key(newKey()).seal("token");
assertThatThrownBy(() -> SecretBox.fromBase64Key(newKey()).open(sealed))
.isInstanceOf(IllegalStateException.class);
}
@Test
void open_ThrowsForAnUnknownVersionOrShortBytes() {
SecretBox box = SecretBox.fromBase64Key(newKey());
byte[] sealed = box.seal("token");
sealed[0] = 2;
assertThatThrownBy(() -> box.open(sealed)).isInstanceOf(IllegalStateException.class);
assertThatThrownBy(() -> box.open(Arrays.copyOf(sealed, 5))).isInstanceOf(IllegalStateException.class);
}
@Test
void fromBase64Key_RejectsAKeyThatIsNot32Bytes() {
String shortKey = Base64.getEncoder().encodeToString(new byte[16]);
assertThatThrownBy(() -> SecretBox.fromBase64Key(shortKey)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("APP_PSN_TOKEN_KEY must be 32 bytes, base64")
.hasMessageNotContaining(shortKey);
assertThatThrownBy(() -> SecretBox.fromBase64Key("not base64 !!")).isInstanceOf(IllegalArgumentException.class);
}
@Test
void errorsAndToString_NeverHoldTheKeyOrTheBytes() {
String key = newKey();
SecretBox box = SecretBox.fromBase64Key(key);
byte[] sealed = box.seal("token");
sealed[sealed.length - 1] ^= 1;
assertThat(box.toString()).isEqualTo("SecretBox").doesNotContain(key);
assertThatThrownBy(() -> box.open(sealed)).hasMessageNotContaining(key)
.hasMessageNotContaining(Base64.getEncoder().encodeToString(sealed));
}
}
@@ -108,6 +108,8 @@ public abstract class AbstractIntegrationTest {
static void secrets(DynamicPropertyRegistry registry) {
registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(TEST_KEYS.getPublic().getEncoded()));
registry.add("app.steam.api-key", () -> "test-key");
// 32 bytes, base64: a key for tests only.
registry.add("app.psn.token-key", () -> "dGVzdC1vbmx5LXBzbi1rZXktMzItYnl0ZXMtISEhISE=");
registry.add("app.library.achievement-sync-pause-ms", () -> "0");
}
@@ -1,12 +1,14 @@
package core_service.shared.test;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.modules.psn_adapter.service.PsnAuthService;
import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
import core_service.modules.steam_adapter.service.SteamApiService;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
/**
* Integration tests with the three external APIs mocked, in one shared context. A mock answers empty, so a sync a
* Integration tests with the external APIs mocked, in one shared context. A mock answers empty, so a sync a
* request starts finds no games and leaves the library as it is.
*/
public abstract class AbstractMockedAdaptersTest extends AbstractIntegrationTest {
@@ -19,4 +21,10 @@ public abstract class AbstractMockedAdaptersTest extends AbstractIntegrationTest
@MockitoBean
protected SpeedrunApiService speedrunApiService;
@MockitoBean
protected PsnAuthService psnAuthService;
@MockitoBean
protected PsnApiService psnApiService;
}