bilhej/backend/src/test/java/se/bilhalsning/controller/OrderControllerTest.java
Joakim Mörling 81e3968e31
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m11s
CI / E2E browser tests (pull_request) Successful in 3m57s
Log out users automatically when their JWT expires.
Previously an expired token left the frontend in a stuck state: the
router guard only checked token presence (never the exp claim), so the
user could still navigate to protected pages, and every API call then
failed with a generic Swedish "Kunde inte hämta…" message while the
header kept showing the logged-in UI. There was no global response
interceptor, and the backend returned an ambiguous 403 (no body) for
unauthenticated requests because no AuthenticationEntryPoint was
configured, making 403 mean both "no/invalid token" and "forbidden".

Backend:
- Add an AuthenticationEntryPoint in SecurityConfig that returns 401
  with a Swedish {"message": ...} ErrorResponse body for
  unauthenticated/expired-token requests, and an AccessDeniedHandler
  returning 403 with the same body shape for genuine authorization
  failures. This makes 401 = not authenticated/expired and
  403 = authenticated but forbidden, the standard REST convention.
- Make JwtService(String, long) constructor public so integration
  tests can mint expired tokens (was package-private).
- Update the 6 no-auth controller tests from 403 to 401
  (OrderControllerTest, AdminControllerTest, PaymentControllerTest,
  AuthControllerTest change-password/change-email) and assert the
  message body exists; keep shouldReturn403ForNonAdminUser as 403.
- Add OrderControllerTest.shouldReturn401WithSwedishMessageWhenTokenExpired
  (expired JWT via TTL -1000ms) and shouldReturn401WithMessageWhenNoAuthHeader.

Frontend:
- Add isTokenExpired() to utils/jwt.ts using the previously-unused exp
  claim, and expose it on the auth store.
- Add a global 401 interceptor in api/client.ts: on a 401 from any
  non-/auth/ endpoint, call auth.logout() and redirect to
  /logga-in?redirect=<currentPath>. Skip /auth/ so wrong-password 401s
  on login/change-password stay handled locally. Add isSessionExpired
  and isForbidden helpers for per-page catch blocks.
- Harden the router guard to reject tokens whose exp is in the past
  (logout + redirect to login with ?redirect=), and let expired-token
  users open /logga-in and /registrera instead of bouncing to home.
- Refactor the generic-error catch blocks on OrdersPage, EditOrderPage,
  ComposePage, PaymentRedirect, useAdminOrders, and useAdminOrderActions
  to skip the generic Swedish message on 401 (handled globally) while
  preserving wrong-password 401 handling on change-pw/email pages.

Tests:
- New frontend/src/__tests__/client.spec.ts covering 401 -> logout +
  redirect, 401 from /auth/ -> no logout, 403 -> no logout, no-token
  401 -> no redirect, and isSessionExpired/isForbidden helpers.
- Add authStore.spec.ts cases for isTokenExpired (no token, past exp,
  future exp, missing exp, after logout).
- Add Router.spec.ts cases for expired-token redirects, token clearing,
  future-exp access, and guest pages not bouncing expired users.
- Add OrdersPage.spec.ts case asserting 401 triggers no generic error
  and the global logout/redirect.
- New E2E expired-token.spec.ts (Docker) covering both the router-guard
  expired-token redirect and the API-401 redirect, with logged-out
  header and cleared localStorage assertions.
- Mock the API in two pre-existing fake-JWT E2E tests
  (auth-guards admin access, header-auth logout redirect) that broke
  because the backend now correctly 401s their unsigned test-sig tokens.

Verified with ./gradlew check (frontend lint + 267 unit tests, backend
tests + coverage, Flyway, 92 E2E tests in Docker) and ./gradlew coverage;
all coverage thresholds maintained (jwt.ts at 100%).
2026-06-17 12:43:31 +02:00

306 lines
12 KiB
Java

