Compare commits

..

11 commits

Author SHA1 Message Date
1a9d2fe688 Merge pull request 'feat(payment): Swish QR code and pre-filled payment link' (#15) from feature/swish-qr-payment into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m20s
CI / E2E browser tests (push) Successful in 3m31s
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/15
2026-06-19 15:38:18 +00:00
Hermes Agent
d9aa2d60af fix(e2e): use unique plate in QR code test to avoid admin row collision
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m11s
CI / E2E browser tests (pull_request) Successful in 3m29s
The new 'shows QR code for desktop scanning' E2E test used plate JKL012,
which is the same plate seeded as a processing order in the dev migrations
(V7__seed_processing_order.sql) and used by the admin dashboard/fulfillment
tests as PROCESSING_PLATE.

Because the E2E chromium (parallel) tests run before the chromium-serial
tests, the QR test created a second order with plate JKL012. When the serial
admin tests then searched for rows matching JKL012, Playwright's strict
mode found 2 matching rows and threw a strict mode violation.

This caused 4 test failures + 2 skipped tests:
- admin-dashboard: click row shows tracking section
- admin-dashboard: click row again collapses it
- admin-dashboard: expanded row shows tracking input and save button
- admin-fulfillment: can register shipment for processing order
- admin-fulfillment: can mark sent order as delivered (skipped)
- admin-fulfillment: can mark delivered order as failed then back to sent (skipped)

Changed the plate to QRA222 — not used in any seed data or other E2E test.
2026-06-19 14:04:26 +00:00
Hermes Agent
00d1f48218 feat(payment): Swish QR code and pre-filled payment link
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m29s
CI / E2E browser tests (pull_request) Failing after 3m49s
Replace manual "type the number and order ID" flow with:
- Client-side QR code (qrcode npm package) for desktop users
- Pre-filled Swish payment URL (app.swish.nu) for mobile users
- Manual number fallback + "Jag har betalat" confirmation

The Swish C2B URL scheme pre-fills amount and message (order ID)
without requiring any Swish Commerce API certificate or bank agreement.

Supports both personal phone numbers (070...) and Swish Företag
business numbers (123...) via number normalization in buildSwishPaymentUrl().

Set SWISH_NUMBER in .env to a Företags number once set up.
2026-06-19 12:06:29 +00:00
56cbc6fedf Merge pull request 'develop' (#14) from develop into master
Some checks failed
CI / E2E browser tests (push) Has been cancelled
CI / Lint, type check, unit tests, coverage (push) Has been cancelled
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/14
2026-06-17 13:45:03 +00:00
f60af7237e Merge pull request 'Autofill deploy version from latest git tag' (#13) from feature/auto-version-from-tag into develop
Some checks failed
CI / E2E browser tests (push) Has been cancelled
CI / Lint, type check, unit tests, coverage (push) Has been cancelled
CI / E2E browser tests (pull_request) Has been cancelled
CI / Lint, type check, unit tests, coverage (pull_request) Has been cancelled
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/13
2026-06-17 13:44:28 +00:00
9a63ff69e7 Autofill deploy version from latest git tag instead of hardcoded v0.1.0
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m6s
CI / E2E browser tests (pull_request) Successful in 3m20s
The deploy.yml workflow_dispatch input always defaulted to 'v0.1.0',
requiring manual edit every time. Now the version defaults to 'auto',
which fetches all tags, finds the latest v* tag via semver sort, bumps
the patch component, and uses that as the deploy tag.

Changes:
- deploy.yml input: default changed to 'auto', required → false,
  description updated to explain both auto and manual modes
- Added 'Resolve version' step: fetches tags, bumps latest semver
  tag by patch, validates output format, exports to $VERSION
- 'Tag version' step: substituted ${{ github.event.inputs.version }}
  → ${{ env.VERSION }} to use the resolved/computed version
- 'Print deploy status' step: same substitution
- Semver validation guard rejects malformed tags (auto and manual)
2026-06-17 15:00:27 +02:00
d7739bcd58 Merge pull request 'fix(admin): restore broken table styling after focused-modules refactor' (#12) from fix/admin-table-styling into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m6s
CI / E2E browser tests (push) Successful in 3m21s
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/12
2026-06-17 12:39:39 +00:00
Hermes Agent
4b35d8ff30 fix(admin): restore broken table styling after focused-modules refactor
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 6m8s
CI / E2E browser tests (pull_request) Successful in 5m53s
The c7eeaf6 refactor split the monolithic AdminPage into AdminStatsBar,
AdminOrdersTable, and AdminOrderDetailPanel. All CSS rules for the admin
sub-components were left in AdminPage.vue's `<style scoped>` block. Vue 3
scoped styles only apply to elements in the parent's own template — they
do not penetrate multi-root child components. Every element rendered by
the extracted sub-components lost its styling (stats grid, table layout,
column widths, status badges, expand icons, row highlighting, expanded
detail panels, etc.), making the admin page appear visually broken.

Changes:
- frontend/src/pages/AdminPage.vue: remove `scoped` attribute from the
  `<style>` block. All selectors are BEM-namespaced under `.admin__*`
  so making them global is safe and is the minimal fix.

Visual verification: N/A (sandbox cannot reach production). See git diff
for the one-character change.
2026-06-17 11:29:24 +00:00
c88fa142d3 Merge pull request 'Log out users automatically when their JWT expires.' (#11) from feature/expired-token-logout into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m38s
CI / E2E browser tests (push) Successful in 3m27s
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/11
2026-06-17 10:44:18 +00:00
81e3968e31 Log out users automatically when their JWT expires.
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
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
5335ba4f12 Merge pull request 'chore: make dev Dockerfiles self-contained, add bindless dev override' (#10) from chore/dockerfile-self-contained into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m33s
CI / E2E browser tests (push) Successful in 3m22s
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/10
2026-06-17 10:34:03 +00:00
32 changed files with 1055 additions and 111 deletions

View file

@ -24,6 +24,12 @@ STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_ID=price_... STRIPE_PRICE_ID=price_...
# ---------- Swish (Phase 0) ---------- # ---------- Swish (Phase 0) ----------
# The Swish number customers pay to. Two formats accepted:
# - Swedish phone number: 0701234567 (normalised to 46… for the payment URL)
# - Swish Business number: 1234567890 (starts with 123, used as-is)
# A Swish Business number (123…) is recommended — get one from your bank
# via a "Swish Företag" agreement. No Swish Commerce API certificate needed;
# the frontend generates a pre-filled QR code + payment link automatically.
SWISH_NUMBER=0701234567 SWISH_NUMBER=0701234567
# ---------- App URL (password reset links in email) ---------- # ---------- App URL (password reset links in email) ----------

View file

@ -4,9 +4,9 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: 'Git tag to create for this deploy (e.g. v0.1.2) — not the branch/tag above' description: 'Leave as "auto" to bump from latest git tag, or enter a specific version (e.g. v0.1.2)'
required: true required: false
default: 'v0.1.0' default: 'auto'
type: string type: string
jobs: jobs:
@ -21,12 +21,36 @@ jobs:
git fetch --depth 1 origin ${GITHUB_SHA} git fetch --depth 1 origin ${GITHUB_SHA}
git checkout FETCH_HEAD git checkout FETCH_HEAD
- name: Resolve version
run: |
INPUT_VERSION="${{ github.event.inputs.version }}"
if [ -z "$INPUT_VERSION" ] || [ "$INPUT_VERSION" = "auto" ]; then
git fetch --tags origin
LATEST=$(git tag --list 'v*' --sort=-v:refname | head -1)
if [ -z "$LATEST" ]; then LATEST="v0.0.0"; fi
BASE="${LATEST#v}"
MAJOR=$(echo "$BASE" | cut -d. -f1)
MINOR=$(echo "$BASE" | cut -d. -f2)
PATCH=$(echo "$BASE" | cut -d. -f3)
PATCH=$(( ${PATCH:-0} + 1 ))
VERSION="v${MAJOR:-0}.${MINOR:-0}.${PATCH}"
echo "Latest tag: $LATEST → auto-bumped to $VERSION"
else
VERSION="$INPUT_VERSION"
echo "Using manual version: $VERSION"
fi
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "ERROR: resolved version '$VERSION' is not valid semver (expected vX.Y.Z)"
exit 1
fi
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Tag version - name: Tag version
run: | run: |
git tag -d ${{ github.event.inputs.version }} 2>/dev/null || true git tag -d ${{ env.VERSION }} 2>/dev/null || true
git push origin --delete ${{ github.event.inputs.version }} 2>/dev/null || true git push origin --delete ${{ env.VERSION }} 2>/dev/null || true
git tag ${{ github.event.inputs.version }} git tag ${{ env.VERSION }}
git push origin ${{ github.event.inputs.version }} git push origin ${{ env.VERSION }}
- name: Write production .env - name: Write production .env
env: env:
@ -134,7 +158,7 @@ jobs:
run: | run: |
echo "" echo ""
echo "═══════════════════════════════════════════════════" echo "═══════════════════════════════════════════════════"
echo " Deployed ${{ github.event.inputs.version }} to production" echo " Deployed ${{ env.VERSION }} to production"
echo "═══════════════════════════════════════════════════" echo "═══════════════════════════════════════════════════"
echo "" echo ""
docker compose -p bilhej-prod -f docker-compose.prod.yml ps docker compose -p bilhej-prod -f docker-compose.prod.yml ps

View file

@ -170,11 +170,16 @@ export STRIPE_SECRET_KEY=sk_test_fake STRIPE_WEBHOOK_SECRET=whsec_fake STRIPE_PR
./gradlew check ./gradlew check
``` ```
This runs frontend lint, frontend unit tests (242), backend tests (163), coverage This runs frontend lint, frontend unit tests, backend tests, coverage
thresholds, Flyway checks, and **all 90 E2E tests in Docker**. **Do not commit or 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` push if this fails.** Optional local guard: `./scripts/install-pre-commit-hook.sh`
(runs the same `check` on every `git commit`). (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) ### Frontend (Vue.js 3)
- `<script setup>` with Composition API only. Never Options API. - `<script setup>` with Composition API only. Never Options API.
- File naming: PascalCase for pages/components, camelCase (`useXxx`) for composables. - File naming: PascalCase for pages/components, camelCase (`useXxx`) for composables.

View file

@ -1,8 +1,12 @@
package se.bilhalsning.config; 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.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 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.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy; 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.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import se.bilhalsning.dto.ErrorResponse;
import se.bilhalsning.security.JwtAuthenticationFilter; import se.bilhalsning.security.JwtAuthenticationFilter;
import se.bilhalsning.security.JwtService; import se.bilhalsning.security.JwtService;
@ -17,6 +22,13 @@ import se.bilhalsning.security.JwtService;
@EnableWebSecurity @EnableWebSecurity
public class SecurityConfig { 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 @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
@ -46,8 +58,21 @@ public class SecurityConfig {
.requestMatchers("/api/vehicles/**").permitAll() .requestMatchers("/api/vehicles/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()) .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); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); 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)));
}
} }

View file

@ -18,7 +18,7 @@ public class JwtService {
this(secret, DEFAULT_EXPIRATION_MS); 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.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
this.expirationMs = expirationMs; this.expirationMs = expirationMs;
} }

View file

@ -42,16 +42,18 @@ class AdminControllerTest {
private AdminOrderWorkflowService adminOrderWorkflowService; private AdminOrderWorkflowService adminOrderWorkflowService;
@Test @Test
void shouldReturn403WhenNotAuthenticated() throws Exception { void shouldReturn401WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/admin/orders")) mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isForbidden()); .andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test
@WithMockUser(username = "test@bilhej.se", roles = "USER") @WithMockUser(username = "test@bilhej.se", roles = "USER")
void shouldReturn403ForNonAdminUser() throws Exception { void shouldReturn403ForNonAdminUser() throws Exception {
mockMvc.perform(get("/api/admin/orders")) mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden())
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test

View file

@ -225,7 +225,8 @@ class AuthControllerTest {
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content( .content(
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}")) "{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test
@ -263,6 +264,7 @@ class AuthControllerTest {
mockMvc.perform(post("/api/auth/change-email") mockMvc.perform(post("/api/auth/change-email")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}")) .content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
} }
} }

View file

@ -18,16 +18,22 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.security.test.context.support.WithMockUser; import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.dto.OrderResponse; import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.entity.User; import se.bilhalsning.entity.User;
import se.bilhalsning.security.JwtService;
import se.bilhalsning.service.OrderService; import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService; import se.bilhalsning.service.UserService;
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@TestPropertySource(properties = "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!")
class OrderControllerTest { class OrderControllerTest {
private static final String TEST_SECRET =
"this-is-a-test-secret-that-is-at-least-32-bytes-long!!";
@Autowired @Autowired
private MockMvc mockMvc; private MockMvc mockMvc;
@ -38,9 +44,10 @@ class OrderControllerTest {
private UserService userService; private UserService userService;
@Test @Test
void shouldReturn403WhenNotAuthenticated() throws Exception { void shouldReturn401WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/orders")) mockMvc.perform(get("/api/orders"))
.andExpect(status().isForbidden()); .andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test
@ -100,11 +107,31 @@ class OrderControllerTest {
} }
@Test @Test
void shouldReturn403WhenPostingWithoutAuth() throws Exception { void shouldReturn401WhenPostingWithoutAuth() throws Exception {
mockMvc.perform(post("/api/orders") mockMvc.perform(post("/api/orders")
.contentType("application/json") .contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}")) .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 @Test

View file

@ -39,10 +39,11 @@ class PaymentControllerTest {
private UserService userService; private UserService userService;
@Test @Test
void shouldReturn403WhenNotAuthenticated() throws Exception { void shouldReturn401WhenNotAuthenticated() throws Exception {
mockMvc.perform(post("/api/payment/{orderId}/pay", mockMvc.perform(post("/api/payment/{orderId}/pay",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")) "c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
.andExpect(status().isForbidden()); .andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test

View file

@ -70,6 +70,13 @@ test.describe('Auth guards', () => {
}) })
test('allows admin user to access /admin', async ({ page }) => { 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' }) const jwt = makeJwt({ role: 'admin' })
await page.goto('/') await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt) await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)

View 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}`
}

View file

@ -100,6 +100,13 @@ test.describe('Header auth state', () => {
}) })
test('logout redirects to home page', async ({ page }) => { 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' }) const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/orders') await page.goto('/orders')
await page.evaluate( await page.evaluate(

View file

@ -49,6 +49,31 @@ test.describe('Payment redirect', () => {
await page.waitForURL(/\/betalning\//) await page.waitForURL(/\/betalning\//)
await expect(page.getByText('Swisha till')).toBeVisible() await expect(page.getByText('Swisha till')).toBeVisible()
await expect(page.getByRole('button', { name: 'Jag har betalat' })).toBeVisible() await expect(
page.getByRole('button', { name: 'Jag har betalat' }),
).toBeVisible()
})
test('shows QR code for desktop scanning', async ({ page }) => {
await page.goto('/compose?plate=QRA222')
await page.getByLabel('Ditt meddelande').fill('Fin bil!')
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await page.waitForURL(/\/betalning\//)
await expect(page.getByRole('img', { name: 'Swish QR-kod' })).toBeVisible()
await expect(page.getByText('Skanna QR-koden')).toBeVisible()
})
test('shows Swish payment link with pre-filled data', async ({ page }) => {
await page.goto('/compose?plate=MNO345')
await page.getByLabel('Ditt meddelande').fill('Hej där!')
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await page.waitForURL(/\/betalning\//)
const swishLink = page.getByRole('link', { name: 'Betala med Swish' })
await expect(swishLink).toBeVisible()
const href = await swishLink.getAttribute('href')
expect(href).toContain('app.swish.nu')
expect(href).toContain('amt=49.00')
}) })
}) })

View file

@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"pinia": "^3.0.4", "pinia": "^3.0.4",
"qrcode": "^1.5.4",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.6" "vue-router": "^5.0.6"
}, },
@ -16,6 +17,7 @@
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@rushstack/eslint-patch": "^1.16.1", "@rushstack/eslint-patch": "^1.16.1",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.6", "@vitest/coverage-v8": "^4.1.6",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
@ -791,9 +793,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -811,9 +810,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -831,9 +827,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -851,9 +844,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -871,9 +861,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -891,9 +878,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1054,6 +1038,16 @@
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.1", "version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
@ -1962,6 +1956,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chai": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@ -1987,11 +1990,91 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@ -2004,7 +2087,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/commander": { "node_modules/commander": {
@ -2136,6 +2218,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": { "node_modules/decimal.js": {
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@ -2160,6 +2251,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/eastasianwidth": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -2718,6 +2815,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/glob": { "node_modules/glob": {
"version": "10.5.0", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@ -2863,7 +2969,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -3285,9 +3390,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3309,9 +3411,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3333,9 +3432,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3357,9 +3453,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3743,6 +3836,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@ -3787,7 +3889,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -3936,6 +4037,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.13", "version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
@ -4034,6 +4144,23 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/quansync": { "node_modules/quansync": {
"version": "0.2.11", "version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@ -4084,6 +4211,15 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -4094,6 +4230,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/reusify": { "node_modules/reusify": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@ -4208,6 +4350,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -5094,6 +5242,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@ -5236,6 +5390,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.3", "version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
@ -5251,6 +5411,134 @@
"url": "https://github.com/sponsors/eemeli" "url": "https://github.com/sponsors/eemeli"
} }
}, },
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View file

@ -17,6 +17,7 @@
}, },
"dependencies": { "dependencies": {
"pinia": "^3.0.4", "pinia": "^3.0.4",
"qrcode": "^1.5.4",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.6" "vue-router": "^5.0.6"
}, },
@ -24,6 +25,7 @@
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@rushstack/eslint-patch": "^1.16.1", "@rushstack/eslint-patch": "^1.16.1",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.6", "@vitest/coverage-v8": "^4.1.6",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",

