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%).
329 lines
13 KiB
Markdown
329 lines
13 KiB
Markdown
# AGENTS.md — BilHej / Bilhälsning.se
|
|
|
|
Project-specific instructions for OpenCode. Commit this file.
|
|
|
|
---
|
|
|
|
## Project Identity
|
|
|
|
BilHej is a web platform letting Swedish residents send physical letters to
|
|
vehicle owners by entering a registration number. The sender composes a letter,
|
|
pays 49 SEK, and BilHej prints+mails it via PostNord. The sender never sees
|
|
the recipient's name or address.
|
|
|
|
**Phase 0 (current):** Manual workflow. No Transportstyrelsen or PostNord API
|
|
integration yet. Owner address is obtained manually by a human and entered into
|
|
the admin panel.
|
|
|
|
Tech stack: Vue.js 3 (Vite, Pinia) frontend + Java 21 Spring Boot 4 backend +
|
|
PostgreSQL 16. Deployed via Docker Compose.
|
|
|
|
---
|
|
|
|
## Build, Lint, Test & Run Commands
|
|
|
|
Always run these after making changes to verify nothing is broken.
|
|
|
|
Gradle lives at repo root. All commands below run from the repo root unless noted.
|
|
|
|
### Quick start (everything)
|
|
|
|
```bash
|
|
cp .env.example .env # first time only, then fill in keys
|
|
docker compose up -d # starts postgres, backend, frontend
|
|
./gradlew up # same as above (Gradle wrapper)
|
|
```
|
|
|
|
### All-in-one
|
|
|
|
```bash
|
|
./gradlew check # frontend lint → frontend test → backend test+coverage → E2E (Docker)
|
|
./gradlew coverage # backend + frontend tests with coverage reports
|
|
./gradlew up # docker compose up -d
|
|
./gradlew down # docker compose down
|
|
./gradlew reset # docker compose down -v && docker compose up -d (full DB reset)
|
|
```
|
|
|
|
### Frontend (Vue.js 3 + Vite)
|
|
|
|
```bash
|
|
cd frontend
|
|
npm install # first time only
|
|
npm run dev # dev server on :3000 with HMR
|
|
npm run build # production build
|
|
npm run lint # ESLint
|
|
npm run test # vitest
|
|
npm run test:coverage # vitest with coverage (HTML at frontend/coverage/)
|
|
```
|
|
|
|
### Backend (Spring Boot 4 + Java 21)
|
|
|
|
```bash
|
|
./gradlew :backend:bootRun # dev server on :8080
|
|
./gradlew :backend:test # JUnit 5 + Mockito (backend only)
|
|
```
|
|
|
|
### Stripe webhooks (local testing)
|
|
|
|
```bash
|
|
stripe listen --forward-to localhost:8080/api/webhooks/stripe
|
|
```
|
|
|
|
### Database
|
|
|
|
Flyway migrations run automatically on Spring Boot startup. Migration files
|
|
live in `backend/src/main/resources/db/migration/`. Naming: `V<number>__descriptive_name.sql`.
|
|
|
|
**Before adding a migration:** run `./scripts/next-flyway-version.sh` and use that
|
|
version. Never reuse a version number already on `master`. Never edit a migration
|
|
after it has merged — add a new higher version instead. CI runs
|
|
`scripts/check-flyway-migrations.sh` against `origin/master`.
|
|
|
|
If local dev Postgres fails with Flyway checksum / “migration not resolved locally”
|
|
after switching branches, run `./gradlew reset` (wipes the Docker DB volume).
|
|
|
|
To reset: `docker compose down -v && docker compose up -d`.
|
|
|
|
Flyway schema migrations live in `db/migration/`; dev-only seeds (test users,
|
|
sample orders) are in `db/dev-migration/` and run only without the `prod` profile.
|
|
Production admin is created from `ADMIN_EMAIL` / `ADMIN_PASSWORD` on first boot.
|
|
|
|
---
|
|
|
|
## Project Structure
|
|
|
|
```
|
|
bilhej/
|
|
├── frontend/ # Vue.js 3 SPA (Vite)
|
|
│ ├── src/
|
|
│ │ ├── pages/ # Route-level page components
|
|
│ │ ├── components/ # Reusable UI components
|
|
│ │ ├── composables/ # useXxx.ts shared logic
|
|
│ │ ├── stores/ # Pinia stores
|
|
│ │ ├── api/ # API client modules
|
|
│ │ ├── router/ # Vue Router config
|
|
│ │ └── assets/ # Static files, CSS
|
|
│ └── ...
|
|
├── backend/ # Spring Boot 4 (Java 21) — Gradle subproject
|
|
│ ├── build.gradle # Spring Boot plugin, Java deps, test config
|
|
│ ├── src/main/java/se/bilhalsning/
|
|
│ │ ├── config/ # @Configuration classes
|
|
│ │ ├── controller/ # REST controllers
|
|
│ │ ├── dto/ # Request/response DTOs
|
|
│ │ ├── entity/ # JPA entities
|
|
│ │ ├── repository/ # Spring Data JPA repos
|
|
│ │ ├── service/ # Business logic
|
|
│ │ ├── security/ # JWT filter, UserDetailsService
|
|
│ │ ├── exception/ # Custom exceptions + @ControllerAdvice
|
|
│ │ └── mapper/ # Entity ↔ DTO mapping
|
|
│ └── src/main/resources/
|
|
│ ├── application.yml # default (H2, IDE dev)
|
|
│ ├── application-docker.yml # docker profile (PostgreSQL)
|
|
│ └── db/migration/ # Flyway migrations
|
|
├── docker/ # Dockerfiles
|
|
│ ├── backend.Dockerfile # dev: JDK 21 + gradle :backend:bootRun
|
|
│ ├── backend.prod.Dockerfile # prod: multi-stage (Gradle build → JRE Alpine, non-root)
|
|
│ ├── frontend.Dockerfile # dev: Node 24 + vite dev server
|
|
│ ├── frontend.prod.Dockerfile # prod: multi-stage (Node build → nginx)
|
|
│ ├── nginx.conf # prod: SPA fallback + /api reverse proxy
|
|
│ └── entrypoint.sh # prod: self-signed cert generation
|
|
├── docker-compose.yml # dev: postgres + backend (bootRun) + frontend (Vite HMR)
|
|
├── docker-compose.prod.yml # prod: multi-stage images, no source mounts, restart always
|
|
├── gradlew # Gradle wrapper (repo root)
|
|
├── gradle/
|
|
│ └── wrapper/
|
|
├── settings.gradle # rootProject.name + include 'backend'
|
|
├── build.gradle # convenience tasks: check, up, down, reset
|
|
├── .env.example
|
|
├── AGENTS.md # This file
|
|
├── README.md
|
|
├── REQUIREMENTS.md
|
|
└── CODING_GUIDELINES.md
|
|
```
|
|
|
|
---
|
|
|
|
## Conventions (Summary)
|
|
|
|
Full details in `@CODING_GUIDELINES.md`. Key rules:
|
|
|
|
### Both sides
|
|
- Code and comments in English. User-facing strings in Swedish.
|
|
- No commented-out code. Delete it.
|
|
- Functions stay small (<30 lines).
|
|
|
|
### Git
|
|
- Create `feature/*`, `fix/*`, or `chore/*` branches from `develop`.
|
|
- Never commit directly to `master` or `develop`.
|
|
- Merge strategy: fast-forward or merge — either is fine.
|
|
- Commit messages must be thorough: describe what changed, why, and
|
|
list concrete changes as bullet points. Never write single-line
|
|
"feat: add X" messages.
|
|
|
|
**Before every commit (mandatory — agents must not skip):**
|
|
|
|
```bash
|
|
# from repo root; needs Docker running
|
|
export POSTGRES_DB=bilhej POSTGRES_USER=bilhej POSTGRES_PASSWORD=test_pw_ci_123
|
|
export JWT_SECRET=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
|
export STRIPE_SECRET_KEY=sk_test_fake STRIPE_WEBHOOK_SECRET=whsec_fake STRIPE_PRICE_ID=price_fake
|
|
./gradlew check
|
|
```
|
|
|
|
This runs frontend lint, frontend unit tests, backend tests, coverage
|
|
thresholds, Flyway checks, and **all E2E tests in Docker**. **Do not commit or
|
|
push if this fails.** Optional local guard: `./scripts/install-pre-commit-hook.sh`
|
|
(runs the same `check` on every `git commit`).
|
|
|
|
**Note for agents:** The pre-commit hook runs the full `./gradlew check` which
|
|
takes ~3.5 minutes. If your tool enforces a default timeout (e.g. 120 s on
|
|
agent tool calls), increase it to ≥300 000 ms, or use `--no-verify` and run
|
|
`./gradlew check` manually before committing.
|
|
|
|
### Frontend (Vue.js 3)
|
|
- `<script setup>` with Composition API only. Never Options API.
|
|
- File naming: PascalCase for pages/components, camelCase (`useXxx`) for composables.
|
|
- API calls live in `api/` modules, never in components.
|
|
- Component styles are scoped.
|
|
|
|
### Backend (Spring Boot 4)
|
|
- Constructor injection with `@RequiredArgsConstructor`. No `@Autowired`.
|
|
- DTOs: prefer Java records. No bare entities in responses.
|
|
- Controllers stay thin. All logic in services.
|
|
- Use `@ControllerAdvice` for consistent error responses (`{ "message": "..." }`).
|
|
- Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Setter`, `@NoArgsConstructor` are all fine. Prefer records for DTOs.
|
|
|
|
### Database
|
|
- Table names: snake_case, plural. PKs: UUID, generated in code.
|
|
- Timestamps: `created_at`, `updated_at`. Use `Instant` in Java.
|
|
- Enums: stored as VARCHAR.
|
|
- Index every FK and every column used in WHERE.
|
|
|
|
---
|
|
|
|
## Critical Gotchas
|
|
|
|
### Phase 0: Address lookup is MANUAL
|
|
There is no Transportstyrelsen API integration yet. When an order is paid, a
|
|
human must manually request the owner address via the "Fråga om fordonsägare"
|
|
form and update the admin panel. Do NOT write code that calls an API that
|
|
doesn't exist yet. The backend should store the order and wait for an admin
|
|
to mark it as processed.
|
|
|
|
### Never store recipient addresses
|
|
After the address is used to mail the letter, it must be deleted. The Order
|
|
entity must NOT have an address field. The address lookup and mailing are
|
|
external/human processes in Phase 0.
|
|
|
|
### E2E must use Docker (not host Playwright)
|
|
See **Testing Approach → E2E (Playwright) — Docker only** above. Do not run `npx playwright install` or `npm run test:e2e` on the host when verifying E2E.
|
|
|
|
### Local email (Mailpit)
|
|
`docker compose up` includes Mailpit (`ghcr.io/axllent/mailpit:v1.28`); password-reset mail appears at http://localhost:8025. E2E verifies SMTP via Mailpit API (`frontend/e2e/helpers/mailpit.ts`). Production uses Resend SMTP—see docs/production-email-checklist.md.
|
|
|
|
### Password reset test token (never in production)
|
|
`app.password-reset.expose-token` must stay **false** in prod/default; it is only enabled in `application-docker.yml` for CI E2E so Playwright can read `testToken` from the forgot-password response.
|
|
|
|
### Stripe webhook signature verification
|
|
Always verify `stripe-signature` header using `STRIPE_WEBHOOK_SECRET`.
|
|
Webhook endpoint is public (no auth). Without signature verification an
|
|
attacker could mark orders as paid.
|
|
|
|
### Swedish UI strings
|
|
All text visible to end users must be in Swedish. Button labels, error
|
|
messages, validation text, email content, template bodies. Only developer
|
|
facing content is in English.
|
|
|
|
### JWT in Authorization header
|
|
Backend expects `Authorization: Bearer <token>`. Frontend interceptor must
|
|
attach this to all API calls. Unauthorized APIs (register, login, webhook,
|
|
public vehicle info) must be excluded from the Spring Security filter chain.
|
|
|
|
---
|
|
|
|
## Testing Approach
|
|
|
|
This project follows **Test-Driven Development (TDD)**. Write tests before
|
|
or alongside implementation. Every feature ticket should include tests in
|
|
the same PR — never merge code without corresponding tests.
|
|
|
|
### Backend
|
|
- JUnit 5 + Mockito for service layer tests.
|
|
- `@WebMvcTest` for controller tests.
|
|
- Test naming: `shouldXxxWhenYyy`.
|
|
- Use test profile with H2 or Testcontainers for DB-dependent tests.
|
|
- Flyway migrations must run in test profile too.
|
|
|
|
### Frontend
|
|
- Vitest for composables and utility functions.
|
|
- Component tests with Vue Test Utils where needed.
|
|
- E2E tests with Playwright in `frontend/e2e/`.
|
|
|
|
### E2E (Playwright) — **Docker only**
|
|
|
|
**Agents and humans: never run Playwright on the host.**
|
|
|
|
| Do **not** run | Why |
|
|
|----------------|-----|
|
|
| `npx playwright test` | Wrong environment; needs Docker stack |
|
|
| `npm run test:e2e` | Same — host Playwright, not supported for agents |
|
|
| `npx playwright install` | Do not install browsers on the host; the Playwright image already includes them |
|
|
|
|
**Always use Docker** (`docker-compose.e2e.yml`): isolated postgres (tmpfs), backend, frontend, Mailpit, and the official Playwright container on the `e2e` network (`PLAYWRIGHT_BASE_URL=http://frontend`).
|
|
|
|
**Full E2E suite** (same as Forgejo CI / `./gradlew check`):
|
|
|
|
```bash
|
|
# from repo root — set env (or use .env; see .env.example)
|
|
cd frontend && npm run test:e2e:ci
|
|
# equivalent:
|
|
./gradlew frontendE2E
|
|
```
|
|
|
|
**Single spec or project** (stack must be reachable on the `e2e` network):
|
|
|
|
```bash
|
|
# from repo root, after exporting the same vars as frontendE2E / .env
|
|
docker compose -f docker-compose.e2e.yml up -d --build postgres mailpit backend frontend
|
|
docker compose -f docker-compose.e2e.yml run --rm --build playwright \
|
|
sh -c 'npx playwright test admin-fulfillment.spec.ts --project=chromium-serial --reporter=list'
|
|
docker compose -f docker-compose.e2e.yml down
|
|
```
|
|
|
|
- Config: `frontend/playwright.config.ts`
|
|
- Tests: `frontend/e2e/*.spec.ts`
|
|
- Serial specs (shared Mailpit / seeded DB): `admin-fulfillment`, `deferred-payment-admin`, `admin-dashboard`, `account-settings`, `password-reset` — project `chromium-serial` runs **after** parallel `chromium`, `workers: 1`
|
|
|
|
### CI (future)
|
|
- `./gradlew check` and `npm run test && npm run lint` must pass before merge.
|
|
|
|
---
|
|
|
|
## Coverage
|
|
|
|
```bash
|
|
./gradlew coverage # backend + frontend tests with coverage
|
|
```
|
|
|
|
Coverage thresholds are enforced during `./gradlew check`. PRs must maintain
|
|
or improve coverage.
|
|
|
|
| Layer | Lines | Branches | Functions |
|
|
|----------|-------|----------|-----------|
|
|
| Backend | 70% | 60% | — |
|
|
| Frontend | 70% | 60% | 70% |
|
|
|
|
HTML reports:
|
|
- Backend: `backend/build/reports/jacoco/index.html`
|
|
- Frontend: `frontend/coverage/index.html`
|
|
|
|
---
|
|
|
|
## External References
|
|
|
|
For detailed conventions, load `@CODING_GUIDELINES.md`.
|
|
For product requirements and business logic, load `@REQUIREMENTS.md`.
|
|
For setup and quick start, load `@README.md`.
|
|
|
|
These are lazy-loaded by OpenCode — only read them when the task at hand
|
|
needs the detail.
|