bilhej/frontend/e2e/header-auth.spec.ts
Joakim Mörling 489fb0335a
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m10s
CI / E2E browser tests (pull_request) Successful in 4m7s
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:07:46 +02:00

256 lines
7.8 KiB
TypeScript

import { test, expect } from '@playwright/test'
test.describe('Header auth state', () => {
test('shows login and register links when not authenticated', async ({
page,
}) => {
await page.goto('/')
const header = page.locator('header')
await expect(header.getByRole('link', { name: 'Logga in' })).toBeVisible()
await expect(
header.getByRole('link', { name: 'Registrera' }),
).toBeVisible()
})
test('does not show logout button when not authenticated', async ({
page,
}) => {
await page.goto('/')
const header = page.locator('header')
await expect(
header.getByRole('button', { name: 'Logga ut' }),
).not.toBeVisible()
})
test('shows email and logout when authenticated', async ({ page }) => {
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
jwt,
)
await page.goto('/')
const header = page.locator('header')
await expect(header.getByText('test@bilhej.se')).toBeVisible()
await expect(
header.getByRole('button', { name: 'Logga ut' }),
).toBeVisible()
})
test('shows orders link when authenticated', async ({ page }) => {
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
jwt,
)
await page.goto('/')
const header = page.locator('header')
const ordersLink = header.getByRole('link', {
name: 'Mina beställningar',
})
await expect(ordersLink).toBeVisible()
await expect(ordersLink).toHaveAttribute('href', '/orders')
})
test('hides login and register links when authenticated', async ({
page,
}) => {
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
jwt,
)
await page.goto('/')
const header = page.locator('header')
await expect(
header.getByRole('link', { name: 'Logga in' }),
).not.toBeVisible()
await expect(
header.getByRole('link', { name: 'Registrera' }),
).not.toBeVisible()
})
test('logout restores login and register links', async ({ page }) => {
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
jwt,
)
await page.goto('/')
const header = page.locator('header')
await header.getByRole('button', { name: 'Logga ut' }).click()
await expect(
header.getByRole('link', { name: 'Logga in' }),
).toBeVisible()
await expect(
header.getByRole('link', { name: 'Registrera' }),
).toBeVisible()
await expect(
header.getByRole('button', { name: 'Logga ut' }),
).not.toBeVisible()
await expect(header.getByText('test@bilhej.se')).not.toBeVisible()
})
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(
(token) => localStorage.setItem('auth_token', token),
jwt,
)
await page.goto('/orders')
await page.waitForURL('/orders')
await page.locator('header').getByRole('button', { name: 'Logga ut' }).click()
await expect(page).toHaveURL('/')
})
test('shows admin link when admin is authenticated', async ({ page }) => {
const jwt = makeJwt({ sub: 'admin@bilhalsning.se', role: 'admin' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
jwt,
)
await page.goto('/')
const header = page.locator('header')
const adminLink = header.getByRole('link', { name: 'Admin' })
await expect(adminLink).toBeVisible()
await expect(adminLink).toHaveAttribute('href', '/admin')
})
test('does not show admin link for regular user', async ({ page }) => {
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
jwt,
)
await page.goto('/')
const header = page.locator('header')
await expect(
header.getByRole('link', { name: 'Admin' }),
).not.toBeVisible()
})
test('shows settings button when authenticated', async ({ page }) => {
await authenticateUser(page)
const header = page.locator('header')
await expect(
header.getByRole('button', { name: 'Inställningar' }),
).toBeVisible()
})
test('settings menu links to change email and password pages', async ({
page,
}) => {
await authenticateUser(page)
const header = page.locator('header')
const settingsButton = header.getByRole('button', { name: 'Inställningar' })
await settingsButton.click()
const menu = header.getByRole('menu')
await expect(
menu.getByRole('menuitem', { name: 'Byt e-postadress' }),
).toHaveAttribute('href', '/andra-epost')
await expect(
menu.getByRole('menuitem', { name: 'Byt lösenord' }),
).toHaveAttribute('href', '/andra-losenord')
})
test('highlights settings button on change password page', async ({
page,
}) => {
await authenticateUser(page)
await page.goto('/andra-losenord')
const settingsButton = page
.locator('header')
.getByRole('button', { name: 'Inställningar' })
await expect(settingsButton).toHaveClass(/app-header__settings-trigger--active/)
await expect(
page.getByRole('heading', { name: 'Byt lösenord' }),
).toBeVisible()
})
test('highlights settings button on change email page', async ({ page }) => {
await authenticateUser(page)
await page.goto('/andra-epost')
const settingsButton = page
.locator('header')
.getByRole('button', { name: 'Inställningar' })
await expect(settingsButton).toHaveClass(/app-header__settings-trigger--active/)
await expect(
page.getByRole('heading', { name: 'Byt e-postadress' }),
).toBeVisible()
})
})
test.describe('Header on mobile viewport', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('menu reveals navigation links when authenticated', async ({ page }) => {
await authenticateUser(page)
await page.goto('/')
const header = page.locator('header')
await expect(
header.getByRole('link', { name: 'Mina beställningar' }),
).not.toBeVisible()
await header.getByRole('button', { name: 'Öppna meny' }).click()
await expect(
header.getByRole('link', { name: 'Mina beställningar' }),
).toBeVisible()
await expect(
header.getByRole('link', { name: 'Byt e-postadress' }),
).toBeVisible()
})
test('home page has no horizontal overflow', async ({ page }) => {
await page.goto('/')
const scrollWidth = await page.evaluate(
() => document.documentElement.scrollWidth,
)
const clientWidth = await page.evaluate(
() => document.documentElement.clientWidth,
)
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1)
})
})
async function authenticateUser(page: import('@playwright/test').Page) {
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
await page.goto('/')
}
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}`
}