View file

@ -4,6 +4,26 @@ import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import OrdersPage from '@/pages/OrdersPage.vue' 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) { function mockFetchResponse(status: number, body: unknown) {
return Promise.resolve({ return Promise.resolve({
ok: status >= 200 && status < 300, ok: status >= 200 && status < 300,
@ -376,3 +396,34 @@ describe('OrdersPage', () => {
expect(wrapper.find('.orders__preview-toggle').exists()).toBe(false) 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' },
})
})
})

View file

@ -5,14 +5,26 @@ import { createRouter, createMemoryHistory } from 'vue-router'
import PaymentRedirect from '@/pages/PaymentRedirect.vue' import PaymentRedirect from '@/pages/PaymentRedirect.vue'
import OrdersPage from '@/pages/OrdersPage.vue' import OrdersPage from '@/pages/OrdersPage.vue'
vi.mock('qrcode', () => ({
default: {
toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,mock-qr'),
},
}))
vi.mock('@/api/payment', () => ({ vi.mock('@/api/payment', () => ({
payOrder: vi.fn(), payOrder: vi.fn(),
fetchSwishInfo: vi.fn(), fetchSwishInfo: vi.fn(),
buildSwishPaymentUrl: vi.fn(
(number: string, amount: number, message: string) =>
`https://app.swish.nu/1/p/sw/?sw=${number}&amt=${amount.toFixed(2)}&msg=${message}`,
),
})) }))
import { payOrder, fetchSwishInfo } from '@/api/payment' import { payOrder, fetchSwishInfo } from '@/api/payment'
import QRCode from 'qrcode'
const mockPayOrder = vi.mocked(payOrder) const mockPayOrder = vi.mocked(payOrder)
const mockFetchSwishInfo = vi.mocked(fetchSwishInfo) const mockFetchSwishInfo = vi.mocked(fetchSwishInfo)
const mockToDataURL = vi.mocked(QRCode.toDataURL)
function createTestRouter() { function createTestRouter() {
return createRouter({ return createRouter({
@ -59,6 +71,7 @@ describe('PaymentRedirect', () => {
number: '0701234567', number: '0701234567',
amount: 49, amount: 49,
}) })
mockToDataURL.mockResolvedValue('data:image/png;base64,mock-qr')
}) })
it('renders heading and amount', async () => { it('renders heading and amount', async () => {
@ -81,7 +94,7 @@ describe('PaymentRedirect', () => {
expect(wrapper.text()).toContain('Beställnings-ID') expect(wrapper.text()).toContain('Beställnings-ID')
expect(wrapper.text()).toContain(orderId) expect(wrapper.text()).toContain(orderId)
expect(wrapper.text()).toContain( expect(wrapper.text()).toContain(
'Ange beställnings-ID ovan som meddelande i Swish-appen', 'fylls i automatiskt via QR-kod eller länk',
) )
}) })
}) })
@ -93,13 +106,30 @@ describe('PaymentRedirect', () => {
}) })
}) })
it('renders QR code after fetching swish info', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__qr-img').exists()).toBe(true)
})
expect(mockToDataURL).toHaveBeenCalledTimes(1)
})
it('renders a Swish payment link', async () => {
const { wrapper } = await mountPage('test-order', 'ABC123')
await vi.waitFor(() => {
const link = wrapper.find('.payment__swish-link')
expect(link.exists()).toBe(true)
expect(link.attributes('href')).toContain('app.swish.nu')
})
})
it('shows confirmation dialog after clicking pay button', async () => { it('shows confirmation dialog after clicking pay button', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true) expect(wrapper.find('.payment__submit').exists()).toBe(true)
}) })
await wrapper.find('.btn--primary').trigger('click') await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat') expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat')
expect(wrapper.text()).toContain('0701234567') expect(wrapper.text()).toContain('0701234567')
@ -110,15 +140,15 @@ describe('PaymentRedirect', () => {
it('can cancel confirmation dialog', async () => { it('can cancel confirmation dialog', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true) expect(wrapper.find('.payment__submit').exists()).toBe(true)
}) })
await wrapper.find('.btn--primary').trigger('click') await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Avbryt') expect(wrapper.text()).toContain('Avbryt')
}) })
await wrapper.find('.btn--ghost').trigger('click') await wrapper.find('.payment__confirm-cancel').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Swisha till') expect(wrapper.text()).toContain('Swisha till')
expect(wrapper.text()).not.toContain('Avbryt') expect(wrapper.text()).not.toContain('Avbryt')
@ -137,16 +167,15 @@ describe('PaymentRedirect', () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true) expect(wrapper.find('.payment__submit').exists()).toBe(true)
}) })
await wrapper.find('.btn--primary').trigger('click') await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat') expect(wrapper.text()).toContain('Ja, jag har betalat')
}) })
const confirmButtons = wrapper.findAll('.btn--primary') await wrapper.find('.payment__confirm .btn--primary').trigger('click')
await confirmButtons[confirmButtons.length - 1].trigger('click')
expect(mockPayOrder).toHaveBeenCalledWith('order-1') expect(mockPayOrder).toHaveBeenCalledWith('order-1')
}) })
@ -156,16 +185,15 @@ describe('PaymentRedirect', () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true) expect(wrapper.find('.payment__submit').exists()).toBe(true)
}) })
await wrapper.find('.btn--primary').trigger('click') await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat') expect(wrapper.text()).toContain('Ja, jag har betalat')
}) })
const confirmButtons = wrapper.findAll('.btn--primary') await wrapper.find('.payment__confirm .btn--primary').trigger('click')
await confirmButtons[confirmButtons.length - 1].trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte bekräfta betalningen') expect(wrapper.text()).toContain('Kunde inte bekräfta betalningen')
@ -184,16 +212,15 @@ describe('PaymentRedirect', () => {
const { wrapper, router } = await mountPage() const { wrapper, router } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true) expect(wrapper.find('.payment__submit').exists()).toBe(true)
}) })
await wrapper.find('.btn--primary').trigger('click') await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat') expect(wrapper.text()).toContain('Ja, jag har betalat')
}) })
const confirmButtons = wrapper.findAll('.btn--primary') await wrapper.find('.payment__confirm .btn--primary').trigger('click')
await confirmButtons[confirmButtons.length - 1].trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('orders') expect(router.currentRoute.value.name).toBe('orders')

View file

@ -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 { function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload)) const body = btoa(JSON.stringify(payload))

View file

@ -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 { function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload)) const body = btoa(JSON.stringify(payload))

View 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)
})
})

View file

@ -1,3 +1,6 @@
import { useAuthStore } from '@/stores/authStore'
import router from '@/router'
const API_BASE = import.meta.env.VITE_API_URL || '/api' const API_BASE = import.meta.env.VITE_API_URL || '/api'
export class ApiError extends Error { 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 { function getToken(): string | null {
return localStorage.getItem('auth_token') 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>( export async function request<T>(
url: string, url: string,
options: RequestInit = {}, options: RequestInit = {},
@ -34,6 +54,9 @@ export async function request<T>(
}) })
if (!response.ok) { if (!response.ok) {
if (response.status === 401 && !url.startsWith('/auth/')) {
handleExpiredSession()
}
const body = await response.json().catch(() => ({})) const body = await response.json().catch(() => ({}))
throw new ApiError(response.status, body.message || 'Något gick fel') throw new ApiError(response.status, body.message || 'Något gick fel')
} }

View file

@ -15,3 +15,44 @@ export function payOrder(orderId: string): Promise<Order> {
export function fetchSwishInfo(): Promise<SwishInfo> { export function fetchSwishInfo(): Promise<SwishInfo> {
return request<SwishInfo>('/payment/swish-info') return request<SwishInfo>('/payment/swish-info')
} }
/**
* Build a pre-filled Swish payment URL.
*
* On mobile, tapping this URL opens the Swish app with the amount and
* message pre-filled. On desktop, embed it in a QR code for the user
* to scan with their phone.
*
* Uses the Swish "C2B pre-fill" URL scheme documented at
* https://developer.swish.nu — no Swish Commerce API certificate required.
* The `sw` parameter accepts either a phone number or a Swish Business
* number (123). Phone numbers in Swedish national format (leading 0)
* are normalised to international format (46).
*/
export function buildSwishPaymentUrl(
swishNumber: string,
amount: number,
message: string,
): string {
const payee = normalizeSwishNumber(swishNumber)
const params = new URLSearchParams({
sw: payee,
amt: amount.toFixed(2),
msg: message,
})
return `https://app.swish.nu/1/p/sw/?${params.toString()}`
}
/**
* Normalise a Swish number to the format the Swish URL expects.
* - 123 (Swish Business number) unchanged
* - 46 (already international) unchanged
* - 0 (Swedish national format) 46 + rest without leading 0
*/
function normalizeSwishNumber(number: string): string {
const trimmed = number.replace(/\s/g, '')
if (trimmed.startsWith('123')) return trimmed
if (trimmed.startsWith('46')) return trimmed
if (trimmed.startsWith('0')) return '46' + trimmed.slice(1)
return trimmed
}

View file

@ -1,5 +1,5 @@
import { ref, reactive, type Ref } from 'vue' import { ref, reactive, type Ref } from 'vue'
import { ApiError } from '@/api/client' import { ApiError, isSessionExpired } from '@/api/client'
import { import {
updateOrderStatus, updateOrderStatus,
registerShipment, registerShipment,
@ -69,10 +69,12 @@ export function useAdminOrderActions(
replaceOrder(updated) replaceOrder(updated)
} catch (err) { } catch (err) {
order.status = previousStatus order.status = previousStatus
statusError.value = if (!isSessionExpired(err)) {
err instanceof ApiError && err.message statusError.value =
? err.message err instanceof ApiError && err.message
: 'Kunde inte uppdatera status. Försök igen.' ? err.message
: 'Kunde inte uppdatera status. Försök igen.'
}
} }
} }
@ -100,11 +102,13 @@ export function useAdminOrderActions(
) )
replaceOrder(updated) replaceOrder(updated)
trackingInputValues[orderId] = updated.trackingId ?? trackingInput trackingInputValues[orderId] = updated.trackingId ?? trackingInput
} catch { } catch (err) {
order.status = previousStatus order.status = previousStatus
order.trackingId = previousTrackingId order.trackingId = previousTrackingId
trackingError.value = if (!isSessionExpired(err)) {
'Kunde inte registrera utskick. Kontrollera spårnings-ID och försök igen.' trackingError.value =
'Kunde inte registrera utskick. Kontrollera spårnings-ID och försök igen.'
}
} finally { } finally {
registeringId.value = null registeringId.value = null
} }
@ -123,10 +127,12 @@ export function useAdminOrderActions(
const updated = await updateAdminNotes(orderId, notes) const updated = await updateAdminNotes(orderId, notes)
replaceOrder(updated) replaceOrder(updated)
adminNotesValues[orderId] = updated.adminNotes ?? '' adminNotesValues[orderId] = updated.adminNotes ?? ''
} catch { } catch (err) {
order.adminNotes = previousNotes order.adminNotes = previousNotes
adminNotesValues[orderId] = previousNotes ?? '' adminNotesValues[orderId] = previousNotes ?? ''
notesError.value = 'Kunde inte spara anteckningar. Försök igen.' if (!isSessionExpired(err)) {
notesError.value = 'Kunde inte spara anteckningar. Försök igen.'
}
} finally { } finally {
savingNotesId.value = null savingNotesId.value = null
} }

