From 947d2c6e20d1f0c399d595b194a2c67d33aae79f Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 5 Sep 2026 00:29:37 +0200 Subject: [PATCH] fix(e2e): wait for the Service Worker to control the page before API calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 (``, 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) --- tests/e2e/spa/helpers.ts | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/e2e/spa/helpers.ts b/tests/e2e/spa/helpers.ts index a1e2abd9..a7071b59 100644 --- a/tests/e2e/spa/helpers.ts +++ b/tests/e2e/spa/helpers.ts @@ -205,6 +205,31 @@ export async function apiLogin(page: Page, admin = TEST_ADMIN): Promise { * 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((resolve) => + navigator.serviceWorker.addEventListener( + 'controllerchange', + () => resolve(), + { once: true }, + ), + ); + } + })(), + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); + } + const csrf = document.cookie.match(/(?:^|; )oxicloud_csrf=([^;]+)/)?.[1] ?? ''; const headers: Record = {}; if (csrf) headers['x-csrf-token'] = csrf;