Files
RenewCMS/features/api_client_global_error_handling.feature
T

122 lines
7.4 KiB
Gherkin

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=<target>&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=<currentPath>&reason=expired, where <currentPath> 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 <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