View file

@ -1,5 +1,6 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { fetchAllOrders, type AdminOrder } from '@/api/admin' import { fetchAllOrders, type AdminOrder } from '@/api/admin'
import { isSessionExpired } from '@/api/client'
import { PAID_GROUP_STATUSES } from '@/constants/orderStatus' import { PAID_GROUP_STATUSES } from '@/constants/orderStatus'
export type AdminOrderFilter = export type AdminOrderFilter =
@ -61,8 +62,10 @@ export function useAdminOrders() {
error.value = '' error.value = ''
try { try {
orders.value = await fetchAllOrders() orders.value = await fetchAllOrders()
} catch { } catch (err) {
error.value = 'Kunde inte hämta beställningar. Försök igen senare.' if (!isSessionExpired(err)) {
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
}
} finally { } finally {
loading.value = false loading.value = false
} }

View file

@ -145,7 +145,7 @@ onUnmounted(() => {
</div> </div>
</template> </template>
<style scoped> <style>
.admin { .admin {
max-width: 72rem; max-width: 72rem;
margin: var(--space-2xl) auto 0; margin: var(--space-2xl) auto 0;

View file

@ -2,6 +2,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { createOrder } from '@/api/orders' import { createOrder } from '@/api/orders'
import { isSessionExpired } from '@/api/client'
import { type LetterTemplate } from '@/data/templates' import { type LetterTemplate } from '@/data/templates'
import TemplatePicker from '@/components/TemplatePicker.vue' import TemplatePicker from '@/components/TemplatePicker.vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
@ -41,8 +42,10 @@ async function handleSubmit() {
params: { orderId: order.id }, params: { orderId: order.id },
query: { plate: plate.value }, query: { plate: plate.value },
}) })
} catch { } catch (err) {
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.' if (!isSessionExpired(err)) {
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'
}
} finally { } finally {
submitting.value = false submitting.value = false
} }

