From 2f2f8a29181d5d95372331979e37de89d08a624b Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 24 Sep 2026 14:18:32 +0200 Subject: [PATCH] feat(profile): public profile data for a ticket from Auth GET /api/public/profiles/library with X-Profile-Ticket returns totals, status counts, playing and finished games with rating; never notes, priority or the Steam ID. Login filters reject the ticket, and Core keeps no copy of the privacy flag. --- readme.md | 4 +- .../configuration/security/JwtService.java | 34 ++++- .../security/SecurityConfig.java | 3 +- .../library/api/PublicProfileController.java | 35 +++++ .../library/api/dto/PublicLibraryDto.java | 24 ++++ .../library/repo/UserGameRepository.java | 6 + .../library/service/LibraryQueryService.java | 46 ++++++- .../security/ProfileTicketVerifier.java | 14 ++ .../security/JwtServiceTest.java | 34 +++++ .../security/PublicEndpointsTest.java | 35 ++++- .../api/PublicProfileControllerTest.java | 128 ++++++++++++++++++ 11 files changed, 348 insertions(+), 15 deletions(-) create mode 100644 src/main/java/core_service/modules/library/api/PublicProfileController.java create mode 100644 src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java create mode 100644 src/main/java/core_service/shared/security/ProfileTicketVerifier.java create mode 100644 src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java diff --git a/readme.md b/readme.md index 39195fd..6bfa806 100644 --- a/readme.md +++ b/readme.md @@ -164,6 +164,6 @@ Today `user_game` mirrors Steam ownership and playtime, nothing else. Closing th - Series / franchise grouping: needs IGDB integration (separate adapter, requires a free IGDB OAuth token). - **Multi-platform support**: bigger schema change. Decouple `Game` (platform-agnostic) from `SteamGame` (platform-specific). New `source` enum (`STEAM`, `GOG`, `EPIC`, `MANUAL`, ...). Defer until at least one non-Steam adapter is on the immediate horizon. -### 8. Public profile data +### 8. ~~Public profile data~~ Done -- Endpoint: `GET /api/public/users/{username}/profile` returning currently-playing + recently-completed + total hours, gated by a `profile_public` flag (owned by Auth, mirrored or fetched on demand). \ No newline at end of file +- `GET /api/public/profiles/library` with header `X-Profile-Ticket` (issued by Auth for a public profile, valid 10 minutes, see the Auth readme) returns totals, status counts, up to 8 playing and 12 finished games with rating. Never notes, priority or the Steam ID. Core keeps no copy of the privacy flag; the ticket is the proof. \ No newline at end of file diff --git a/src/main/java/core_service/configuration/security/JwtService.java b/src/main/java/core_service/configuration/security/JwtService.java index 963ce47..44032f3 100644 --- a/src/main/java/core_service/configuration/security/JwtService.java +++ b/src/main/java/core_service/configuration/security/JwtService.java @@ -1,5 +1,6 @@ package core_service.configuration.security; +import core_service.shared.security.ProfileTicketVerifier; import io.jsonwebtoken.Jwts; import jakarta.annotation.PostConstruct; import org.springframework.stereotype.Service; @@ -8,14 +9,18 @@ import java.security.KeyFactory; import java.security.PublicKey; import java.security.spec.X509EncodedKeySpec; import java.util.Base64; +import java.util.Optional; /** * Verifies JWTs issued by tbp-auth-service. Verify-only: holds the Ed25519 public key. */ @Service -public class JwtService { +public class JwtService implements ProfileTicketVerifier { private static final String ED25519 = "Ed25519"; + // Only non-login tokens carry this claim; isTokenValid rejects them. + private static final String TYPE_CLAIM = "typ"; + private static final String PROFILE_TICKET_TYPE = "public-profile"; private final JwtConfig jwtConfig; private PublicKey publicKey; @@ -56,18 +61,39 @@ public class JwtService { .get("steamId", Long.class); } + /** + * True for a login token with a valid signature. A profile ticket is signed with the same key, but is no login. + */ public boolean isTokenValid(String token) { try { - Jwts.parser() + var claims = Jwts.parser() .verifyWith(publicKey) .build() - .parseSignedClaims(token); - return true; + .parseSignedClaims(token) + .getPayload(); + return claims.get(TYPE_CLAIM) == null; } catch (Exception e) { return false; } } + @Override + public Optional verifyProfileTicket(String ticket) { + try { + var claims = Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(ticket) + .getPayload(); + if (!PROFILE_TICKET_TYPE.equals(claims.get(TYPE_CLAIM, String.class))) { + return Optional.empty(); + } + return Optional.of(Long.parseLong(claims.get("profileUserId", String.class))); + } catch (Exception e) { + return Optional.empty(); + } + } + private static PublicKey decodePublicKey(String base64X509Der) { try { byte[] keyBytes = Base64.getDecoder().decode(base64X509Der); diff --git a/src/main/java/core_service/configuration/security/SecurityConfig.java b/src/main/java/core_service/configuration/security/SecurityConfig.java index 2aaf500..95d3a53 100644 --- a/src/main/java/core_service/configuration/security/SecurityConfig.java +++ b/src/main/java/core_service/configuration/security/SecurityConfig.java @@ -27,7 +27,8 @@ public class SecurityConfig { private static final String[] WHITE_LIST_URLS = { "/actuator/health", // Docker health check; details are off "/error", - "/api/stats/public" + "/api/stats/public", + "/api/public/**" // Public profiles; PublicProfileController checks the ticket }; private final OncePerRequestFilter jwtAuthFilter; diff --git a/src/main/java/core_service/modules/library/api/PublicProfileController.java b/src/main/java/core_service/modules/library/api/PublicProfileController.java new file mode 100644 index 0000000..4308e2e --- /dev/null +++ b/src/main/java/core_service/modules/library/api/PublicProfileController.java @@ -0,0 +1,35 @@ +package core_service.modules.library.api; + +import core_service.modules.library.service.LibraryQueryService; +import core_service.shared.security.ProfileTicketVerifier; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.Optional; + +/** + * Public, without login. Auth hands out the ticket only for a user whose profile is public, so this controller + * never needs to know the name or the privacy switch. + */ +@RestController +@RequestMapping("/api/public/profiles") +@RequiredArgsConstructor +public class PublicProfileController { + + private final LibraryQueryService queryService; + private final ProfileTicketVerifier profileTicketVerifier; + + @GetMapping("/library") + public ResponseEntity getPublicLibrary(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket) { + return (ticket == null ? Optional.empty() : profileTicketVerifier.verifyProfileTicket(ticket)) + .>map(userId -> ResponseEntity.ok(queryService.getPublicLibrary(userId))) + .orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("code", "INVALID_TICKET", "message", "Missing, invalid or expired profile ticket"))); + } +} diff --git a/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java b/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java new file mode 100644 index 0000000..46fd071 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java @@ -0,0 +1,24 @@ +package core_service.modules.library.api.dto; + +import core_service.modules.library.domain.GameStatus; + +import java.util.List; +import java.util.Map; + +/** + * What a public profile shows. Never notes, priority or anything that identifies the Steam account. + * + * @param byStatus has every status, also those with 0 games + * @param playing up to 8 games, most played first + * @param finished up to 12 FINISHED or COMPLETED games, best rated first + */ +public record PublicLibraryDto( + long totalGames, + long totalPlaytimeMinutes, + Map byStatus, + List playing, + List finished +) { + + public record Game(Long appid, String name, int playtimeMinutes, GameStatus status, Integer rating) {} +} diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index c752507..666686a 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -14,6 +14,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.Instant; +import java.util.Collection; import java.util.List; import java.util.Locale; import java.util.Optional; @@ -24,6 +25,11 @@ public interface UserGameRepository extends JpaRepository long countByUserIdAndRemovedAtIsNull(Long userId); + @Query("select coalesce(sum(ug.playtimeMinutes), 0) from UserGame ug where ug.userId = :userId and ug.removedAt is null") + long sumPlaytime(@Param("userId") Long userId); + + List findByUserIdAndRemovedAtIsNullAndStatusIn(Long userId, Collection statuses, Pageable pageable); + List findTop5ByUserIdAndRemovedAtIsNullAndPlaytime2WeeksGreaterThanOrderByPlaytime2WeeksDesc( Long userId, int playtime2Weeks); diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index f770b64..dec0a3a 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -3,6 +3,7 @@ package core_service.modules.library.service; import core_service.modules.library.api.dto.ActivityDto; import core_service.modules.library.api.dto.GameDto; import core_service.modules.library.api.dto.LibrarySummaryDto; +import core_service.modules.library.api.dto.PublicLibraryDto; import core_service.modules.library.api.dto.RecentGameDto; import core_service.modules.library.api.dto.WishlistDto; import core_service.modules.library.domain.DayMinutes; @@ -19,6 +20,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,12 +32,16 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import java.util.stream.Stream; @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class LibraryQueryService { + private static final int PUBLIC_PLAYING = 8; + private static final int PUBLIC_FINISHED = 12; + private final UserGameRepository userGameRepository; private final UserSteamSyncRepository userSteamSyncRepository; private final SteamGameRepository steamGameRepository; @@ -64,11 +70,7 @@ public class LibraryQueryService { * @param excludeAppid the current suggestion, so a new pick differs from it; null for none */ public LibrarySummaryDto getSummary(Long userId, Long excludeAppid) { - Map byStatus = new EnumMap<>(GameStatus.class); - for (GameStatus status : GameStatus.values()) { - byStatus.put(status, 0L); - } - userGameRepository.countByStatus(userId).forEach(c -> byStatus.put(c.status(), c.count())); + Map byStatus = countByStatus(userId); long total = byStatus.values().stream().mapToLong(Long::longValue).sum(); long neverPlayed = userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(userId, 0); GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : -1) @@ -105,6 +107,40 @@ public class LibraryQueryService { return new ActivityDto(perDay, total, recentGames); } + public PublicLibraryDto getPublicLibrary(Long userId) { + Map byStatus = countByStatus(userId); + List playing = userGameRepository.findByUserIdAndRemovedAtIsNullAndStatusIn(userId, + List.of(GameStatus.PLAYING), + PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid")))); + List finished = userGameRepository.findByUserIdAndRemovedAtIsNullAndStatusIn(userId, + List.of(GameStatus.FINISHED, GameStatus.COMPLETED), + PageRequest.of(0, PUBLIC_FINISHED, Sort.by(Sort.Order.desc("rating").nullsLast()).and(Sort.by("appid")))); + Map meta = steamGameRepository.findAllById( + Stream.concat(playing.stream(), finished.stream()).map(UserGame::getAppid).toList()) + .stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g)); + long total = byStatus.values().stream().mapToLong(Long::longValue).sum(); + return new PublicLibraryDto(total, userGameRepository.sumPlaytime(userId), byStatus, + toPublicGames(playing, meta), toPublicGames(finished, meta)); + } + + // Every status, also those with 0 games. + private Map countByStatus(Long userId) { + Map byStatus = new EnumMap<>(GameStatus.class); + for (GameStatus status : GameStatus.values()) { + byStatus.put(status, 0L); + } + userGameRepository.countByStatus(userId).forEach(c -> byStatus.put(c.status(), c.count())); + return byStatus; + } + + private static List toPublicGames(List games, Map meta) { + return games.stream() + .map(ug -> new PublicLibraryDto.Game(ug.getAppid(), + meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null, + ug.getPlaytimeMinutes(), ug.getStatus(), ug.getRating())) + .toList(); + } + public WishlistDto getWishlist(Long userId) { return new WishlistDto(wishlistGameRepository.findForUser(userId).stream() .map(w -> new WishlistDto.Game(w.getAppid(), w.getName(), w.getAddedAt(), diff --git a/src/main/java/core_service/shared/security/ProfileTicketVerifier.java b/src/main/java/core_service/shared/security/ProfileTicketVerifier.java new file mode 100644 index 0000000..50800a5 --- /dev/null +++ b/src/main/java/core_service/shared/security/ProfileTicketVerifier.java @@ -0,0 +1,14 @@ +package core_service.shared.security; + +import java.util.Optional; + +/** + * Port for the public-profile ticket that Auth issues for a user with a public profile. + */ +public interface ProfileTicketVerifier { + + /** + * @return the user of a valid, unexpired ticket; empty for anything else, also for a login token + */ + Optional verifyProfileTicket(String ticket); +} diff --git a/src/test/java/core_service/configuration/security/JwtServiceTest.java b/src/test/java/core_service/configuration/security/JwtServiceTest.java index b862360..d27ccfc 100644 --- a/src/test/java/core_service/configuration/security/JwtServiceTest.java +++ b/src/test/java/core_service/configuration/security/JwtServiceTest.java @@ -11,6 +11,7 @@ import java.security.KeyPairGenerator; import java.security.PrivateKey; import java.util.Base64; import java.util.Date; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -99,4 +100,37 @@ class JwtServiceTest extends AbstractUnitTest { assertFalse(jwtService.isTokenValid(token)); } + + private String signTicket(String type, String profileUserId, long expiresInMs) { + return Jwts.builder() + .subject("public-profile") + .claim("typ", type) + .claim("profileUserId", profileUserId) + .expiration(new Date(System.currentTimeMillis() + expiresInMs)) + .signWith(signingKey, Jwts.SIG.EdDSA).compact(); + } + + @Test + void verifyProfileTicket_ReturnsTheUser() { + assertEquals(Optional.of(42L), jwtService.verifyProfileTicket(signTicket("public-profile", "42", 60_000))); + } + + @Test + void verifyProfileTicket_RejectsLoginTokensOtherTypesExpiredAndForeignTickets() throws Exception { + assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signToken("42", "a@b.de", null))); + assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("something-else", "42", 60_000))); + assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("public-profile", "42", -1_000))); + assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("public-profile", "not-a-number", 60_000))); + assertEquals(Optional.empty(), jwtService.verifyProfileTicket("garbage")); + + PrivateKey otherKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPrivate(); + String foreign = Jwts.builder().claim("typ", "public-profile").claim("profileUserId", "42") + .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(otherKey, Jwts.SIG.EdDSA).compact(); + assertEquals(Optional.empty(), jwtService.verifyProfileTicket(foreign)); + } + + @Test + void isTokenValid_ProfileTicketIsNoLogin() { + assertFalse(jwtService.isTokenValid(signTicket("public-profile", "42", 60_000))); + } } diff --git a/src/test/java/core_service/configuration/security/PublicEndpointsTest.java b/src/test/java/core_service/configuration/security/PublicEndpointsTest.java index 803e09b..9ea7ed2 100644 --- a/src/test/java/core_service/configuration/security/PublicEndpointsTest.java +++ b/src/test/java/core_service/configuration/security/PublicEndpointsTest.java @@ -1,5 +1,6 @@ package core_service.configuration.security; +import io.jsonwebtoken.Jwts; import org.junit.jupiter.api.Tag; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -15,8 +16,10 @@ import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import java.security.KeyPair; import java.security.KeyPairGenerator; import java.util.Base64; +import java.util.Date; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -37,10 +40,19 @@ class PublicEndpointsTest { @ServiceConnection static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:17-alpine"); + private static final KeyPair KEYS = generateKeys(); + + private static KeyPair generateKeys() { + try { + return KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + @DynamicPropertySource - static void properties(DynamicPropertyRegistry registry) throws Exception { - byte[] publicKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPublic().getEncoded(); - registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(publicKey)); + static void properties(DynamicPropertyRegistry registry) { + registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(KEYS.getPublic().getEncoded())); registry.add("app.steam.api-key", () -> "test-key"); } @@ -61,4 +73,21 @@ class PublicEndpointsTest { int status = mockMvc.perform(get(path)).andReturn().getResponse().getStatus(); assertTrue(Set.of(401, 403).contains(status), path + " answered " + status); } + + @Test + void profileTicketIsNoLogin() throws Exception { + String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "1") + .claim("userId", "1").expiration(new Date(System.currentTimeMillis() + 60_000)) + .signWith(KEYS.getPrivate(), Jwts.SIG.EdDSA).compact(); + + int status = mockMvc.perform(get("/api/library/games-count").header("Authorization", "Bearer " + ticket)) + .andReturn().getResponse().getStatus(); + assertTrue(Set.of(401, 403).contains(status), "ticket as login answered " + status); + } + + @Test + void publicProfileEndpointIsPublic() throws Exception { + mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("INVALID_TICKET")); + } } diff --git a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java new file mode 100644 index 0000000..009aff2 --- /dev/null +++ b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java @@ -0,0 +1,128 @@ +package core_service.modules.library.api; + +import core_service.modules.library.domain.GamePriority; +import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; +import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.shared.test.AbstractIntegrationTest; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.List; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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; + +/** + * Signs its own tickets: the test key pair replaces the dev public key. + */ +@AutoConfigureMockMvc +class PublicProfileControllerTest extends AbstractIntegrationTest { + + private static final long USER_ID = 7L; + private static final KeyPair KEYS = generateKeys(); + + private static KeyPair generateKeys() { + try { + return KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) { + registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(KEYS.getPublic().getEncoded())); + } + + @Autowired + private MockMvc mockMvc; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private UserGameRepository userGameRepository; + + private static String ticket(long userId) { + return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId)) + .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(KEYS.getPrivate(), Jwts.SIG.EdDSA).compact(); + } + + private UserGame game(long appid, int playtime, GameStatus status, Integer rating) { + UserGame game = new UserGame(USER_ID, appid, playtime, Instant.now(), Instant.now()); + game.setStatus(status); + game.setRating(rating); + game.setNotes("private note"); + game.setPriority(GamePriority.HIGH); + return game; + } + + @BeforeEach + void seed() { + userGameRepository.deleteAll(); + steamGameRepository.saveAll(List.of( + new SteamGame(220L, "Half-Life 2", null, Instant.now()), + new SteamGame(620L, "Portal 2", null, Instant.now()), + new SteamGame(700L, "Dota 2", null, Instant.now()), + new SteamGame(800L, "Hades", null, Instant.now()))); + UserGame removed = game(800L, 999, GameStatus.FINISHED, 5); + removed.setRemovedAt(Instant.now()); + userGameRepository.saveAll(List.of( + game(220L, 300, GameStatus.PLAYING, null), + game(620L, 900, GameStatus.COMPLETED, 5), + game(700L, 60, GameStatus.FINISHED, 3), + removed, + new UserGame(99L, 220L, 5000, Instant.now(), Instant.now()))); + } + + @Test + void publicLibrary_ShowsStatsPlayingAndFinishedGamesButNoNotesOrPriority() throws Exception { + mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalGames").value(3)) + .andExpect(jsonPath("$.totalPlaytimeMinutes").value(1260)) + .andExpect(jsonPath("$.byStatus.FINISHED").value(1)) + .andExpect(jsonPath("$.byStatus.COMPLETED").value(1)) + .andExpect(jsonPath("$.playing", hasSize(1))) + .andExpect(jsonPath("$.playing[0].name").value("Half-Life 2")) + .andExpect(jsonPath("$.finished", hasSize(2))) + .andExpect(jsonPath("$.finished[0].name").value("Portal 2")) + .andExpect(jsonPath("$.finished[0].rating").value(5)) + .andExpect(jsonPath("$.finished[1].status").value("FINISHED")) + .andExpect(jsonPath("$..notes").isEmpty()) + .andExpect(jsonPath("$..priority").isEmpty()); + } + + @Test + void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception { + mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized()); + mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", "garbage")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("INVALID_TICKET")); + } + + // The dev filter falls back to the dummy user 1 for a token it rejects; user 7's game would answer 200. + @Test + void profileTicket_IsNoLoginForTheLibraryApi() throws Exception { + mockMvc.perform(put("/api/library/games/220/backlog").header("Authorization", "Bearer " + ticket(USER_ID)) + .contentType("application/json").content("{\"status\":\"DROPPED\"}")) + .andExpect(status().isNotFound()); + } +}