Merge pull request 'Log out users automatically when their JWT expires.' (#11) from feature/expired-token-logout into master
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/11
This commit is contained in:
commit
c88fa142d3
24 changed files with 515 additions and 41 deletions
|
|
@ -170,11 +170,16 @@ export STRIPE_SECRET_KEY=sk_test_fake STRIPE_WEBHOOK_SECRET=whsec_fake STRIPE_PR
|
|||
./gradlew check
|
||||
```
|
||||
|
||||
This runs frontend lint, frontend unit tests (242), backend tests (163), coverage
|
||||
thresholds, Flyway checks, and **all 90 E2E tests in Docker**. **Do not commit or
|
||||
This runs frontend lint, frontend unit tests, backend tests, coverage
|
||||
thresholds, Flyway checks, and **all E2E tests in Docker**. **Do not commit or
|
||||
push if this fails.** Optional local guard: `./scripts/install-pre-commit-hook.sh`
|
||||
(runs the same `check` on every `git commit`).
|
||||
|
||||
**Note for agents:** The pre-commit hook runs the full `./gradlew check` which
|
||||
takes ~3.5 minutes. If your tool enforces a default timeout (e.g. 120 s on
|
||||
agent tool calls), increase it to ≥300 000 ms, or use `--no-verify` and run
|
||||
`./gradlew check` manually before committing.
|
||||
|
||||
### Frontend (Vue.js 3)
|
||||
- `<script setup>` with Composition API only. Never Options API.
|
||||
- File naming: PascalCase for pages/components, camelCase (`useXxx`) for composables.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
package se.bilhalsning.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
|
|
@ -10,6 +14,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import se.bilhalsning.dto.ErrorResponse;
|
||||
import se.bilhalsning.security.JwtAuthenticationFilter;
|
||||
import se.bilhalsning.security.JwtService;
|
||||
|
||||
|
|
@ -17,6 +22,13 @@ import se.bilhalsning.security.JwtService;
|
|||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
static final String UNAUTHENTICATED_MESSAGE =
|
||||
"Din session har löpt ut eller är ogiltig. Logga in igen.";
|
||||
static final String FORBIDDEN_MESSAGE =
|
||||
"Du har inte behörighet att utföra denna åtgärd.";
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
|
|
@ -46,8 +58,21 @@ public class SecurityConfig {
|
|||
.requestMatchers("/api/vehicles/**").permitAll()
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
.anyRequest().authenticated())
|
||||
.exceptionHandling(eh -> eh
|
||||
.authenticationEntryPoint((request, response, ex) ->
|
||||
writeError(response, HttpStatus.UNAUTHORIZED, UNAUTHENTICATED_MESSAGE))
|
||||
.accessDeniedHandler((request, response, ex) ->
|
||||
writeError(response, HttpStatus.FORBIDDEN, FORBIDDEN_MESSAGE)))
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
private void writeError(HttpServletResponse response, HttpStatus status, String message)
|
||||
throws java.io.IOException {
|
||||
response.setStatus(status.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
response.getWriter().write(objectMapper.writeValueAsString(new ErrorResponse(message)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ public class JwtService {
|
|||
this(secret, DEFAULT_EXPIRATION_MS);
|
||||
}
|
||||
|
||||
JwtService(String secret, long expirationMs) {
|
||||
public JwtService(String secret, long expirationMs) {
|
||||
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
|
||||
this.expirationMs = expirationMs;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,16 +42,18 @@ class AdminControllerTest {
|
|||
private AdminOrderWorkflowService adminOrderWorkflowService;
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
||||
void shouldReturn401WhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/orders"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se", roles = "USER")
|
||||
void shouldReturn403ForNonAdminUser() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/orders"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -225,7 +225,8 @@ class AuthControllerTest {
|
|||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -263,6 +264,7 @@ class AuthControllerTest {
|
|||
mockMvc.perform(post("/api/auth/change-email")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,16 +18,22 @@ 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;
|
||||
|
||||
|
|
@ -38,9 +44,10 @@ class OrderControllerTest {
|
|||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
||||
void shouldReturn401WhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/orders"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -100,11 +107,31 @@ class OrderControllerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenPostingWithoutAuth() throws Exception {
|
||||
void shouldReturn401WhenPostingWithoutAuth() throws Exception {
|
||||
mockMvc.perform(post("/api/orders")
|
||||
.contentType("application/json")
|
||||
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
.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
|
||||
|
|
|
|||
|
|
@ -39,10 +39,11 @@ class PaymentControllerTest {
|
|||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
||||
void shouldReturn401WhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/payment/{orderId}/pay",
|
||||
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -70,6 +70,13 @@ test.describe('Auth guards', () => {
|
|||
})
|
||||
|
||||
test('allows admin user to access /admin', async ({ page }) => {
|
||||
await page.route('**/api/admin/orders', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: '[]',
|
||||
}),
|
||||
)
|
||||
const jwt = makeJwt({ role: 'admin' })
|
||||
await page.goto('/')
|
||||
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||
|
|
|
|||
55
frontend/e2e/expired-token.spec.ts
Normal file
55
frontend/e2e/expired-token.spec.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Expired token logout', () => {
|
||||
test('router guard redirects expired token to login and logs out', async ({
|
||||
page,
|
||||
}) => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: past })
|
||||
|
||||
await page.goto('/')
|
||||
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||
|
||||
await page.goto('/orders')
|
||||
|
||||
await expect(page).toHaveURL(/\/logga-in\?redirect=\/orders/)
|
||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
|
||||
const header = page.locator('header')
|
||||
await expect(header.getByRole('link', { name: 'Logga in' })).toBeVisible()
|
||||
await expect(
|
||||
header.getByRole('button', { name: 'Logga ut' }),
|
||||
).not.toBeVisible()
|
||||
|
||||
const stored = await page.evaluate(() => localStorage.getItem('auth_token'))
|
||||
expect(stored).toBeNull()
|
||||
})
|
||||
|
||||
test('API 401 logs out and redirects when guard accepts token but backend rejects it', async ({
|
||||
page,
|
||||
}) => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600
|
||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: future })
|
||||
|
||||
await page.goto('/')
|
||||
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||
|
||||
await page.goto('/orders')
|
||||
await page.waitForURL(/\/logga-in\?redirect=\/orders/)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
|
||||
const header = page.locator('header')
|
||||
await expect(header.getByRole('button', { name: 'Logga ut' })).not.toBeVisible()
|
||||
|
||||
const stored = await page.evaluate(() => localStorage.getItem('auth_token'))
|
||||
expect(stored).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
const signature = 'test-sig'
|
||||
return `${header}.${body}.${signature}`
|
||||
}
|
||||
|
|
@ -100,6 +100,13 @@ test.describe('Header auth state', () => {
|
|||
})
|
||||
|
||||
test('logout redirects to home page', async ({ page }) => {
|
||||
await page.route('**/api/orders', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: '[]',
|
||||
}),
|
||||
)
|
||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
|
||||
await page.goto('/orders')
|
||||
await page.evaluate(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,26 @@ import { createRouter, createMemoryHistory } from 'vue-router'
|
|||
import { createPinia } from 'pinia'
|
||||
import OrdersPage from '@/pages/OrdersPage.vue'
|
||||
|
||||
const sessionMocks = vi.hoisted(() => {
|
||||
const mockLogout = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
return {
|
||||
mockLogout,
|
||||
mockPush,
|
||||
mockAuth: { isAuthenticated: true, logout: mockLogout },
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => sessionMocks.mockAuth,
|
||||
}))
|
||||
vi.mock('@/router', () => ({
|
||||
default: {
|
||||
currentRoute: { value: { fullPath: '/orders' } },
|
||||
push: sessionMocks.mockPush,
|
||||
},
|
||||
}))
|
||||
|
||||
function mockFetchResponse(status: number, body: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
|
|
@ -376,3 +396,34 @@ describe('OrdersPage', () => {
|
|||
expect(wrapper.find('.orders__preview-toggle').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OrdersPage — expired session (401)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
globalThis.fetch = vi.fn()
|
||||
sessionMocks.mockLogout.mockClear()
|
||||
sessionMocks.mockPush.mockClear()
|
||||
sessionMocks.mockAuth.isAuthenticated = true
|
||||
})
|
||||
|
||||
it('does not show generic error and triggers global logout/redirect on 401', async () => {
|
||||
vi.mocked(globalThis.fetch).mockImplementation((url) => {
|
||||
const urlStr = String(url)
|
||||
if (urlStr.includes('/payment/swish-info')) {
|
||||
return mockFetchResponse(200, { number: '123', amount: 49 })
|
||||
}
|
||||
return mockFetchResponse(401, { message: 'Din session har löpt ut.' })
|
||||
})
|
||||
localStorage.setItem('auth_token', 'expired-token')
|
||||
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(wrapper.text()).not.toContain('Kunde inte hämta beställningar')
|
||||
expect(sessionMocks.mockLogout).toHaveBeenCalledTimes(1)
|
||||
expect(sessionMocks.mockPush).toHaveBeenCalledWith({
|
||||
name: 'login',
|
||||
query: { redirect: '/orders' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -214,6 +214,64 @@ describe('Router guards', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Router guards — expired tokens', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('redirects expired-token user from /orders to /logga-in with redirect query', async () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
|
||||
await router.push('/orders')
|
||||
await router.isReady()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/orders')
|
||||
})
|
||||
|
||||
it('clears the expired token from localStorage on redirect', async () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
|
||||
await router.push('/orders')
|
||||
await router.isReady()
|
||||
|
||||
expect(localStorage.getItem('auth_token')).toBeNull()
|
||||
})
|
||||
|
||||
it('allows access with a token whose exp is in the future', async () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
|
||||
|
||||
await router.push('/orders')
|
||||
await router.isReady()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('orders')
|
||||
})
|
||||
|
||||
it('lets expired-token user open /logga-in instead of bouncing to home', async () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
|
||||
await router.push('/logga-in')
|
||||
await router.isReady()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
})
|
||||
|
||||
it('lets expired-token user open /registrera instead of bouncing to home', async () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
|
||||
await router.push('/registrera')
|
||||
await router.isReady()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('register')
|
||||
})
|
||||
})
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
|
|
|
|||
|
|
@ -212,6 +212,52 @@ describe('authStore', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('authStore.isTokenExpired', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns true when there is no token', () => {
|
||||
const store = useAuthStore()
|
||||
expect(store.isTokenExpired()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for a token with an expired exp claim', () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.isTokenExpired()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for a token with a future exp claim', () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.isTokenExpired()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a token without an exp claim', () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.isTokenExpired()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true after logout clears the token', async () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
|
||||
const store = useAuthStore()
|
||||
expect(store.isTokenExpired()).toBe(false)
|
||||
|
||||
store.logout()
|
||||
|
||||
expect(store.isTokenExpired()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
|
|
|
|||
125
frontend/src/__tests__/client.spec.ts
Normal file
125
frontend/src/__tests__/client.spec.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const mockLogout = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
return {
|
||||
mockLogout,
|
||||
mockPush,
|
||||
mockAuth: { isAuthenticated: true, logout: mockLogout },
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => mocks.mockAuth,
|
||||
}))
|
||||
vi.mock('@/router', () => ({
|
||||
default: {
|
||||
currentRoute: { value: { fullPath: '/orders' } },
|
||||
push: mocks.mockPush,
|
||||
},
|
||||
}))
|
||||
|
||||
import { request, ApiError, isSessionExpired, isForbidden } from '@/api/client'
|
||||
|
||||
function mockFetchResponse(status: number, body: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
})
|
||||
}
|
||||
|
||||
describe('api client', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
globalThis.fetch = vi.fn()
|
||||
mocks.mockLogout.mockClear()
|
||||
mocks.mockPush.mockClear()
|
||||
mocks.mockAuth.isAuthenticated = true
|
||||
})
|
||||
|
||||
it('logs out and redirects to login on 401 from a protected endpoint', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
|
||||
)
|
||||
localStorage.setItem('auth_token', 'expired-token')
|
||||
|
||||
await expect(request('/orders')).rejects.toThrow('Din session har löpt ut.')
|
||||
|
||||
expect(mocks.mockLogout).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.mockPush).toHaveBeenCalledWith({
|
||||
name: 'login',
|
||||
query: { redirect: '/orders' },
|
||||
})
|
||||
})
|
||||
|
||||
it('still throws ApiError with 401 status after handling expired session', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
|
||||
)
|
||||
|
||||
try {
|
||||
await request('/orders')
|
||||
throw new Error('should have thrown')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ApiError)
|
||||
expect((err as ApiError).status).toBe(401)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not log out on 401 from an auth endpoint (wrong credentials)', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }),
|
||||
)
|
||||
|
||||
await expect(request('/auth/login', { method: 'POST' })).rejects.toThrow(
|
||||
'Felaktig e-post eller lösenord',
|
||||
)
|
||||
|
||||
expect(mocks.mockLogout).not.toHaveBeenCalled()
|
||||
expect(mocks.mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not log out on 403 (forbidden is not session expiry)', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(403, { message: 'Du har inte behörighet' }),
|
||||
)
|
||||
localStorage.setItem('auth_token', 'valid-token')
|
||||
|
||||
await expect(request('/admin/orders')).rejects.toThrow(
|
||||
'Du har inte behörighet',
|
||||
)
|
||||
|
||||
expect(mocks.mockLogout).not.toHaveBeenCalled()
|
||||
expect(mocks.mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not redirect when there is no token on 401', async () => {
|
||||
mocks.mockAuth.isAuthenticated = false
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
|
||||
)
|
||||
|
||||
await expect(request('/orders')).rejects.toThrow()
|
||||
|
||||
expect(mocks.mockLogout).not.toHaveBeenCalled()
|
||||
expect(mocks.mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('isSessionExpired returns true only for a 401 ApiError', () => {
|
||||
expect(isSessionExpired(new ApiError(401, 'x'))).toBe(true)
|
||||
expect(isSessionExpired(new ApiError(403, 'x'))).toBe(false)
|
||||
expect(isSessionExpired(new ApiError(500, 'x'))).toBe(false)
|
||||
expect(isSessionExpired(new Error('x'))).toBe(false)
|
||||
expect(isSessionExpired(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('isForbidden returns true only for a 403 ApiError', () => {
|
||||
expect(isForbidden(new ApiError(403, 'x'))).toBe(true)
|
||||
expect(isForbidden(new ApiError(401, 'x'))).toBe(false)
|
||||
expect(isForbidden(new Error('x'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
import { useAuthStore } from '@/stores/authStore'
|
||||
import router from '@/router'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '/api'
|
||||
|
||||
export class ApiError extends Error {
|
||||
|
|
@ -10,10 +13,27 @@ export class ApiError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export function isSessionExpired(err: unknown): boolean {
|
||||
return err instanceof ApiError && err.status === 401
|
||||
}
|
||||
|
||||
export function isForbidden(err: unknown): boolean {
|
||||
return err instanceof ApiError && err.status === 403
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem('auth_token')
|
||||
}
|
||||
|
||||
function handleExpiredSession(): void {
|
||||
const auth = useAuthStore()
|
||||
if (auth.isAuthenticated) {
|
||||
auth.logout()
|
||||
const redirect = router.currentRoute.value.fullPath
|
||||
router.push({ name: 'login', query: { redirect } })
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
|
|
@ -34,6 +54,9 @@ export async function request<T>(
|
|||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && !url.startsWith('/auth/')) {
|
||||
handleExpiredSession()
|
||||
}
|
||||
const body = await response.json().catch(() => ({}))
|
||||
throw new ApiError(response.status, body.message || 'Något gick fel')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ref, reactive, type Ref } from 'vue'
|
||||
import { ApiError } from '@/api/client'
|
||||
import { ApiError, isSessionExpired } from '@/api/client'
|
||||
import {
|
||||
updateOrderStatus,
|
||||
registerShipment,
|
||||
|
|
@ -69,12 +69,14 @@ export function useAdminOrderActions(
|
|||
replaceOrder(updated)
|
||||
} catch (err) {
|
||||
order.status = previousStatus
|
||||
if (!isSessionExpired(err)) {
|
||||
statusError.value =
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: 'Kunde inte uppdatera status. Försök igen.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegisterShipment(orderId: string) {
|
||||
const trackingInput = trackingInputValues[orderId]?.trim()
|
||||
|
|
@ -100,11 +102,13 @@ export function useAdminOrderActions(
|
|||
)
|
||||
replaceOrder(updated)
|
||||
trackingInputValues[orderId] = updated.trackingId ?? trackingInput
|
||||
} catch {
|
||||
} catch (err) {
|
||||
order.status = previousStatus
|
||||
order.trackingId = previousTrackingId
|
||||
if (!isSessionExpired(err)) {
|
||||
trackingError.value =
|
||||
'Kunde inte registrera utskick. Kontrollera spårnings-ID och försök igen.'
|
||||
}
|
||||
} finally {
|
||||
registeringId.value = null
|
||||
}
|
||||
|
|
@ -123,10 +127,12 @@ export function useAdminOrderActions(
|
|||
const updated = await updateAdminNotes(orderId, notes)
|
||||
replaceOrder(updated)
|
||||
adminNotesValues[orderId] = updated.adminNotes ?? ''
|
||||
} catch {
|
||||
} catch (err) {
|
||||
order.adminNotes = previousNotes
|
||||
adminNotesValues[orderId] = previousNotes ?? ''
|
||||
if (!isSessionExpired(err)) {
|
||||
notesError.value = 'Kunde inte spara anteckningar. Försök igen.'
|
||||
}
|
||||
} finally {
|
||||
savingNotesId.value = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import { fetchAllOrders, type AdminOrder } from '@/api/admin'
|
||||
import { isSessionExpired } from '@/api/client'
|
||||
import { PAID_GROUP_STATUSES } from '@/constants/orderStatus'
|
||||
|
||||
export type AdminOrderFilter =
|
||||
|
|
@ -61,8 +62,10 @@ export function useAdminOrders() {
|
|||
error.value = ''
|
||||
try {
|
||||
orders.value = await fetchAllOrders()
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (!isSessionExpired(err)) {
|
||||
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { createOrder } from '@/api/orders'
|
||||
import { isSessionExpired } from '@/api/client'
|
||||
import { type LetterTemplate } from '@/data/templates'
|
||||
import TemplatePicker from '@/components/TemplatePicker.vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
|
@ -41,8 +42,10 @@ async function handleSubmit() {
|
|||
params: { orderId: order.id },
|
||||
query: { plate: plate.value },
|
||||
})
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (!isSessionExpired(err)) {
|
||||
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { fetchOrder, updateOrder, type Order } from '@/api/orders'
|
||||
import { isSessionExpired } from '@/api/client'
|
||||
import { type LetterTemplate } from '@/data/templates'
|
||||
import TemplatePicker from '@/components/TemplatePicker.vue'
|
||||
|
||||
|
|
@ -44,8 +45,10 @@ async function loadOrder() {
|
|||
if (fetched.status === 'pending_payment') {
|
||||
letterText.value = fetched.letterText
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (!isSessionExpired(err)) {
|
||||
loadError.value = 'Kunde inte hämta beställningen. Försök igen senare.'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
|
@ -64,8 +67,10 @@ async function handleSubmit() {
|
|||
params: { orderId: order.value.id },
|
||||
query: { plate: order.value.plate },
|
||||
})
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (!isSessionExpired(err)) {
|
||||
errorMessage.value = 'Kunde inte spara ändringarna. Försök igen senare.'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { ref, computed, onMounted } from 'vue'
|
||||
import { fetchOrders, cancelOrder, type Order } from '@/api/orders'
|
||||
import { fetchSwishInfo } from '@/api/payment'
|
||||
import { isSessionExpired } from '@/api/client'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import {
|
||||
ORDER_STATUS_BADGE,
|
||||
|
|
@ -65,8 +66,10 @@ async function loadOrders() {
|
|||
])
|
||||
orders.value = fetchedOrders
|
||||
orderAmount.value = swishInfo.amount
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (!isSessionExpired(err)) {
|
||||
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
|
@ -87,8 +90,11 @@ async function handleCancel(order: Order) {
|
|||
try {
|
||||
const updated = await cancelOrder(order.id)
|
||||
orders.value = orders.value.map((o) => (o.id === updated.id ? updated : o))
|
||||
} catch {
|
||||
actionError.value = 'Kunde inte avbryta beställningen. Försök igen senare.'
|
||||
} catch (err) {
|
||||
if (!isSessionExpired(err)) {
|
||||
actionError.value =
|
||||
'Kunde inte avbryta beställningen. Försök igen senare.'
|
||||
}
|
||||
} finally {
|
||||
cancellingId.value = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { payOrder, fetchSwishInfo } from '@/api/payment'
|
||||
import { isSessionExpired } from '@/api/client'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
|
@ -38,8 +39,10 @@ async function confirmPayment() {
|
|||
try {
|
||||
await payOrder(orderId)
|
||||
await router.push({ name: 'orders' })
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (!isSessionExpired(err)) {
|
||||
error.value = 'Kunde inte bekräfta betalningen. Försök igen.'
|
||||
}
|
||||
} finally {
|
||||
paying.value = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,9 +148,11 @@ router.beforeEach((to) => {
|
|||
if (!getActivePinia()) return
|
||||
|
||||
const auth = useAuthStore()
|
||||
const authenticated = auth.isAuthenticated && !auth.isTokenExpired()
|
||||
|
||||
if (to.meta.guestOnly && auth.isAuthenticated) return { name: 'home' }
|
||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||
if (to.meta.guestOnly && authenticated) return { name: 'home' }
|
||||
if (to.meta.requiresAuth && !authenticated) {
|
||||
if (auth.isAuthenticated) auth.logout()
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
if (to.meta.requiresAdmin && !auth.isAdmin) return { name: 'home' }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { register, login, changeEmail, confirmEmailChange } from '@/api/auth'
|
||||
import { parseJwtPayload } from '@/utils/jwt'
|
||||
import { parseJwtPayload, isTokenExpired as isJwtExpired } from '@/utils/jwt'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem('auth_token'))
|
||||
|
|
@ -21,6 +21,10 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
return payload.role ?? null
|
||||
}
|
||||
|
||||
function isTokenExpired(): boolean {
|
||||
return isJwtExpired(token.value)
|
||||
}
|
||||
|
||||
function setToken(newToken: string) {
|
||||
token.value = newToken
|
||||
role.value = extractRole(newToken)
|
||||
|
|
@ -69,6 +73,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
email,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
isTokenExpired,
|
||||
registerUser,
|
||||
loginUser,
|
||||
changeUserEmail,
|
||||
|
|
|
|||
|
|
@ -20,3 +20,10 @@ export function parseJwtPayload(token: string): JwtPayload {
|
|||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function isTokenExpired(token: string | null): boolean {
|
||||
if (!token) return true
|
||||
const payload = parseJwtPayload(token)
|
||||
if (payload.exp === undefined || payload.exp === null) return false
|
||||
return payload.exp < Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue