From 12faab7f923872efe46fdfe3aaf6ca2a796225e5 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Fri, 25 Sep 2026 17:33:40 +0200 Subject: [PATCH] feat: typed API client (useApi) and session store (useSession) with the global 401, unverified-403, 429 and offline handling from states.md --- .../api_client_global_error_handling.feature | 121 ++++++++++++ .../api_client_global_error_handling.qa.md | 76 ++++++++ web/src/composables/__tests__/useApi.spec.ts | 178 ++++++++++++++++++ web/src/composables/useApi.ts | 92 ++++++++- web/src/stores/__tests__/session.spec.ts | 20 ++ web/src/stores/session.ts | 7 +- 6 files changed, 488 insertions(+), 6 deletions(-) create mode 100644 features/api_client_global_error_handling.feature create mode 100644 features/api_client_global_error_handling.qa.md create mode 100644 web/src/composables/__tests__/useApi.spec.ts create mode 100644 web/src/stores/__tests__/session.spec.ts diff --git a/features/api_client_global_error_handling.feature b/features/api_client_global_error_handling.feature new file mode 100644 index 0000000..1091e79 --- /dev/null +++ b/features/api_client_global_error_handling.feature @@ -0,0 +1,121 @@ +Feature: Typed API client and session store with global 401, unverified-403, 429 and offline handling + + As the maintainer of RenewCMS + I want useApi to expose one generic authenticated request method that every future screen call + site builds on, that turns a mid-session 401 into a redirect to /login with the expired reason + and clears the session, turns a 403 from a known-unverified session into a redirect to + /register/pending, surfaces the Retry-After seconds from a 429 so screens can show a countdown, + and marks the app offline on a network failure + So that later screen slices (login, articles, editor, ...) get consistent global session and + connectivity handling instead of each reimplementing 401/403/429/offline handling ad hoc + + Background: + Given useApi exports a generic request(path, options) method, used by getMe and by every future + endpoint call, that always sends credentials: 'include' and parses a JSON response body on + success + And a non-2xx response makes request(...) throw an ApiError carrying the numeric status; a 429 + response also carries retryAfter, the integer number of seconds parsed from the response's + Retry-After header, or undefined if the header is absent or not a plain integer + And a fetch that rejects instead of resolving (no connectivity, DNS failure, the request never + reaches a server) makes request(...) throw a NetworkError instead of an ApiError, so callers + can tell "the server answered with an error" apart from "the request never got an answer" with + instanceof + And useApi exports a shared reactive isOffline ref, module-scoped so every caller of useApi() and + every component observes the same value: a NetworkError sets it true; it is also true whenever + navigator.onLine is false; a request that completes (2xx or a non-network ApiError) while + navigator.onLine is true sets it back to false + And getMe() calls request('/v1/auth/me', { onUnauthorized: 'passthrough' }): a 401 from this one + call still throws a plain ApiError with status 401, exactly as before, and does not trigger the + redirect below, because this call only ever runs as part of session.check() during router + navigation (features/nuxt_ui_shell_foundation.feature), and the router's own guard already + redirects to /login?redirect=&reason=expired for a "no session" boot result + And every other call to request(...) uses the default onUnauthorized: 'redirect': on a 401, + before the ApiError reaches the caller, useApi calls the session store's clear() action (session + = null, status stays 'ready') and navigates the router to + /login?redirect=&reason=expired, where is + router.currentRoute.value.fullPath at the moment the 401 is received + And on a 403, useApi reads the session store: if it holds a session whose verified is false, this + is the global "unverified account" case from docs/design/routes.md and states.md, so useApi + navigates the router to /register/pending; the ApiError still reaches the caller as a plain 403, + and the session store is left untouched, since it already correctly records verified: false; any + other 403 (no session, or a session whose verified is true) is not the global case: useApi does + nothing extra and the ApiError propagates to the caller unchanged, for the screen to interpret + (wrong-credentials at login, already-verified at cancel-registration, an expired verify or reset + link, and registration-closed are all screen-level, out of scope here) + And the session store gains a clear() action (session = null, status = 'ready', independent of + check()/retry()) used by the global 401 handling above + + Scenario: A successful authenticated request returns the parsed body + Given a mocked fetch that resolves 200 with JSON body {"id": 5} + When useApi().request('/v1/article/5') is called + Then the returned value is {"id": 5} + And isOffline is false + + Scenario: A 401 received mid-session redirects to login with the expired reason and clears the session + Given the session store holds session {"verified": true} and status "ready" + And the current route is /article/12/edit + And a mocked fetch that resolves 401 + When useApi().request('/v1/article/12') is called + Then the call rejects with an ApiError whose status is 401 + And the session store's session becomes null and its status stays "ready" + And the router navigates to "/login?redirect=/article/12/edit&reason=expired" + + Scenario: The boot call to getMe does not trigger the global 401 redirect + Given the session store holds session null and status "checking" + And a mocked fetch that resolves 401 + When useApi().getMe() is called directly, outside of router navigation + Then the call rejects with an ApiError whose status is 401 + And the router does not navigate anywhere + + Scenario: A 403 while the session is known to be unverified redirects to the pending page + Given the session store holds session {"verified": false} and status "ready" + And a mocked fetch that resolves 403 + When useApi().request('/v1/article/') is called + Then the call rejects with an ApiError whose status is 403 + And the router navigates to "/register/pending" + And the session store's session is still {"verified": false} + + Scenario Outline: A 403 that is not the global unverified case passes through untouched + Given the session store holds session and status "ready" + And a mocked fetch that resolves 403 + When useApi().request('/v1/some/endpoint') is called + Then the call rejects with an ApiError whose status is 403 + And the router does not navigate anywhere + And the session store is unchanged + + Examples: + | session | + | null | + | {"verified": true} | + + Scenario: A 429 on a rate-limited endpoint exposes the Retry-After seconds + Given a mocked fetch that resolves 429 with a Retry-After header of "30" + When useApi().request('/v1/auth/login', { method: 'POST' }) is called + Then the call rejects with an ApiError whose status is 429 and whose retryAfter is 30 + + Scenario: A 429 with no Retry-After header exposes an undefined retryAfter + Given a mocked fetch that resolves 429 with no Retry-After header + When useApi().request('/v1/auth/password-reset', { method: 'POST' }) is called + Then the call rejects with an ApiError whose status is 429 and whose retryAfter is undefined + + Scenario: A network failure throws a NetworkError and marks the app offline + Given navigator.onLine is true + And a mocked fetch that rejects with a TypeError, as a real fetch does when the network is + unreachable + When useApi().request('/v1/article/') is called + Then the call rejects with a NetworkError, not an ApiError + And isOffline becomes true + + Scenario: A later successful request clears the offline flag + Given isOffline is true from a previous network failure + And navigator.onLine is true + And a mocked fetch that resolves 200 with JSON body {} + When useApi().request('/v1/article/') is called + Then isOffline becomes false + + Scenario: The browser's own online/offline events also drive the shared flag + Given isOffline is false + When the browser fires a "offline" event on window + Then isOffline becomes true + When the browser fires a "online" event on window + Then isOffline becomes false diff --git a/features/api_client_global_error_handling.qa.md b/features/api_client_global_error_handling.qa.md new file mode 100644 index 0000000..3c5c3de --- /dev/null +++ b/features/api_client_global_error_handling.qa.md @@ -0,0 +1,76 @@ +# QA: Typed API client and session store with global 401, unverified-403, 429 and offline handling + +This is a composable/store-only slice with no new screen and no new `/v1` handler, so there is no +Go `httptest` procedure. Verification is entirely Vitest (`task test:web`), unit-testing +`web/src/composables/useApi.ts` and `web/src/stores/session.ts` directly, plus a small check that +the existing router guard suite still passes unmodified. + +## Automated (Vitest + Vue Test Utils, `task test:web`) + +Mock `global.fetch` per case (`vi.fn()`), install a Pinia instance (`createPinia()` / +`setActivePinia`) before each test, and import the real `router` singleton from `@/router` so +`request(...)`'s navigation calls are observable on `router.currentRoute`. No real backend, no +httptest server, no live SMTP. + +1. **Successful request returns parsed JSON and leaves isOffline false** — mock fetch resolving + `{ ok: true, status: 200, json: async () => ({ id: 5 }) }`; call + `useApi().request('/v1/article/5')`; assert it resolves to `{ id: 5 }`, assert `fetch` was called + with `credentials: 'include'`, and assert the exported `isOffline` ref is `false`. +2. **401 mid-session redirects and clears the session** — seed the session store with + `session = { verified: true }`, `status = 'ready'`; navigate the router to `/article/12/edit` + first so `router.currentRoute.value.fullPath` matches; mock fetch resolving status 401; call + `useApi().request('/v1/article/12')`; assert the promise rejects with an `ApiError` whose + `status === 401`; assert `session.session === null` and `session.status === 'ready'`; assert + `router.currentRoute.value.fullPath` is + `/login?redirect=%2Farticle%2F12%2Fedit&reason=expired` (or the equivalent decoded query object). +3. **getMe's own 401 does not trigger the global redirect** — seed + `session = null, status = 'checking'`; mock fetch resolving status 401; record + `router.currentRoute.value.fullPath` before the call; call `useApi().getMe()`; assert it rejects + with `ApiError` status 401; assert the router's current route is unchanged from before the call. +4. **403 with a known-unverified session redirects to /register/pending** — seed + `session = { verified: false }, status = 'ready'`; mock fetch resolving status 403; call + `useApi().request('/v1/article/')`; assert the promise rejects with `ApiError` status 403; assert + `router.currentRoute.value.path === '/register/pending'`; assert + `session.session` is still `{ verified: false }`. +5. **403 with no session, or a verified session, passes through untouched** — two cases, + `session = null` and `session = { verified: true }`, both `status = 'ready'`; mock fetch resolving + status 403; navigate the router to a known path first; call `useApi().request(...)`; assert the + promise rejects with `ApiError` status 403; assert the router's current route did not change; + assert the session store's state object is unchanged (`toEqual` the pre-call snapshot). +6. **429 exposes retryAfter parsed from the Retry-After header** — mock fetch resolving + `{ ok: false, status: 429, headers: new Headers({ 'Retry-After': '30' }) }`; call + `useApi().request('/v1/auth/login', { method: 'POST' })`; assert the rejection is an `ApiError` + with `status === 429` and `retryAfter === 30`. +7. **429 with no Retry-After header exposes retryAfter as undefined** — same as above with no header + set; assert `retryAfter === undefined`. +8. **Network failure throws NetworkError, not ApiError, and sets isOffline true** — set + `Object.defineProperty(navigator, 'onLine', { value: true, configurable: true })`; mock fetch to + `mockRejectedValue(new TypeError('Failed to fetch'))`; call `useApi().request('/v1/article/')`; + assert the rejection is instanceof `NetworkError` and `not instanceof ApiError`; assert `isOffline` + is `true` after the rejection settles. +9. **A later successful request clears isOffline** — starting from `isOffline === true` (reuse the + state from test 8, or set it directly if the module exposes a test seam), keep + `navigator.onLine === true`, mock fetch resolving 200; call `useApi().request('/v1/article/')`; + assert `isOffline` becomes `false` after the call resolves. +10. **window online/offline events drive isOffline directly** — dispatch + `window.dispatchEvent(new Event('offline'))`; assert `isOffline === true`; dispatch + `window.dispatchEvent(new Event('online'))`; assert `isOffline === false`. If `useApi`'s offline + tracking is implemented as a composable that needs to be "activated" (e.g. listeners attached on + first `useApi()` call or app mount), call `useApi()` once before dispatching the events. +11. **Regression: existing getMe/session/router-guard suites stay green** — run + `web/src/router/__tests__/routerGuard.spec.ts` (and any existing `useApi`/`session` spec files) + unmodified alongside the new tests in the same `task test:web` run; assert no existing test's + assertions changed and all still pass. This is not a new scenario to author, just a check that the + new request()/isOffline/clear() additions don't regress boot, session caching or the + public/session/verified/any redirect matrix already covered by + `features/nuxt_ui_shell_foundation.feature`. + +## Manual verification + +- **Real network offline test** — disable the machine's network adapter (or use browser devtools' + "Offline" throttling) against a `bun dev` instance, trigger a real authenticated call, and confirm + `isOffline` flips and a consuming banner-style UI would show, since Vitest only simulates a + rejected `fetch`, never a real dropped connection. +- **429 countdown UI** — this slice only proves `useApi` surfaces `retryAfter`; the actual countdown + display (`useRetryAfter`, per `docs/design/components.md`) belongs to the login and + password-reset screen slices and is out of scope here. diff --git a/web/src/composables/__tests__/useApi.spec.ts b/web/src/composables/__tests__/useApi.spec.ts new file mode 100644 index 0000000..898f7e3 --- /dev/null +++ b/web/src/composables/__tests__/useApi.spec.ts @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import router from '@/router' +import { useSessionStore } from '@/stores/session' +import { ApiError, NetworkError, isOffline, useApi } from '@/composables/useApi' + +function mockResponse(status: number, body: unknown = null, headers: Record = {}) { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: (name: string) => headers[name] ?? null }, + json: async () => body, + } as unknown as Response +} + +function setOnline(value: boolean) { + Object.defineProperty(navigator, 'onLine', { configurable: true, value }) +} + +async function expectApiError(call: Promise, matcher: Record) { + await expect(call).rejects.toBeInstanceOf(ApiError) + await expect(call).rejects.toMatchObject(matcher) +} + +async function settle() { + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 20)) +} + +const fetchMock = vi.fn() + +beforeEach(() => { + setActivePinia(createPinia()) + fetchMock.mockReset() + global.fetch = fetchMock as unknown as typeof fetch + setOnline(true) + isOffline.value = false +}) + +afterEach(async () => { + await router.push('/login') +}) + +describe('useApi global 401/403/429/offline handling', () => { + it('A successful authenticated request returns the parsed body', async () => { + fetchMock.mockResolvedValue(mockResponse(200, { id: 5 })) + + const result = await useApi().request('/v1/article/5') + + expect(result).toEqual({ id: 5 }) + expect(isOffline.value).toBe(false) + expect(fetchMock).toHaveBeenCalledWith( + '/v1/article/5', + expect.objectContaining({ credentials: 'include' }), + ) + }) + + it('A 401 received mid-session redirects to login with the expired reason and clears the session', async () => { + const session = useSessionStore() + session.session = { verified: true } + session.status = 'ready' + await router.push('/article/12/edit') + fetchMock.mockResolvedValue(mockResponse(401)) + + const call = useApi().request('/v1/article/12') + + await expectApiError(call, { status: 401 }) + + expect(session.session).toBeNull() + expect(session.status).toBe('ready') + await vi.waitFor(() => + expect(router.currentRoute.value.fullPath).toBe( + '/login?redirect=/article/12/edit&reason=expired', + ), + ) + }) + + it('The boot call to getMe does not trigger the global 401 redirect', async () => { + const session = useSessionStore() + session.session = null + session.status = 'checking' + await router.push('/login') + const pathBefore = router.currentRoute.value.fullPath + fetchMock.mockResolvedValue(mockResponse(401)) + + const call = useApi().getMe() + + await expectApiError(call, { status: 401 }) + await settle() + + expect(router.currentRoute.value.fullPath).toBe(pathBefore) + }) + + it('A 403 while the session is known to be unverified redirects to the pending page', async () => { + const session = useSessionStore() + session.session = { verified: false } + session.status = 'ready' + fetchMock.mockResolvedValue(mockResponse(403)) + + const call = useApi().request('/v1/article/') + + await expectApiError(call, { status: 403 }) + + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending')) + expect(session.session).toEqual({ verified: false }) + }) + + it.each([ + { name: 'null', session: null }, + { name: 'verified true', session: { verified: true } }, + ])( + 'A 403 that is not the global unverified case passes through untouched ($name)', + async ({ session: sessionValue }) => { + const session = useSessionStore() + session.session = sessionValue + session.status = 'ready' + await router.push('/login') + const pathBefore = router.currentRoute.value.fullPath + fetchMock.mockResolvedValue(mockResponse(403)) + + const call = useApi().request('/v1/some/endpoint') + + await expectApiError(call, { status: 403 }) + await settle() + + expect(router.currentRoute.value.fullPath).toBe(pathBefore) + expect(session.session).toEqual(sessionValue) + }, + ) + + it('A 429 on a rate-limited endpoint exposes the Retry-After seconds', async () => { + fetchMock.mockResolvedValue(mockResponse(429, null, { 'Retry-After': '30' })) + + const call = useApi().request('/v1/auth/login', { method: 'POST' }) + + await expectApiError(call, { status: 429, retryAfter: 30 }) + }) + + it('A 429 with no Retry-After header exposes an undefined retryAfter', async () => { + fetchMock.mockResolvedValue(mockResponse(429)) + + const call = useApi().request('/v1/auth/password-reset', { method: 'POST' }) + + await expectApiError(call, { status: 429, retryAfter: undefined }) + }) + + it('A network failure throws a NetworkError and marks the app offline', async () => { + setOnline(true) + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')) + + const call = useApi().request('/v1/article/') + + await expect(call).rejects.toBeInstanceOf(NetworkError) + await expect(call).rejects.not.toBeInstanceOf(ApiError) + expect(isOffline.value).toBe(true) + }) + + it('A later successful request clears the offline flag', async () => { + isOffline.value = true + setOnline(true) + fetchMock.mockResolvedValue(mockResponse(200, {})) + + await useApi().request('/v1/article/') + + expect(isOffline.value).toBe(false) + }) + + it("The browser's own online/offline events also drive the shared flag", () => { + isOffline.value = false + + window.dispatchEvent(new Event('offline')) + expect(isOffline.value).toBe(true) + + window.dispatchEvent(new Event('online')) + expect(isOffline.value).toBe(false) + }) +}) diff --git a/web/src/composables/useApi.ts b/web/src/composables/useApi.ts index 3a3b50a..4e54af1 100644 --- a/web/src/composables/useApi.ts +++ b/web/src/composables/useApi.ts @@ -1,9 +1,21 @@ +import { ref } from 'vue' +import router from '@/router' +import { useSessionStore } from '@/stores/session' + export class ApiError extends Error { status: number + retryAfter?: number - constructor(status: number) { + constructor(status: number, retryAfter?: number) { super(`Request failed with status ${status}`) this.status = status + this.retryAfter = retryAfter + } +} + +export class NetworkError extends Error { + constructor() { + super('Network request failed') } } @@ -11,15 +23,85 @@ export interface MeResult { verified: boolean } +export type OnUnauthorized = 'redirect' | 'passthrough' + +export interface RequestOptions extends RequestInit { + onUnauthorized?: OnUnauthorized +} + +export const isOffline = ref(false) + +function isNavigatorOnLine() { + return typeof navigator === 'undefined' || navigator.onLine !== false +} + +function syncOfflineFlagWithNavigator() { + isOffline.value = !isNavigatorOnLine() +} + +function parseRetryAfterSeconds(response: Response): number | undefined { + const header = response.headers.get('Retry-After') + if (header === null || !/^\d+$/.test(header)) { + return undefined + } + return Number.parseInt(header, 10) +} + +if (typeof window !== 'undefined') { + window.addEventListener('offline', () => { + isOffline.value = true + }) + window.addEventListener('online', () => { + isOffline.value = false + }) +} + export function useApi() { - async function getMe(): Promise { - const response = await fetch('/v1/auth/me', { credentials: 'include' }) + async function request(path: string, options: RequestOptions = {}): Promise { + const { onUnauthorized = 'redirect', ...init } = options + + let response: Response + try { + response = await fetch(path, { credentials: 'include', ...init }) + } catch { + isOffline.value = true + throw new NetworkError() + } + + syncOfflineFlagWithNavigator() + if (!response.ok) { + if (response.status === 401 && onUnauthorized === 'redirect') { + const currentPath = router.currentRoute.value.fullPath + useSessionStore().clear() + router.push({ path: '/login', query: { redirect: currentPath, reason: 'expired' } }) + throw new ApiError(401) + } + + if (response.status === 403) { + const session = useSessionStore() + if (session.session?.verified === false) { + router.push('/register/pending') + } + throw new ApiError(403) + } + + if (response.status === 429) { + throw new ApiError(429, parseRetryAfterSeconds(response)) + } + throw new ApiError(response.status) } - const body = await response.json() + + return response.json() + } + + async function getMe(): Promise { + const body = await request<{ is_verified: boolean }>('/v1/auth/me', { + onUnauthorized: 'passthrough', + }) return { verified: Boolean(body.is_verified) } } - return { getMe } + return { getMe, request } } diff --git a/web/src/stores/__tests__/session.spec.ts b/web/src/stores/__tests__/session.spec.ts new file mode 100644 index 0000000..e68325b --- /dev/null +++ b/web/src/stores/__tests__/session.spec.ts @@ -0,0 +1,20 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useSessionStore } from '@/stores/session' + +beforeEach(() => { + setActivePinia(createPinia()) +}) + +describe('session store clear action', () => { + it('resets the session to null and the status to ready regardless of the previous state', () => { + const session = useSessionStore() + session.session = { verified: true } + session.status = 'checking' + + session.clear() + + expect(session.session).toBeNull() + expect(session.status).toBe('ready') + }) +}) diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts index 061ceb2..9074e92 100644 --- a/web/src/stores/session.ts +++ b/web/src/stores/session.ts @@ -46,5 +46,10 @@ export const useSessionStore = defineStore('session', () => { return check() } - return { status, session, check, retry } + function clear() { + session.value = null + status.value = 'ready' + } + + return { status, session, check, retry, clear } })