mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-14 09:01:16 +00:00
test(e2e): isolate fixture sessions and client quotas
Signed-off-by: JeonJunYeong <wnsdud9476@gmail.com>
This commit is contained in:
parent
d1ea293b1f
commit
82a8adc199
7 changed files with 116 additions and 15 deletions
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
71
web/e2e/session-isolation.spec.ts
Normal file
71
web/e2e/session-isolation.spec.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue