From 82a8adc199d4fb4b01cbcd17e14e7c6b271ea5a3 Mon Sep 17 00:00:00 2001 From: JeonJunYeong Date: Mon, 26 Oct 2026 15:40:27 +0900 Subject: [PATCH] test(e2e): isolate fixture sessions and client quotas Signed-off-by: JeonJunYeong --- web/e2e/auth-entry.spec.ts | 3 +- web/e2e/helpers/auth-fixtures.ts | 20 ++++++- web/e2e/helpers/csrf.ts | 15 +++--- web/e2e/helpers/session.ts | 7 +-- web/e2e/search-card-interaction.spec.ts | 8 ++- web/e2e/search-page-full.spec.ts | 7 ++- web/e2e/session-isolation.spec.ts | 71 +++++++++++++++++++++++++ 7 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 web/e2e/session-isolation.spec.ts diff --git a/web/e2e/auth-entry.spec.ts b/web/e2e/auth-entry.spec.ts index e54def229..b527a1412 100644 --- a/web/e2e/auth-entry.spec.ts +++ b/web/e2e/auth-entry.spec.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto' import { expect, test } from '@playwright/test' -import { setEnglishLocale } from './helpers/auth-fixtures' +import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures' test.describe('Auth Entry', () => { test.beforeEach(async ({ page }) => { @@ -234,6 +234,7 @@ test.describe('Auth Entry', () => { test('logs in to an explicit local destination on the isolated runtime', async ({ browser, page }) => { test.skip(process.env.SKILLHUB_RUNTIME_E2E !== '1', 'Requires an isolated runtime with local registration enabled') test.setTimeout(90_000) + await setUniqueClientIp(page, 'auth-entry-runtime-registration') const suffix = randomBytes(6).toString('hex') const username = `pr901_${suffix}` diff --git a/web/e2e/helpers/auth-fixtures.ts b/web/e2e/helpers/auth-fixtures.ts index fc89c8a1c..bb1138349 100644 --- a/web/e2e/helpers/auth-fixtures.ts +++ b/web/e2e/helpers/auth-fixtures.ts @@ -1,4 +1,18 @@ -import type { Page } from '@playwright/test' +import { randomBytes } from 'node:crypto' +import type { BrowserContext, Page } from '@playwright/test' + +const clientIpByContext = new WeakMap() + +/** Stable identity for fixture setup, without replacing the context's other headers. */ +export function fixtureClientIp(page: Page): string { + const context = page.context() + let address = clientIpByContext.get(context) + if (!address) { + address = `10.${[...randomBytes(3)].join('.')}` + clientIpByContext.set(context, address) + } + return address +} export async function setEnglishLocale(page: Page) { await page.addInitScript(() => { @@ -10,7 +24,9 @@ export async function setUniqueClientIp(page: Page, seed: string) { const suffix = Date.now() + Math.floor(Math.random() * 1000) const thirdOctet = seed.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0) % 250 const fourthOctet = suffix % 250 + const address = `10.0.${thirdOctet}.${fourthOctet}` + clientIpByContext.set(page.context(), address) await page.context().setExtraHTTPHeaders({ - 'X-Forwarded-For': `10.0.${thirdOctet}.${fourthOctet}`, + 'X-Forwarded-For': address, }) } diff --git a/web/e2e/helpers/csrf.ts b/web/e2e/helpers/csrf.ts index 410b08375..a91b0eac5 100644 --- a/web/e2e/helpers/csrf.ts +++ b/web/e2e/helpers/csrf.ts @@ -4,17 +4,20 @@ const csrfCookieName = 'XSRF-TOKEN' const csrfHeaderName = 'X-XSRF-TOKEN' const requestTimeoutMs = process.env.CI ? 12_000 : 8_000 -async function readCsrfToken(page: Page): Promise { - const cookie = (await page.context().cookies()).find((item) => item.name === csrfCookieName) +async function readCsrfToken(page: Page, requestUrl: string): Promise { + const cookie = (await page.context().cookies(requestUrl)).find((item) => item.name === csrfCookieName) return cookie?.value?.trim() || null } export async function csrfHeaders(page: Page, headers?: Record): Promise> { - let token = await readCsrfToken(page) - if (!token) { - await page.context().request.get('/api/v1/auth/providers', { timeout: requestTimeoutMs }) - token = await readCsrfToken(page) + // Resolve the API context's actual origin, including before page.goto(). Cached + // sessions can contain both localhost and 127.0.0.1 cookies; only use cookies + // that the browser would send to this server, and refresh after session changes. + const response = await page.context().request.get('/api/v1/auth/providers', { timeout: requestTimeoutMs }) + if (!response.ok()) { + throw new Error(`Failed to prime CSRF cookie: HTTP ${response.status()}`) } + const token = await readCsrfToken(page, response.url()) if (!token) { throw new Error('Missing XSRF-TOKEN cookie after auth provider warm-up') diff --git a/web/e2e/helpers/session.ts b/web/e2e/helpers/session.ts index d1616d7a7..21f79cb34 100644 --- a/web/e2e/helpers/session.ts +++ b/web/e2e/helpers/session.ts @@ -1,6 +1,7 @@ import { expect, type Page, type TestInfo } from '@playwright/test' import { randomUUID } from 'node:crypto' import { csrfHeaders } from './csrf' +import { fixtureClientIp } from './auth-fixtures' const password = 'Passw0rd!123' const cachedUserByWorker = new Map() @@ -220,7 +221,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re password, email: `${username}@example.test`, }, - headers: await csrfHeaders(page), + headers: await csrfHeaders(page, { 'X-Forwarded-For': fixtureClientIp(page) }), timeout: requestTimeoutMs, }) @@ -251,7 +252,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re password, email: `${uniqueUsername}@example.test`, }, - headers: await csrfHeaders(page), + headers: await csrfHeaders(page, { 'X-Forwarded-For': fixtureClientIp(page) }), timeout: requestTimeoutMs, }) @@ -311,7 +312,7 @@ async function createFreshSessionOnce(page: Page, testInfo?: TestInfo) { password, email: `${uniqueUsername}@example.test`, }, - headers: await csrfHeaders(page), + headers: await csrfHeaders(page, { 'X-Forwarded-For': fixtureClientIp(page) }), timeout: requestTimeoutMs, }) diff --git a/web/e2e/search-card-interaction.spec.ts b/web/e2e/search-card-interaction.spec.ts index 1deadc585..e27ec5b4d 100644 --- a/web/e2e/search-card-interaction.spec.ts +++ b/web/e2e/search-card-interaction.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from '@playwright/test' -import { setEnglishLocale } from './helpers/auth-fixtures' +import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures' import { getSearchCard, getSearchCards, @@ -94,6 +94,11 @@ let basicSeed: PreparedSearchSeed | undefined test.setTimeout(300_000) +// Independent browser scenarios must not exhaust one anonymous search quota. +test.beforeEach(async ({ page }, testInfo) => { + await setUniqueClientIp(page, testInfo.testId) +}) + test.beforeAll(async ({ browser }, testInfo) => { test.setTimeout(300_000) basicSeed = await prepareSearchSeed(browser, testInfo, { count: 13 }) @@ -331,7 +336,6 @@ test.describe('Search Card Loading State (Real API)', () => { // ─── Responsive Layout ──────────────────────────────────────────────────────── test.describe('Search Card Responsive Layout (Real API)', () => { - test.describe.configure({ retries: 2 }) test.use({ hasTouch: true }) test.beforeEach(async ({ page }) => { diff --git a/web/e2e/search-page-full.spec.ts b/web/e2e/search-page-full.spec.ts index 9f18bb24d..979b7244a 100644 --- a/web/e2e/search-page-full.spec.ts +++ b/web/e2e/search-page-full.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test' -import { setEnglishLocale } from './helpers/auth-fixtures' +import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures' import { DEFAULT_SEARCH_KEYWORD, getSearchCards, @@ -16,6 +16,11 @@ let basicSeed: PreparedSearchSeed | undefined test.setTimeout(300_000) +// Independent browser scenarios must not exhaust one anonymous search quota. +test.beforeEach(async ({ page }, testInfo) => { + await setUniqueClientIp(page, testInfo.testId) +}) + test.beforeAll(async ({ browser }, testInfo) => { test.setTimeout(300_000) basicSeed = await prepareSearchSeed(browser, testInfo, { count: 13 }) diff --git a/web/e2e/session-isolation.spec.ts b/web/e2e/session-isolation.spec.ts new file mode 100644 index 000000000..b71f20479 --- /dev/null +++ b/web/e2e/session-isolation.spec.ts @@ -0,0 +1,71 @@ +import { randomUUID } from 'node:crypto' +import { expect, test } from '@playwright/test' +import { csrfHeaders } from './helpers/csrf' +import { fixtureClientIp } from './helpers/auth-fixtures' +import { createFreshSession } from './helpers/session' + +test.describe('E2E fixture session isolation (Real API)', () => { + test('selects the CSRF cookie for the request origin, not another loopback host', async ({ page, baseURL }) => { + const primary = new URL(baseURL!) + test.skip(!['localhost', '127.0.0.1'].includes(primary.hostname), 'Requires the same local server on both loopback names') + const alternate = new URL(primary) + alternate.hostname = primary.hostname === 'localhost' ? '127.0.0.1' : 'localhost' + const providers = '/api/v1/auth/providers' + expect((await page.request.get(new URL(providers, alternate).href)).ok()).toBeTruthy() + expect((await page.request.get(providers)).ok()).toBeTruthy() + const cookies = await page.context().cookies() + expect(new Set(cookies.filter(c => c.name === 'XSRF-TOKEN').map(c => c.domain)).size).toBe(2) + + const response = await page.request.post('/api/v1/auth/local/login', { + data: { username: `missing_${randomUUID().slice(0, 8)}`, password: 'InvalidPassword!123' }, + headers: await csrfHeaders(page), + }) + // A valid CSRF token reaches authentication and rejects the nonexistent account. + // A foreign-origin token fails earlier with 403. + expect(response.status()).toBe(401) + + const invalidCsrf = await page.request.post('/api/v1/auth/local/login', { + data: { username: 'missing_account', password: 'InvalidPassword!123' }, + headers: { 'X-XSRF-TOKEN': 'deliberately-invalid-csrf' }, + }) + expect(invalidCsrf.status()).toBe(403) + }) + + test('fresh-account fixtures do not share one anonymous registration quota', async ({ browser, baseURL }, testInfo) => { + test.setTimeout(30_000) + const usernames = new Set() + // The server's default anonymous registration quota is five per client in five minutes. + // Seven independent contexts must remain seven independent fixture clients. + for (let index = 0; index < 7; index += 1) { + const context = await browser.newContext({ baseURL }) + try { + const page = await context.newPage() + const account = await createFreshSession(page, testInfo) + usernames.add(account.username) + expect((await page.request.get('/api/v1/auth/me')).status()).toBe(200) + } finally { + await context.close() + } + } + expect(usernames.size).toBe(7) + }) + + test('the server still throttles repeated registration attempts from one client', async ({ browser, baseURL }) => { + let address: string | undefined + for (let index = 0; index < 6; index += 1) { + const context = await browser.newContext({ baseURL }) + try { + const page = await context.newPage() + address ??= fixtureClientIp(page) + const response = await page.request.post('/api/v1/auth/local/register', { + // Invalid input consumes the anonymous quota without creating accounts. + data: { username: 'x', password: 'invalid', email: 'invalid' }, + headers: await csrfHeaders(page, { 'X-Forwarded-For': address }), + }) + expect(response.status()).toBe(index < 5 ? 400 : 429) + } finally { + await context.close() + } + } + }) +})