122 lines
7.4 KiB
Gherkin
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
|