package se.bilhalsning.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.entity.User;
import se.bilhalsning.security.JwtService;
import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService;
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!")
class OrderControllerTest {
private static final String TEST_SECRET =
"this-is-a-test-secret-that-is-at-least-32-bytes-long!!";
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OrderService orderService;
@MockitoBean
private UserService userService;
@Test
void shouldReturn401WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldReturnOrdersForAuthenticatedUser() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
when(orderService.getOrdersByUserId(userId)).thenReturn(List.of());
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$").isEmpty());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldReturnOrderWithAllFields() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
order.setUserId(userId);
order.setPlate("ABC123");
order.setLetterText("Test letter");
order.setStatus(se.bilhalsning.entity.OrderStatus.SENT);
order.setTrackingId("PN123456789");
when(orderService.getOrdersByUserId(userId)).thenReturn(List.of(order));
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
.andExpect(jsonPath("$[0].plate").value("ABC123"))
.andExpect(jsonPath("$[0].letterText").value("Test letter"))
.andExpect(jsonPath("$[0].status").value("sent"))
.andExpect(jsonPath("$[0].trackingId").value("PN123456789"));
}
@Test
@WithMockUser(username = "unknown@example.com")
void shouldReturn401WhenUserNotFound() throws Exception {
when(userService.findByEmail("unknown@example.com")).thenReturn(Optional.empty());
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized());
}
@Test
void shouldReturn401WhenPostingWithoutAuth() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
}
@Test
void shouldReturn401WithSwedishMessageWhenTokenExpired() throws Exception {
JwtService expiredJwtService = new JwtService(TEST_SECRET, -1000L);
String expiredToken = expiredJwtService.generateToken("test@bilhej.se");
mockMvc.perform(get("/api/orders")
.header("Authorization", "Bearer " + expiredToken))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
}
@Test
void shouldReturn401WithMessageWhenNoAuthHeader() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message")
.value(org.hamcrest.Matchers.containsString("session")));
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldCreateOrderSuccessfully() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order savedOrder = new se.bilhalsning.entity.Order();
savedOrder.setId(UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
savedOrder.setUserId(userId);
savedOrder.setPlate("ABC123");
savedOrder.setLetterText("Hej fin bil!");
savedOrder.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
when(orderService.createOrder(userId, "ABC123", "Hej fin bil!"))
.thenReturn(savedOrder);
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej fin bil!\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
.andExpect(jsonPath("$.plate").value("ABC123"))
.andExpect(jsonPath("$.letterText").value("Hej fin bil!"))
.andExpect(jsonPath("$.status").value("pending_payment"));
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldRejectInvalidPlateFormat() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"INVALID\",\"letterText\":\"Hej\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("Ogiltigt registreringsnummer")));
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldRejectEmptyLetterText() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldRejectLetterTextOver1000Chars() throws Exception {
String longText = "a".repeat(1001);
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"" + longText + "\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldGetSingleOrderForOwner() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(orderId);
order.setUserId(userId);
order.setPlate("ABC123");
order.setLetterText("Test letter");
order.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
when(orderService.getOrderById(orderId)).thenReturn(order);
mockMvc.perform(get("/api/orders/" + orderId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.plate").value("ABC123"))
.andExpect(jsonPath("$.status").value("pending_payment"));
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldReturn404WhenGettingOtherUsersOrder() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(orderId);
order.setUserId(UUID.randomUUID());
when(orderService.getOrderById(orderId)).thenReturn(order);
mockMvc.perform(get("/api/orders/" + orderId))
.andExpect(status().isNotFound());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldPatchOrderSuccessfully() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(orderId);
order.setUserId(userId);
order.setPlate("ABC123");
order.setLetterText("Updated text");
order.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
when(orderService.updatePendingOrder(any(), any(), any())).thenReturn(order);
mockMvc.perform(patch("/api/orders/" + orderId)
.contentType("application/json")
.content("{\"letterText\":\"Updated text\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.letterText").value("Updated text"));
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldRejectPatchWithEmptyLetterText() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
mockMvc.perform(patch("/api/orders/" + orderId)
.contentType("application/json")
.content("{\"letterText\":\"\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldCancelOrderSuccessfully() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(orderId);
order.setUserId(userId);
order.setPlate("ABC123");
order.setLetterText("Test letter");
order.setStatus(se.bilhalsning.entity.OrderStatus.CANCELLED);
when(orderService.cancelOrder(orderId, userId)).thenReturn(order);
mockMvc.perform(post("/api/orders/" + orderId + "/cancel"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("cancelled"));
}
}