Merge pull request #711 from EdouardVanbelle/fix/front-end2end-test-race

This commit is contained in:
Dionisio Pozo
2026-09-07 21:37:35 +02:00
committed by GitHub
10 changed files with 378 additions and 10 deletions
+20
View File
@@ -103,6 +103,26 @@ OXICLOUD_RATE_LIMIT_LOGIN_WINDOW_SECS=3600
OXICLOUD_RATE_LIMIT_REGISTER_MAX=36000
OXICLOUD_RATE_LIMIT_REGISTER_WINDOW_SECS=3600
# The three above are keyed on the client IP. These two are keyed on the
# CALLER ID, which is why raising the three did nothing: the whole suite
# runs as a single `admin`, so every test shares one bucket.
#
# A 763-line e2e server log showed 81 × 429 — all on `http::api`, never
# `http::api::auth` — against the 60/min default for user-profile
# lookups. Admin views resolve an owner name per row, and the run
# creates 34 users, so a minute of tests clears 60 easily. Nothing
# failed, because the SPA degrades to an unresolved name, which is
# precisely why it went unnoticed: the noise would hide a real
# rate-limit regression.
#
# Same posture as above — a 1-hour window with a budget far beyond what
# one run consumes, rather than a raised per-minute rate that would
# still burst-trip.
OXICLOUD_RATE_LIMIT_USER_PROFILE_MAX=36000
OXICLOUD_RATE_LIMIT_USER_PROFILE_WINDOW_SECS=3600
OXICLOUD_RATE_LIMIT_DELTA_UPLOAD_MAX=36000
OXICLOUD_RATE_LIMIT_DELTA_UPLOAD_WINDOW_SECS=3600
# Magic-link / external-users flow (PR 9). The mock SMTP captures every
# outbound message in-process so external_users.hurl can retrieve the
# invitation body and follow the magic-link URL. The `SMTP_FROM` value
+48
View File
@@ -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;