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.
This commit is contained in:
lobo committed 2026-09-24 14:18:32 +02:00
1 parent ea1e1e9aec
commit 2f2f8a2918
11 files changed
+348 -15

No files matched your search

@@ -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)));
}
}
@@ -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"));
}
}
@@ -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());
}
}