View file

@ -2,6 +2,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router' import { useRouter, useRoute, RouterLink } from 'vue-router'
import { fetchOrder, updateOrder, type Order } from '@/api/orders' import { fetchOrder, updateOrder, type Order } from '@/api/orders'
import { isSessionExpired } from '@/api/client'
import { type LetterTemplate } from '@/data/templates' import { type LetterTemplate } from '@/data/templates'
import TemplatePicker from '@/components/TemplatePicker.vue' import TemplatePicker from '@/components/TemplatePicker.vue'
@ -44,8 +45,10 @@ async function loadOrder() {
if (fetched.status === 'pending_payment') { if (fetched.status === 'pending_payment') {
letterText.value = fetched.letterText letterText.value = fetched.letterText
} }
} catch { } catch (err) {
loadError.value = 'Kunde inte hämta beställningen. Försök igen senare.' if (!isSessionExpired(err)) {
loadError.value = 'Kunde inte hämta beställningen. Försök igen senare.'
}
} finally { } finally {
loading.value = false loading.value = false
} }
@ -64,8 +67,10 @@ async function handleSubmit() {
params: { orderId: order.value.id }, params: { orderId: order.value.id },
query: { plate: order.value.plate }, query: { plate: order.value.plate },
}) })
} catch { } catch (err) {
errorMessage.value = 'Kunde inte spara ändringarna. Försök igen senare.' if (!isSessionExpired(err)) {
errorMessage.value = 'Kunde inte spara ändringarna. Försök igen senare.'
}
} finally { } finally {
submitting.value = false submitting.value = false
} }

