fix(e2e): wait for the Service Worker to control the page before API calls
`apiAdminCreateUser` intermittently failed with
`401 {"error":"DPoP nonce required","error_type":"DpopVerificationFailed"}`,
most visibly in admin.spec.ts's pagination test.
Not a nonce-rotation race — the nonce pool keeps a 3-minute overlap
window precisely so in-flight requests survive rotation. It is a
service-worker-control race.
`browserFetch` issues a raw page-context `fetch`, and the DPoP proof is
attached by the Service Worker intercepting it — the helper's own doc
comment says so. The SPA's proof-and-retry logic lives in `client.ts`'s
`dpopFetch`, which this helper deliberately bypasses. So when the SW is
not yet controlling the page, the request goes out unsigned, the
middleware sees a bound session with no proof (`dpop.rs`, the
`expected_jkt` match), and answers with a nonce challenge. Nothing
retries it: the SW that would have signed it is what is missing, and
`dpopFetch` was never in the path.
The window is real on every fresh browser context. `service-worker.ts`
does `skipWaiting()` + `clients.claim()`, which is correct, but claiming
is asynchronous — the first navigation loads uncontrolled, then
install → activate → claim. `waitForLoadState('networkidle')` says
nothing about SW control, so a helper called a few lines after
`apiLogin` can land inside it. Slower CI widens it, which is why this
showed up there and not locally.
The fix waits inside the same `page.evaluate` as the fetch, so it costs
one property check once the page is controlled and needs no per-page
bookkeeping. It is bounded at 10s: if the SW never claims, the request
goes out as before and the resulting 401 stays the clear signal it is
today rather than becoming an unexplained Playwright timeout.
Worth stating because it is easy to get wrong: **`ready` is not
`controlling`.** `navigator.serviceWorker.ready` resolves once a
registration is active, while `controller` stays null until that worker
has claimed THIS page. Awaiting only `ready` looks correct and still
flakes.
This is a test bug, not a product one. Real paths go through `apiFetch`,
which signs in page JS; the SW is the safety net for requests that
bypass it (`<img src>`, downloads). This helper is the only caller
relying on the SW as its primary signer.
Not verified by running the suite: a flake reproduces on CI timing, so
one green local run would prove nothing. The mechanism is confirmed from
the code path — SW-signed proof, no fallback retry, asynchronous claim.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -205,6 +205,31 @@ export async function apiLogin(page: Page, admin = TEST_ADMIN): Promise<void> {
|
||||
* lives on the non-HttpOnly `oxicloud_csrf` cookie). Body is passed
|
||||
* as an already-serialized string so this helper works uniformly
|
||||
* for JSON, form, and multipart-manually-encoded payloads.
|
||||
*
|
||||
* ## Waiting for the Service Worker is load-bearing, not defensive
|
||||
*
|
||||
* Because the proof comes from the SW rather than from this fetch,
|
||||
* a request issued while the page is NOT YET CONTROLLED goes out
|
||||
* unsigned. The server sees a bound session with no proof and
|
||||
* answers `401 DPoP nonce required` — a nonce challenge, from
|
||||
* `nonce_challenge_response` in `middleware/dpop.rs`. Nothing
|
||||
* retries it: the SPA's retry lives in `client.ts`'s `dpopFetch`,
|
||||
* which this helper deliberately bypasses, and the SW that would
|
||||
* have signed it is exactly what is missing.
|
||||
*
|
||||
* That window is real on every fresh browser context. The worker
|
||||
* does `skipWaiting()` + `clients.claim()` (`service-worker.ts`),
|
||||
* which is correct, but claiming is asynchronous: the first
|
||||
* navigation loads uncontrolled, then install → activate → claim.
|
||||
* `waitForLoadState('networkidle')` says nothing about SW control,
|
||||
* so a helper called soon after `apiLogin` — such as
|
||||
* `apiAdminCreateUser` in `admin.spec.ts`'s pagination test — can
|
||||
* land inside it. Intermittently, and more often on slower CI.
|
||||
*
|
||||
* **`ready` is not `controlling`.** `navigator.serviceWorker.ready`
|
||||
* resolves once a registration is *active*; `controller` stays null
|
||||
* until that worker has claimed THIS page. Awaiting only `ready`
|
||||
* looks right and still flakes.
|
||||
*/
|
||||
async function browserFetch(
|
||||
page: Page,
|
||||
@@ -217,6 +242,29 @@ async function browserFetch(
|
||||
): Promise<{ ok: boolean; status: number; body: string }> {
|
||||
return page.evaluate(
|
||||
async ({ url, method, contentType, body }) => {
|
||||
// Bounded wait: if the SW never claims (not registered, disabled,
|
||||
// or a page that never booted the SPA) fall through and let the
|
||||
// request go out as before. The resulting 401 is then the same
|
||||
// clear signal it is today, rather than a Playwright timeout with
|
||||
// no explanation attached.
|
||||
if ('serviceWorker' in navigator && !navigator.serviceWorker.controller) {
|
||||
await Promise.race([
|
||||
(async () => {
|
||||
await navigator.serviceWorker.ready;
|
||||
if (!navigator.serviceWorker.controller) {
|
||||
await new Promise<void>((resolve) =>
|
||||
navigator.serviceWorker.addEventListener(
|
||||
'controllerchange',
|
||||
() => resolve(),
|
||||
{ once: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
})(),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
|
||||
]);
|
||||
}
|
||||
|
||||
const csrf = document.cookie.match(/(?:^|; )oxicloud_csrf=([^;]+)/)?.[1] ?? '';
|
||||
const headers: Record<string, string> = {};
|
||||
if (csrf) headers['x-csrf-token'] = csrf;
|
||||
|
||||
Reference in New Issue
Block a user