refactor(api): one error body, token audiences, contract fixture
Build, Test & Push Core Service / quality-check (push) Successful in 1m7s
Build, Test & Push Core Service / docker (push) Successful in 1m23s

This commit is contained in:
lobo committed 2026-09-29 14:48:28 +02:00
1 parent 2360beeb81
commit 73cfcb58e2
24 files changed
+633 -129

No files matched your search

@@ -23,7 +23,7 @@ import static org.mockito.Mockito.lenient;
*/
class AccessTokenFiltersTest extends AbstractUnitTest {
private static final UserPrincipal USER = new UserPrincipal("42", "a@b.de", 76561198000000042L);
private static final UserPrincipal USER = new UserPrincipal("42", 76561198000000042L);
@Mock
private JwtService jwtService;
@@ -1,16 +1,15 @@
package core_service.configuration.security;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
import jakarta.servlet.http.Cookie;
import io.jsonwebtoken.Jwts;
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.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Date;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -25,13 +24,7 @@ class ExpiredAccessTokenTest extends AbstractIntegrationTest {
private MockMvc mockMvc;
private static String expiredAccessToken() {
return Jwts.builder()
.subject("a@b.de")
.claim("userId", "42")
.issuedAt(new Date(System.currentTimeMillis() - 120_000))
.expiration(new Date(System.currentTimeMillis() - 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
.compact();
return ContractTokens.sign(ContractTokens.login(42, Role.USER, null), TEST_KEYS.getPrivate(), -60_000);
}
@Test
@@ -4,6 +4,7 @@ import core_service.shared.security.ProfileTicket;
import core_service.shared.security.Role;
import core_service.shared.security.UserPrincipal;
import core_service.shared.test.AbstractUnitTest;
import core_service.shared.test.ContractTokens;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -16,6 +17,7 @@ import java.security.PrivateKey;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@@ -62,7 +64,7 @@ class JwtServiceTest extends AbstractUnitTest {
void parseLoginToken_ReturnsTheUser() {
String token = signToken("0815", "test@example.com", 123456789L);
assertEquals(Optional.of(new UserPrincipal("0815", "test@example.com", 123456789L)),
assertEquals(Optional.of(new UserPrincipal("0815", 123456789L)),
jwtService.parseLoginToken(token));
}
@@ -205,4 +207,66 @@ class JwtServiceTest extends AbstractUnitTest {
assertEquals(Role.USER, jwtService.parseLoginToken(token).orElseThrow().role());
}
private String sign(Map<String, Object> claims) {
return ContractTokens.sign(claims, signingKey, 60_000);
}
// The shapes of item 29: without the claims an Auth from before item 26 needs.
private static Map<String, Object> without(Map<String, Object> claims, String... names) {
for (String name : names) {
claims.remove(name);
}
return claims;
}
@Test
void contractTokens_OfEveryKind_AreAccepted() {
assertEquals("42", jwtService.parseLoginToken(sign(ContractTokens.claims(ContractTokens.LOGIN))).orElseThrow().id());
assertEquals(42L, jwtService.verifyProfileTicket(sign(ContractTokens.claims(ContractTokens.PUBLIC_PROFILE)))
.orElseThrow().userId());
assertTrue(jwtService.isServiceToken(sign(ContractTokens.claims(ContractTokens.SERVICE))));
}
@Test
void legacyTokens_OfAnAuthFromBeforeItem26_AreAccepted() {
assertEquals("42", jwtService.parseLoginToken(sign(ContractTokens.legacyClaims(ContractTokens.LOGIN))).orElseThrow().id());
assertEquals(42L, jwtService.verifyProfileTicket(sign(ContractTokens.legacyClaims(ContractTokens.PUBLIC_PROFILE)))
.orElseThrow().userId());
assertTrue(jwtService.isServiceToken(sign(ContractTokens.legacyClaims(ContractTokens.SERVICE))));
}
@Test
void tokensWithoutTheOldClaims_AreAccepted() {
assertEquals("42", jwtService.parseLoginToken(sign(without(ContractTokens.claims(ContractTokens.LOGIN), "userId")))
.orElseThrow().id());
assertEquals(42L, jwtService.verifyProfileTicket(sign(without(ContractTokens.claims(ContractTokens.PUBLIC_PROFILE), "typ")))
.orElseThrow().userId());
assertTrue(jwtService.isServiceToken(sign(without(ContractTokens.claims(ContractTokens.SERVICE), "typ"))));
}
@Test
void aTokenForAnotherAudience_IsRejected() {
Map<String, Object> ticketAsLogin = ContractTokens.claims(ContractTokens.LOGIN);
ticketAsLogin.put("aud", List.of(JwtService.PROFILE_TICKET_AUDIENCE));
Map<String, Object> loginAsService = ContractTokens.claims(ContractTokens.SERVICE);
loginAsService.put("aud", List.of(JwtService.LOGIN_AUDIENCE));
Map<String, Object> loginAsTicket = ContractTokens.claims(ContractTokens.PUBLIC_PROFILE);
loginAsTicket.put("aud", List.of(JwtService.LOGIN_AUDIENCE));
assertEquals(Optional.empty(), jwtService.parseLoginToken(sign(ticketAsLogin)));
assertFalse(jwtService.isServiceToken(sign(loginAsService)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(sign(loginAsTicket)));
}
@Test
void aTokenWithAnAudienceButAnotherIssuer_IsRejected() {
Map<String, Object> login = ContractTokens.claims(ContractTokens.LOGIN);
login.put("iss", "someone-else");
Map<String, Object> service = ContractTokens.claims(ContractTokens.SERVICE);
service.remove("iss");
assertEquals(Optional.empty(), jwtService.parseLoginToken(sign(login)));
assertFalse(jwtService.isServiceToken(sign(service)));
}
}
@@ -1,6 +1,6 @@
package core_service.configuration.security;
import io.jsonwebtoken.Jwts;
import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
@@ -19,8 +19,8 @@ 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.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -77,9 +77,10 @@ class PublicEndpointsTest {
@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();
Map<String, Object> claims = ContractTokens.ticket(1, null, null);
// A ticket that also carries a user id is still no login.
claims.put("userId", "1");
String ticket = ContractTokens.sign(claims, KEYS.getPrivate(), 60_000);
int status = mockMvc.perform(get("/api/library/games-count").header("Authorization", "Bearer " + ticket))
.andReturn().getResponse().getStatus();
@@ -95,9 +96,8 @@ class PublicEndpointsTest {
// Without a login: a class role marker on the way would answer 401.
@Test
void publicProfileWithSharedStats_WorksWithoutALogin() throws Exception {
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "1")
.claim("profileStats", List.of("TOP_GAMES", "OVERVIEW")).expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
String ticket = ContractTokens.sign(ContractTokens.ticket(1, List.of("TOP_GAMES", "OVERVIEW"), null),
KEYS.getPrivate(), 60_000);
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket))
.andExpect(status().isOk())
@@ -4,7 +4,7 @@ import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts;
import core_service.shared.test.ContractTokens;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -18,8 +18,6 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilde
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -93,13 +91,7 @@ class RoleCheckFlowTest extends AbstractIntegrationTest {
private ProbeService probeService;
private static String token(Role role) {
return Jwts.builder()
.subject("probe@example.com")
.claim("userId", "4711")
.claim("role", role.name())
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
.compact();
return ContractTokens.loginToken(4711, role);
}
private ResultActions call(String path, Role role) throws Exception {
@@ -1,25 +1,29 @@
package core_service.modules.account.api;
import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import tools.jackson.databind.JsonNode;
import java.util.Date;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
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.request.MockMvcRequestBuilders.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -40,20 +44,15 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
@AfterEach
void cleanUp() {
jdbc.update("delete from deleted_user where user_id = ?", USER_ID);
}
private static String token(JwtBuilder builder) {
return builder.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID);
}
private static String serviceToken() {
return token(Jwts.builder().subject("tbp-auth").claim("typ", "service"));
return ContractTokens.serviceToken();
}
private static String loginToken() {
return token(Jwts.builder().subject("a@b.de").claim("userId", String.valueOf(USER_ID)));
return ContractTokens.loginToken(USER_ID);
}
private ResultActions send(MockHttpServletRequestBuilder request, String body, String token) throws Exception {
@@ -107,6 +106,38 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
@Test
void prune_WithAnEmptyListIs400() throws Exception {
send(post("/internal/accounts/prune"), "{\"knownUserIds\":[],\"dryRun\":false}", serviceToken()).andExpect(status().isBadRequest());
send(post("/internal/accounts/prune"), "{\"knownUserIds\":[],\"dryRun\":false}", serviceToken())
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.fields[0].field").value("knownUserIds"));
}
private static MockHttpServletRequestBuilder contractRequest(JsonNode call) {
return request(HttpMethod.valueOf(call.path("method").asString()), call.path("path").asString());
}
// The calls as Auth's CoreAccountService sends them, with the service tokens of an old and a new Auth.
@ParameterizedTest
@ValueSource(booleans = {false, true})
void contractCalls_AreAnswered(boolean legacyToken) throws Exception {
String token = legacyToken ? ContractTokens.sign(ContractTokens.legacyClaims(ContractTokens.SERVICE))
: ContractTokens.serviceToken();
// replaceDeleted with [] removes every tombstone, also those of other tests in this database.
List<Long> tombstones = jdbc.queryForList("select user_id from deleted_user", Long.class);
try {
for (String name : List.of("markDeleted", "replaceDeleted")) {
JsonNode call = ContractTokens.internalCall(name);
send(contractRequest(call), call.path("request").toString(), token).andExpect(status().isNoContent());
}
} finally {
tombstones.forEach(userId -> jdbc.update("insert into deleted_user (user_id) values (?) on conflict do nothing", userId));
}
for (String name : List.of("purge", "prune")) {
JsonNode call = ContractTokens.internalCall(name);
ResultActions result = send(contractRequest(call), call.path("request").toString(), token).andExpect(status().isOk());
for (String field : call.path("response").propertyNames()) {
result.andExpect(jsonPath("$." + field).exists());
}
}
}
}
@@ -11,7 +11,7 @@ import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts;
import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -22,7 +22,6 @@ import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.time.Instant;
import java.util.Date;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.contains;
@@ -77,13 +76,7 @@ class GameCorrectionFlowTest extends AbstractIntegrationTest {
}
private static String token(Role role) {
return Jwts.builder()
.subject("auditor@example.com")
.claim("userId", String.valueOf(AUDITOR_ID))
.claim("role", role.name())
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
.compact();
return ContractTokens.loginToken(AUDITOR_ID, role);
}
private ResultActions correct(Role role, long appid, String field, String valueJson) throws Exception {
@@ -7,16 +7,16 @@ 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.domain.UserGameId;
import core_service.modules.library.domain.UserStats;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserStatsRepository;
import core_service.modules.library.domain.UserStats;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts;
import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -30,7 +30,6 @@ import org.springframework.test.web.servlet.ResultActions;
import java.time.Instant;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -94,13 +93,7 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
}
private static String token(Role role) {
return Jwts.builder()
.subject("auditor@example.com")
.claim("userId", String.valueOf(AUDITOR_ID))
.claim("role", role.name())
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
.compact();
return ContractTokens.loginToken(AUDITOR_ID, role);
}
private ResultActions merge(Role role, long appid, long otherAppid) throws Exception {
@@ -14,17 +14,28 @@ import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataIntegrityViolationException;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.sql.SQLException;
import java.time.Instant;
import java.util.List;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -102,6 +113,50 @@ class LibraryControllerStatsTest extends AbstractIntegrationTest {
@Test
void stats_UnknownGroup_Is400() throws Exception {
mockMvc.perform(get("/api/library/stats").param("groups", "MONEY"))
.andExpect(status().isBadRequest());
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.fields[0].field").value("groups"));
}
@Test
void anUnexpectedError_Is500WithoutDetails() throws Exception {
doThrow(new IllegalStateException("secret detail")).when(moodService).refreshListedInBackground(USER_ID);
mockMvc.perform(get("/api/library/stats"))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"))
.andExpect(content().string(not(containsString("secret detail"))));
}
@Test
void aDatabaseConflict_Is409() throws Exception {
doThrow(new DataIntegrityViolationException("doppelter Schlüsselwert", new SQLException("doppelter Schlüsselwert", "23505"))).when(moodService).refreshListedInBackground(USER_ID);
mockMvc.perform(get("/api/library/stats"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("CONFLICT"));
}
@Test
void anotherRefusedWrite_Is500() throws Exception {
doThrow(new DataIntegrityViolationException("Wert zu lang", new SQLException("Wert zu lang", "22001")))
.when(moodService).refreshListedInBackground(USER_ID);
mockMvc.perform(get("/api/library/stats"))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"));
}
@Test
@ExtendWith(OutputCaptureExtension.class)
void aSpringServerError_IsLoggedAsInternalError(CapturedOutput output) throws Exception {
doThrow(new HttpMessageNotWritableException("body not writable"))
.when(moodService).refreshListedInBackground(USER_ID);
mockMvc.perform(get("/api/library/stats"))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"));
assertTrue(output.getAll().contains("body not writable"), "the 500 left no trace in the log");
}
}
@@ -2,7 +2,7 @@ package core_service.modules.library.api;
import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts;
import core_service.shared.test.ContractTokens;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -13,7 +13,6 @@ import org.springframework.test.web.servlet.MockMvc;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -42,12 +41,7 @@ class LibraryControllerSteamIdGoneTest extends AbstractIntegrationTest {
}
private static Cookie loginWithoutSteamId() {
return new Cookie("tbp_access", Jwts.builder()
.subject("unlinked@example.com")
.claim("userId", String.valueOf(USER_ID))
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
.compact());
return new Cookie("tbp_access", ContractTokens.loginToken(USER_ID));
}
@Test
@@ -3,8 +3,8 @@ package core_service.modules.library.api;
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.domain.GameTimeToBeat;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
@@ -12,6 +12,7 @@ import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -215,6 +216,14 @@ class ManualGameControllerTest extends AbstractIntegrationTest {
add(119388, " ").andExpect(status().isBadRequest());
}
@Test
void search_OneLetter_Is400WithTheField() throws Exception {
mockMvc.perform(get("/api/library/game-search").param("q", "a"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.fields[0].field").value("q"));
}
@Test
void search_MarksGamesAlreadyInTheLibrary() throws Exception {
steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", null, Instant.now()));
@@ -342,12 +351,7 @@ class ManualGameControllerTest extends AbstractIntegrationTest {
// Signed like an Auth login token, for a user who saved no Steam ID.
private static String tokenWithoutSteamId() {
return io.jsonwebtoken.Jwts.builder()
.subject("no-steam@example.com")
.claim("userId", String.valueOf(USER_ID))
.expiration(new java.util.Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), io.jsonwebtoken.Jwts.SIG.EdDSA)
.compact();
return ContractTokens.loginToken(USER_ID);
}
@Test
@@ -11,7 +11,7 @@ import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts;
import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -19,7 +19,6 @@ import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import static org.hamcrest.Matchers.hasSize;
@@ -49,20 +48,15 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
private UserGameAchievementMetaRepository userAchievementMetaRepository;
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(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
return ContractTokens.ticketToken(userId, null, null);
}
private static String ticket(long userId, List<String> profileStats) {
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId))
.claim("profileStats", profileStats)
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
return ContractTokens.ticketToken(userId, profileStats, null);
}
private static String ticket(long userId, List<String> profileStats, List<String> sections) {
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId))
.claim("profileStats", profileStats).claim("sections", sections)
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
return ContractTokens.ticketToken(userId, profileStats, sections);
}
private UserGame game(long appid, int playtime, GameStatus status, Integer rating) {
@@ -0,0 +1,120 @@
package core_service.shared.test;
import core_service.shared.security.Role;
import io.jsonwebtoken.Jwts;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.security.PrivateKey;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Tokens only in the shapes of {@code contract/auth-core-contract.json}, the file Auth checks its tokens against. A
* test changes the values, never the claim names.
*/
public final class ContractTokens {
public static final String LOGIN = "login";
public static final String PUBLIC_PROFILE = "publicProfile";
public static final String SERVICE = "service";
private static final JsonMapper MAPPER = JsonMapper.builder().build();
private static final JsonNode CONTRACT = load();
private ContractTokens() {
}
private static JsonNode load() {
try (InputStream in = ContractTokens.class.getResourceAsStream("/contract/auth-core-contract.json")) {
return MAPPER.readTree(in);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
/**
* The claims Auth signs for this kind today.
*/
@SuppressWarnings("unchecked")
public static Map<String, Object> claims(String kind) {
return new LinkedHashMap<>(MAPPER.treeToValue(CONTRACT.path("tokens").required(kind), Map.class));
}
/**
* The claims of an Auth from before item 26.
*/
@SuppressWarnings("unchecked")
public static Map<String, Object> legacyClaims(String kind) {
return new LinkedHashMap<>(MAPPER.treeToValue(CONTRACT.path("legacyTokens").required(kind), Map.class));
}
public static JsonNode internalCall(String name) {
return CONTRACT.path("internal").required(name);
}
public static String sign(Map<String, Object> claims, PrivateKey key, long expiresInMs) {
return Jwts.builder()
.claims(claims)
.expiration(new Date(System.currentTimeMillis() + expiresInMs))
.signWith(key, Jwts.SIG.EdDSA)
.compact();
}
public static String sign(Map<String, Object> claims) {
return sign(claims, AbstractIntegrationTest.TEST_KEYS.getPrivate(), 60_000);
}
/**
* @param steamId null for a user without a linked Steam account
*/
public static Map<String, Object> login(long userId, Role role, Long steamId) {
Map<String, Object> claims = claims(LOGIN);
claims.put("sub", String.valueOf(userId));
claims.put("userId", String.valueOf(userId));
claims.put("role", role.name());
putOrRemove(claims, "steamId", steamId);
return claims;
}
public static String loginToken(long userId, Role role) {
return sign(login(userId, role, null));
}
public static String loginToken(long userId) {
return loginToken(userId, Role.USER);
}
/**
* @param profileStats null for a ticket without the claim
* @param sections null for a ticket without the claim
*/
public static Map<String, Object> ticket(long userId, List<String> profileStats, List<String> sections) {
Map<String, Object> claims = claims(PUBLIC_PROFILE);
claims.put("profileUserId", String.valueOf(userId));
putOrRemove(claims, "profileStats", profileStats);
putOrRemove(claims, "sections", sections);
return claims;
}
public static String ticketToken(long userId, List<String> profileStats, List<String> sections) {
return sign(ticket(userId, profileStats, sections));
}
public static String serviceToken() {
return sign(claims(SERVICE));
}
private static void putOrRemove(Map<String, Object> claims, String name, Object value) {
if (value == null) {
claims.remove(name);
} else {
claims.put(name, value);
}
}
}
@@ -0,0 +1,71 @@
{
"_about": "The token and internal-call contract between Auth and Core. The same file is in both repos; ToBePlayed-Deployment/scripts/check-contract.sh compares them. iat and exp are left out. legacyTokens are the shapes of an Auth from before item 26: Core and Auth accept them until item 29.",
"tokens": {
"login": {
"iss": "tbp-auth",
"aud": ["tbp-login"],
"sub": "42",
"userId": "42",
"role": "USER",
"steamId": 76561198000000001
},
"publicProfile": {
"iss": "tbp-auth",
"aud": ["tbp-public-profile"],
"sub": "public-profile",
"typ": "public-profile",
"profileUserId": "42",
"profileStats": ["OVERVIEW"],
"sections": ["SUMMARY"]
},
"service": {
"iss": "tbp-auth",
"aud": ["tbp-internal"],
"sub": "tbp-auth",
"typ": "service"
}
},
"legacyTokens": {
"login": {
"sub": "user@example.com",
"userId": "42",
"role": "USER",
"steamId": 76561198000000001
},
"publicProfile": {
"sub": "public-profile",
"typ": "public-profile",
"profileUserId": "42",
"profileStats": ["OVERVIEW"],
"sections": ["SUMMARY"]
},
"service": {
"sub": "tbp-auth",
"typ": "service"
}
},
"internal": {
"markDeleted": {
"method": "POST",
"path": "/internal/accounts/deleted",
"request": {"userIds": [42]}
},
"replaceDeleted": {
"method": "PUT",
"path": "/internal/accounts/deleted",
"request": {"userIds": []}
},
"purge": {
"method": "POST",
"path": "/internal/accounts/purge",
"request": {"userIds": [42], "dryRun": false},
"response": {"dryRun": true, "users": [42], "rows": {"user_game": 3}, "refused": false}
},
"prune": {
"method": "POST",
"path": "/internal/accounts/prune",
"request": {"knownUserIds": [1, 42], "dryRun": true},
"response": {"dryRun": true, "users": [42], "rows": {"user_game": 3}, "refused": false}
}
}
}