View file

@ -2,6 +2,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { fetchOrders, cancelOrder, type Order } from '@/api/orders' import { fetchOrders, cancelOrder, type Order } from '@/api/orders'
import { fetchSwishInfo } from '@/api/payment' import { fetchSwishInfo } from '@/api/payment'
import { isSessionExpired } from '@/api/client'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { import {
ORDER_STATUS_BADGE, ORDER_STATUS_BADGE,
@ -65,8 +66,10 @@ async function loadOrders() {
]) ])
orders.value = fetchedOrders orders.value = fetchedOrders
orderAmount.value = swishInfo.amount orderAmount.value = swishInfo.amount
} catch { } catch (err) {
error.value = 'Kunde inte hämta beställningar. Försök igen senare.' if (!isSessionExpired(err)) {
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
}
} finally { } finally {
loading.value = false loading.value = false
} }
@ -87,8 +90,11 @@ async function handleCancel(order: Order) {
try { try {
const updated = await cancelOrder(order.id) const updated = await cancelOrder(order.id)
orders.value = orders.value.map((o) => (o.id === updated.id ? updated : o)) orders.value = orders.value.map((o) => (o.id === updated.id ? updated : o))
} catch { } catch (err) {
actionError.value = 'Kunde inte avbryta beställningen. Försök igen senare.' if (!isSessionExpired(err)) {
actionError.value =
'Kunde inte avbryta beställningen. Försök igen senare.'
}
} finally { } finally {
cancellingId.value = null cancellingId.value = null
} }

View file

@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { payOrder, fetchSwishInfo } from '@/api/payment' import QRCode from 'qrcode'
import { payOrder, fetchSwishInfo, buildSwishPaymentUrl } from '@/api/payment'
import { isSessionExpired } from '@/api/client'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -12,12 +14,27 @@ const swishAmount = ref(49)
const paying = ref(false) const paying = ref(false)
const error = ref('') const error = ref('')
const showConfirmation = ref(false) const showConfirmation = ref(false)
const qrDataUrl = ref('')
const swishPaymentUrl = computed(() =>
swishNumber.value
? buildSwishPaymentUrl(swishNumber.value, swishAmount.value, orderId)
: '',
)
onMounted(async () => { onMounted(async () => {
try { try {
const info = await fetchSwishInfo() const info = await fetchSwishInfo()
swishNumber.value = info.number swishNumber.value = info.number
swishAmount.value = info.amount swishAmount.value = info.amount
if (swishPaymentUrl.value) {
qrDataUrl.value = await QRCode.toDataURL(swishPaymentUrl.value, {
width: 224,
margin: 2,
color: { dark: '#111827', light: '#ffffff' },
})
}
} catch { } catch {
error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.' error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.'
} }
@ -38,8 +55,10 @@ async function confirmPayment() {
try { try {
await payOrder(orderId) await payOrder(orderId)
await router.push({ name: 'orders' }) await router.push({ name: 'orders' })
} catch { } catch (err) {
error.value = 'Kunde inte bekräfta betalningen. Försök igen.' if (!isSessionExpired(err)) {
error.value = 'Kunde inte bekräfta betalningen. Försök igen.'
}
} finally { } finally {
paying.value = false paying.value = false
} }
@ -75,21 +94,37 @@ async function confirmPayment() {
</div> </div>
<template v-if="!showConfirmation"> <template v-if="!showConfirmation">
<!-- QR code scan with the Swish app (desktop users) -->
<div v-if="qrDataUrl" class="payment__qr">
<img :src="qrDataUrl" alt="Swish QR-kod" class="payment__qr-img" />
<p class="payment__qr-hint">
Skanna QR-koden med Swish-appen för att betala
</p>
</div>
<!-- Direct link opens the Swish app (mobile users) -->
<a
v-if="swishPaymentUrl"
:href="swishPaymentUrl"
class="btn btn--primary btn--lg payment__swish-link"
>
Betala med Swish
</a>
<!-- Manual fallback -->
<div class="payment__swish"> <div class="payment__swish">
<p class="payment__swish-label">Swisha till</p> <p class="payment__swish-label">Swisha till</p>
<p class="payment__swish-number">{{ swishNumber }}</p> <p class="payment__swish-number">{{ swishNumber }}</p>
<p class="payment__swish-instruction"> <p class="payment__swish-instruction">
Ange beställnings-ID ovan som meddelande i Swish-appen. Belopp och beställnings-ID fylls i automatiskt via QR-kod eller
länk.
</p> </p>
<p class="payment__swish-instruction"> <p class="payment__swish-instruction">
Tryck sedan knappen nedan för att bekräfta. Betala manuellt om du inte har Swish-appen tillgänglig.
</p> </p>
</div> </div>
<button <button class="btn btn--ghost payment__submit" @click="startPayment">
class="btn btn--primary btn--lg payment__submit"
@click="startPayment"
>
Jag har betalat Jag har betalat
</button> </button>
</template> </template>
@ -198,6 +233,31 @@ async function confirmPayment() {
color: var(--color-ink); color: var(--color-ink);
} }
.payment__qr {
text-align: center;
margin-bottom: var(--space-lg);
}
.payment__qr-img {
width: 224px;
height: 224px;
border-radius: var(--radius-md);
margin: 0 auto var(--space-sm);
}
.payment__qr-hint {
font-size: 0.8125rem;
color: var(--color-muted);
}
.payment__swish-link {
display: block;
width: 100%;
text-align: center;
text-decoration: none;
margin-bottom: var(--space-lg);
}
.payment__swish { .payment__swish {
background: var(--color-border-light); background: var(--color-border-light);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);

View file

@ -148,9 +148,11 @@ router.beforeEach((to) => {
if (!getActivePinia()) return if (!getActivePinia()) return
const auth = useAuthStore() const auth = useAuthStore()
const authenticated = auth.isAuthenticated && !auth.isTokenExpired()
if (to.meta.guestOnly && auth.isAuthenticated) return { name: 'home' } if (to.meta.guestOnly && authenticated) return { name: 'home' }
if (to.meta.requiresAuth && !auth.isAuthenticated) { if (to.meta.requiresAuth && !authenticated) {
if (auth.isAuthenticated) auth.logout()
return { name: 'login', query: { redirect: to.fullPath } } return { name: 'login', query: { redirect: to.fullPath } }
} }
if (to.meta.requiresAdmin && !auth.isAdmin) return { name: 'home' } if (to.meta.requiresAdmin && !auth.isAdmin) return { name: 'home' }

View file

@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { register, login, changeEmail, confirmEmailChange } from '@/api/auth' 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', () => { export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('auth_token')) const token = ref<string | null>(localStorage.getItem('auth_token'))
@ -21,6 +21,10 @@ export const useAuthStore = defineStore('auth', () => {
return payload.role ?? null return payload.role ?? null
} }
function isTokenExpired(): boolean {
return isJwtExpired(token.value)
}
function setToken(newToken: string) { function setToken(newToken: string) {
token.value = newToken token.value = newToken
role.value = extractRole(newToken) role.value = extractRole(newToken)
@ -69,6 +73,7 @@ export const useAuthStore = defineStore('auth', () => {
email, email,
isAuthenticated, isAuthenticated,
isAdmin, isAdmin,
isTokenExpired,
registerUser, registerUser,
loginUser, loginUser,
changeUserEmail, changeUserEmail,

View file

@ -20,3 +20,10 @@ export function parseJwtPayload(token: string): JwtPayload {
return {} 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)
}