test(e2e): isolate fixture sessions and client quotas

Signed-off-by: JeonJunYeong <wnsdud9476@gmail.com>
This commit is contained in:
JeonJunYeong 2026-10-26 15:40:27 +09:00
parent d1ea293b1f
commit 82a8adc199
7 changed files with 116 additions and 15 deletions

View file

@ -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}`

View file

@ -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<BrowserContext, string>()
/** 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,
})
}

View file

@ -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<string | null> {
const cookie = (await page.context().cookies()).find((item) => item.name === csrfCookieName)
async function readCsrfToken(page: Page, requestUrl: string): Promise<string | null> {
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<string, string>): Promise<Record<string, string>> {
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')

View file

@ -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<number, string>()
@ -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,
})

View file

@ -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 }) => {

View file

@ -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 })

View file

@ -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<string>()
// 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()
}
}
})
})