feat(security): accept the auth cookie and guard against CSRF

Core reads the login token from the tbp_access cookie or a Bearer
header, whichever verifies, and parses it once per request. Unsafe
requests need an X-TBP-Client or Authorization header, CORS allows
only the exact frontend origin, and a missing or expired token
answers 401 instead of 403.
This commit is contained in:
lobo committed 2026-09-25 13:45:08 +02:00
1 parent 762b7898d3
commit 5d608b72e6
12 files changed
+356 -115

No files matched your search

+4 -2
View File
@@ -37,8 +37,10 @@ To Be Played is split into three repositories:
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's
JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay
until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. Core reads `userId` and
`steamId` from the claims.
until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. The token comes from Auth's
`tbp_access` cookie or a `Bearer` header, whichever verifies first. Core reads `userId` and `steamId` from the claims.
`POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the
frontend origin.
A token with a `typ` claim (for example a profile ticket) is rejected on normal endpoints; the profile ticket is
accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` header.
@@ -0,0 +1,41 @@
package core_service.configuration.security;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.Optional;
import java.util.stream.Stream;
/**
* Where a request can carry the login token: the access cookie that Auth sets, or a Bearer header (API clients and
* tests). Both are tried; a stale cookie next to a valid header must not block the request.
*/
final class AccessTokens {
static final String ACCESS_COOKIE = "tbp_access";
private static final String BEARER = "Bearer ";
private AccessTokens() {
}
static Stream<String> candidates(HttpServletRequest request) {
return Stream.of(cookie(request), bearer(request)).flatMap(Optional::stream);
}
private static Optional<String> cookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return Optional.empty();
}
return Arrays.stream(cookies)
.filter(cookie -> ACCESS_COOKIE.equals(cookie.getName()) && !cookie.getValue().isBlank())
.map(Cookie::getValue)
.findFirst();
}
private static Optional<String> bearer(HttpServletRequest request) {
String header = request.getHeader("Authorization");
return header != null && header.startsWith(BEARER) ? Optional.of(header.substring(BEARER.length())) : Optional.empty();
}
}
@@ -0,0 +1,41 @@
package core_service.configuration.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.NonNull;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Set;
/**
* CSRF guard for the login cookie. A request that changes data must carry {@code X-TBP-Client} or
* {@code Authorization}. A page on another origin can send neither without a CORS preflight, and CORS allows only
* our own origins.
*/
public class ClientHeaderFilter extends OncePerRequestFilter {
public static final String CLIENT_HEADER = "X-TBP-Client";
private static final Set<String> SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS");
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
if (SAFE_METHODS.contains(request.getMethod())
|| request.getHeader(CLIENT_HEADER) != null
|| request.getHeader("Authorization") != null) {
filterChain.doFilter(request, response);
return;
}
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"code\":\"CLIENT_HEADER_MISSING\",\"message\":\"Missing X-TBP-Client header\"}");
}
}
@@ -17,6 +17,7 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
import java.util.Optional;
/**
* Dev profile authentication filter.
@@ -56,28 +57,11 @@ public class DevJwtAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
}
private java.util.Optional<UserPrincipal> principalFromJwt(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return java.util.Optional.empty();
}
String jwt = authHeader.substring(7);
if (!jwtService.isTokenValid(jwt)) {
log.debug("Dev mode: invalid JWT, falling back to dummy principal");
return java.util.Optional.empty();
}
try {
String email = jwtService.extractEmail(jwt);
String userId = jwtService.extractUserId(jwt);
Long steamId = jwtService.extractSteamId(jwt);
if (email == null || userId == null) {
return java.util.Optional.empty();
}
return java.util.Optional.of(new UserPrincipal(userId, email, steamId));
} catch (Exception e) {
log.warn("Dev mode: JWT parsing failed: {}", e.getMessage());
return java.util.Optional.empty();
}
private Optional<UserPrincipal> principalFromJwt(HttpServletRequest request) {
return AccessTokens.candidates(request)
.map(jwtService::parseLoginToken)
.flatMap(Optional::stream)
.findFirst();
}
private UserPrincipal dummyPrincipal() {
@@ -6,7 +6,6 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull;
import org.springframework.context.annotation.Profile;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -17,11 +16,11 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
import java.util.Optional;
@Component("jwtAuthenticationFilter")
@Profile("!dev")
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
@@ -33,38 +32,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
@NonNull FilterChain filterChain
) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String jwt = authHeader.substring(7);
if (!jwtService.isTokenValid(jwt)) {
filterChain.doFilter(request, response);
return;
}
try {
final String userEmail = jwtService.extractEmail(jwt);
final String userId = jwtService.extractUserId(jwt);
final Long steamId = jwtService.extractSteamId(jwt);
if (userEmail != null && userId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserPrincipal principal = new UserPrincipal(userId, userEmail, steamId);
Optional<UserPrincipal> principal = AccessTokens.candidates(request)
.map(jwtService::parseLoginToken)
.flatMap(Optional::stream)
.findFirst();
if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
principal,
principal.get(),
null,
Collections.emptyList()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
} catch (Exception e) {
log.error("JWT Validation failed: {}", e.getMessage());
}
filterChain.doFilter(request, response);
}
@@ -1,6 +1,7 @@
package core_service.configuration.security;
import core_service.shared.security.ProfileTicketVerifier;
import core_service.shared.security.UserPrincipal;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
@@ -14,7 +15,7 @@ import java.util.Optional;
@Service
public class JwtService implements ProfileTicketVerifier {
// Only non-login tokens carry this claim; isTokenValid rejects them.
// Only non-login tokens carry this claim; parseLoginToken rejects them.
private static final String TYPE_CLAIM = "typ";
private static final String PROFILE_TICKET_TYPE = "public-profile";
@@ -28,27 +29,20 @@ public class JwtService implements ProfileTicketVerifier {
return parser.parseSignedClaims(token).getPayload();
}
public String extractEmail(String token) {
return claims(token).getSubject();
}
public String extractUserId(String token) {
return claims(token).get("userId", String.class);
}
public Long extractSteamId(String token) {
return claims(token).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.
* The user of a login token with a valid signature. Empty for anything else, also for a profile ticket: it is
* signed with the same key, but is no login.
*/
public boolean isTokenValid(String token) {
public Optional<UserPrincipal> parseLoginToken(String token) {
try {
Claims claims = claims(token);
return claims.get(TYPE_CLAIM) == null;
String userId = claims.get("userId", String.class);
if (claims.get(TYPE_CLAIM) != null || claims.getSubject() == null || userId == null) {
return Optional.empty();
}
return Optional.of(new UserPrincipal(userId, claims.getSubject(), claims.get("steamId", Long.class)));
} catch (Exception e) {
return false;
return Optional.empty();
}
}
@@ -6,17 +6,21 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.filter.OncePerRequestFilter;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
@@ -43,10 +47,13 @@ public class SecurityConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList(allowedOrigins));
// Exact origins only: with credentials, a pattern would send the login cookie to every matching site.
configuration.setAllowedOrigins(Arrays.asList(allowedOrigins));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(false);
configuration.setAllowedHeaders(List.of(
"Content-Type", "Authorization", ClientHeaderFilter.CLIENT_HEADER, "X-Profile-Ticket"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(Duration.ofHours(1));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
@@ -59,11 +66,14 @@ public class SecurityConfig {
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 401, not Spring's default 403: the frontend renews the access cookie only on a 401.
.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.authorizeHttpRequests(auth -> auth
.requestMatchers(WHITE_LIST_URLS).permitAll()
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.anyRequest().authenticated()
)
.addFilterAfter(new ClientHeaderFilter(), CorsFilter.class)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
@@ -0,0 +1,100 @@
package core_service.configuration.security;
import core_service.shared.security.UserPrincipal;
import core_service.shared.test.AbstractUnitTest;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.lenient;
/**
* Both login filters take the token from the access cookie or the Bearer header, the first one that verifies.
*/
class AccessTokenFiltersTest extends AbstractUnitTest {
private static final UserPrincipal USER = new UserPrincipal("42", "a@b.de", 76561198000000042L);
@Mock
private JwtService jwtService;
@AfterEach
void clearContext() {
SecurityContextHolder.clearContext();
}
private Object principalAfter(jakarta.servlet.Filter filter, MockHttpServletRequest request) throws Exception {
filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain());
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return authentication == null ? null : authentication.getPrincipal();
}
private void validToken(String token) {
lenient().when(jwtService.parseLoginToken(token)).thenReturn(Optional.of(USER));
}
@Test
void cookieAuthenticates() throws Exception {
validToken("good");
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "good"));
assertEquals(USER, principalAfter(new JwtAuthenticationFilter(jwtService), request));
}
@Test
void bearerAuthenticates() throws Exception {
validToken("good");
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer good");
assertEquals(USER, principalAfter(new JwtAuthenticationFilter(jwtService), request));
}
@Test
void staleCookieFallsBackToTheBearerHeader() throws Exception {
validToken("good");
lenient().when(jwtService.parseLoginToken("stale")).thenReturn(Optional.empty());
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "stale"));
request.addHeader("Authorization", "Bearer good");
assertEquals(USER, principalAfter(new JwtAuthenticationFilter(jwtService), request));
}
@Test
void invalidTokenStaysAnonymous() throws Exception {
lenient().when(jwtService.parseLoginToken("bad")).thenReturn(Optional.empty());
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "bad"));
assertNull(principalAfter(new JwtAuthenticationFilter(jwtService), request));
}
@Test
void devFilter_UsesTheCookieUser() throws Exception {
validToken("good");
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "good"));
assertEquals(USER, principalAfter(new DevJwtAuthenticationFilter(jwtService), request));
}
@Test
void devFilter_WithoutTokenUsesTheDummyUser() throws Exception {
UserPrincipal principal = (UserPrincipal) principalAfter(new DevJwtAuthenticationFilter(jwtService),
new MockHttpServletRequest());
assertEquals("1", principal.id());
}
}
@@ -0,0 +1,56 @@
package core_service.configuration.security;
import core_service.shared.test.AbstractIntegrationTest;
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.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The dev profile logs every request in, so only the CSRF header and CORS decide here.
*/
@AutoConfigureMockMvc
class ClientHeaderFilterTest extends AbstractIntegrationTest {
private static final String FRONTEND = "https://tbp.andreas-dahm.eu";
@Autowired
private MockMvc mockMvc;
@Test
void postWithoutClientHeader_Is403() throws Exception {
mockMvc.perform(post("/api/library/sync"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("CLIENT_HEADER_MISSING"));
}
@Test
void postWithClientHeader_PassesTheFilter() throws Exception {
int status = mockMvc.perform(post("/api/library/sync").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andReturn().getResponse().getStatus();
org.junit.jupiter.api.Assertions.assertNotEquals(403, status);
}
@Test
void corsPreflight_AllowsOnlyTheFrontendWithCookies() throws Exception {
mockMvc.perform(options("/api/library/sync")
.header("Origin", FRONTEND)
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Headers", "x-tbp-client"))
.andExpect(status().isOk())
.andExpect(header().string("Access-Control-Allow-Origin", FRONTEND))
.andExpect(header().string("Access-Control-Allow-Credentials", "true"));
mockMvc.perform(options("/api/library/sync")
.header("Origin", "https://evil.andreas-dahm.eu")
.header("Access-Control-Request-Method", "POST"))
.andExpect(status().isForbidden());
}
}
@@ -0,0 +1,42 @@
package core_service.configuration.security;
import core_service.shared.test.AbstractIntegrationTest;
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;
/**
* The real (non-dev) filter chain, where the token decides.
*/
@AutoConfigureMockMvc
@ActiveProfiles(value = "prod", inheritProfiles = false)
class ExpiredAccessTokenTest extends AbstractIntegrationTest {
@Autowired
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();
}
@Test
void expiredAccessCookie_Is401SoTheFrontendRefreshes() throws Exception {
mockMvc.perform(get("/api/library/summary").cookie(new Cookie(AccessTokens.ACCESS_COOKIE, expiredAccessToken())))
.andExpect(status().isUnauthorized());
}
}
@@ -1,5 +1,6 @@
package core_service.configuration.security;
import core_service.shared.security.UserPrincipal;
import core_service.shared.test.AbstractUnitTest;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach;
@@ -54,51 +55,40 @@ class JwtServiceTest extends AbstractUnitTest {
}
@Test
void extractEmail_Success() {
var email = "test@example.com";
var token = signToken("0815", email, null);
void parseLoginToken_ReturnsTheUser() {
String token = signToken("0815", "test@example.com", 123456789L);
assertEquals(email, jwtService.extractEmail(token));
assertEquals(Optional.of(new UserPrincipal("0815", "test@example.com", 123456789L)),
jwtService.parseLoginToken(token));
}
@Test
void extractUserId_Success() {
var userId = "0815";
var token = signToken(userId, "test@example.com", null);
assertEquals(userId, jwtService.extractUserId(token));
void parseLoginToken_RejectsGarbage() {
assertEquals(Optional.empty(), jwtService.parseLoginToken("not-a-jwt"));
}
@Test
void extractSteamId_Success() {
var steamId = 123456789L;
var token = signToken("0815", "test@example.com", steamId);
assertEquals(steamId, jwtService.extractSteamId(token));
}
@Test
void isTokenValid_Success() {
var token = signToken("0815", "test@example.com", null);
assertTrue(jwtService.isTokenValid(token));
}
@Test
void isTokenValid_Failure_garbage() {
assertFalse(jwtService.isTokenValid("not-a-jwt"));
}
@Test
void isTokenValid_Failure_wrongKey() throws Exception {
void parseLoginToken_RejectsAForeignKey() throws Exception {
KeyPair other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
String token = Jwts.builder()
.subject("test@example.com")
.claim("userId", "0815")
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(other.getPrivate(), Jwts.SIG.EdDSA)
.compact();
assertFalse(jwtService.isTokenValid(token));
assertEquals(Optional.empty(), jwtService.parseLoginToken(token));
}
@Test
void parseLoginToken_RejectsATokenWithoutUserId() {
String token = Jwts.builder()
.subject("test@example.com")
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(signingKey, Jwts.SIG.EdDSA)
.compact();
assertEquals(Optional.empty(), jwtService.parseLoginToken(token));
}
private String signTicket(String type, String profileUserId, long expiresInMs) {
@@ -130,7 +120,7 @@ class JwtServiceTest extends AbstractUnitTest {
}
@Test
void isTokenValid_ProfileTicketIsNoLogin() {
assertFalse(jwtService.isTokenValid(signTicket("public-profile", "42", 60_000)));
void parseLoginToken_ProfileTicketIsNoLogin() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(signTicket("public-profile", "42", 60_000)));
}
}
@@ -7,6 +7,7 @@ import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -56,7 +57,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
@Test
void updateBacklog_StoresAllFields() throws Exception {
mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON)
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"FINISHED\",\"rating\":4,\"notes\":\"Great ending\",\"priority\":\"HIGH\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("FINISHED"))
@@ -76,7 +77,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
game.setNotes("old");
userGameRepository.save(game);
mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON)
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"DROPPED\",\"notes\":\" \"}"))
.andExpect(status().isOk());
@@ -95,7 +96,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
"{\"status\":\"PLAYING\",\"rating\":6}",
"{\"status\":\"PLAYING\",\"priority\":\"URGENT\"}"})
void updateBacklog_InvalidBody_Is400AndStoresNothing(String body) throws Exception {
mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON).content(body))
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isBadRequest());
assertEquals(GameStatus.BACKLOG, stored().getStatus());
@@ -103,7 +104,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
@Test
void updateBacklog_FractionalRating_Is400() throws Exception {
mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON)
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\",\"rating\":4.7}"))
.andExpect(status().isBadRequest());
@@ -113,7 +114,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
@Test
void updateBacklog_NotesTooLong_Is400() throws Exception {
String notes = "x".repeat(2001);
mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON)
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\",\"notes\":\"" + notes + "\"}"))
.andExpect(status().isBadRequest());
}
@@ -124,7 +125,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
game.setRemovedAt(Instant.now());
userGameRepository.save(game);
mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON)
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
@@ -132,7 +133,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
@Test
void updateBacklog_GameNotOwned_Is404() throws Exception {
mockMvc.perform(put("/api/library/games/999/backlog").contentType(MediaType.APPLICATION_JSON)
mockMvc.perform(put("/api/library/games/999/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isNotFound());
}
@@ -145,7 +146,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
game.setPriority(GamePriority.HIGH);
userGameRepository.save(game);
mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON)
mockMvc.perform(put("/api/library/games/220/status").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PLAYING"))
@@ -160,7 +161,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
@Test
void updateStatus_MissingStatus_Is400() throws Exception {
mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON)
mockMvc.perform(put("/api/library/games/220/status").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
@@ -173,7 +174,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
game.setRemovedAt(Instant.now());
userGameRepository.save(game);
mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON)
mockMvc.perform(put("/api/library/games/220/status").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
@@ -181,7 +182,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
@Test
void updateStatus_GameNotOwned_Is404() throws Exception {
mockMvc.perform(put("/api/library/games/999/status").contentType(MediaType.APPLICATION_JSON)
mockMvc.perform(put("/api/library/games/999/status").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isNotFound());
}