fix(web): 修复会话隔离与响应式壳

This commit is contained in:
tbphp committed 2026-07-25 13:12:30 +08:00
1 parent 5104e6669f
commit f2225221fb
5 files changed
+120 -8

No files matched your search

+80 -2
View File
@@ -1,6 +1,31 @@
import { readdir, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { expect, test } from '@playwright/test'
test('Reka overlays work under production CSP', async ({ page }) => {
const credentialCanary = 'csp-auth-key'
async function artifactFiles(directory: string): Promise<string[]> {
try {
const entries = await readdir(directory, { withFileTypes: true })
const nested = await Promise.all(
entries.map((entry) => {
const path = join(directory, entry.name)
return entry.isDirectory() ? artifactFiles(path) : [path]
}),
)
return nested.flat()
} catch {
return []
}
}
test('Reka overlays work under production CSP', async ({ page }, testInfo) => {
expect(testInfo.project.use).toMatchObject({
trace: 'off',
screenshot: 'off',
video: 'off',
})
const errors: string[] = []
page.on('console', (message) => message.type() === 'error' && errors.push(message.text()))
page.on('pageerror', (error) => errors.push(error.message))
@@ -15,7 +40,7 @@ test('Reka overlays work under production CSP', async ({ page }) => {
})
})
await page.goto('/login')
await page.getByLabel('AUTH_KEY', { exact: true }).fill('csp-auth-key')
await page.getByLabel('AUTH_KEY', { exact: true }).fill(credentialCanary)
await page.getByRole('button', { name: 'Sign in' }).click()
await page.setViewportSize({ width: 375, height: 812 })
const navigationTrigger = page.getByRole('button', {
@@ -33,4 +58,57 @@ test('Reka overlays work under production CSP', async ({ page }) => {
)
expect(cspViolations).toEqual([])
expect(errors).toEqual([])
const recordings = testInfo.attachments.filter(({ name }) => /trace|screenshot|video/i.test(name))
expect(recordings).toEqual([])
for (const path of await artifactFiles(testInfo.outputDir)) {
expect((await readFile(path)).includes(Buffer.from(credentialCanary))).toBe(false)
}
})
test('compact navigation remains safe at 1024px in every locale', async ({ page }) => {
await page.setViewportSize({ width: 1024, height: 768 })
await page.goto('/login')
await page.getByLabel('AUTH_KEY', { exact: true }).fill(credentialCanary)
await page.locator('form button[type="submit"]').click()
await expect(page).toHaveURL('/')
const locales = [
{ locale: 'zh-CN', open: '打开导航', close: '关闭导航' },
{ locale: 'en-US', open: 'Open navigation', close: 'Close navigation' },
{ locale: 'ja-JP', open: 'ナビゲーションを開く', close: 'ナビゲーションを閉じる' },
] as const
for (const { locale, open, close } of locales) {
await page.evaluate((nextLocale) => localStorage.setItem('gpt-load.locale', nextLocale), locale)
await page.reload()
const navigationTrigger = page.getByRole('button', { name: open })
await expect(navigationTrigger).toBeVisible()
const layout = await page.evaluate(() => ({
viewportWidth: window.innerWidth,
documentWidth: document.documentElement.scrollWidth,
}))
expect(layout.documentWidth).toBeLessThanOrEqual(layout.viewportWidth)
const undersizedControls = await page
.locator('.app-topbar a, .app-topbar button')
.evaluateAll((controls) =>
controls
.map((control) => {
const bounds = control.getBoundingClientRect()
return {
label: control.getAttribute('aria-label') ?? control.textContent,
...bounds.toJSON(),
}
})
.filter(({ width, height }) => width > 0 && height > 0)
.filter(({ width, height }) => width < 44 || height < 44),
)
expect(undersizedControls).toEqual([])
await navigationTrigger.click()
await expect(page.getByRole('button', { name: close })).toBeVisible()
await page.getByRole('button', { name: close }).click()
}
})
+3 -1
View File
@@ -9,7 +9,9 @@ export default defineConfig({
reporter: 'list',
use: {
baseURL: `http://127.0.0.1:${port}`,
trace: 'retain-on-failure',
trace: 'off',
screenshot: 'off',
video: 'off',
},
projects: [
{
+1 -1
View File
@@ -287,7 +287,7 @@ watch(
border-top: 1px solid var(--color-border);
padding-top: var(--space-5);
}
@media (max-width: 1023px) {
@media (max-width: 1199px) {
.desktop-nav,
.import-action,
.logout-action {
@@ -165,6 +165,37 @@ describe('createAuthSession', () => {
).toBeUndefined()
})
it('waits for old query cancellation so an old 401 cannot clear the candidate', async () => {
window.sessionStorage.setItem(authStorageKey, 'old-key')
const dependencies = createDependencies()
const cancellation = deferred<void>()
vi.spyOn(dependencies.queryClient, 'cancelQueries')
.mockImplementationOnce(() => cancellation.promise)
.mockResolvedValue(undefined)
const session = createAuthSession(dependencies)
const login = session.login('candidate-key')
await vi.waitFor(() => {
expect(dependencies.queryClient.cancelQueries).toHaveBeenCalledTimes(1)
})
expect(session.getAuthKey()).toBe('old-key')
expect(session.state.phase).toBe('unvalidated')
expect(dependencies.queryClient.getQueryData(authSessionQueryKey)).toBeUndefined()
const handleOldUnauthorized = () => session.clear()
handleOldUnauthorized()
expect(session.getAuthKey()).toBe('')
cancellation.resolve()
await login
expect(session.getAuthKey()).toBe('candidate-key')
expect(session.state.phase).toBe('validated')
expect(dependencies.queryClient.getQueryData(authSessionQueryKey)).toEqual({
authenticated: true,
})
})
it('clears prior authenticated state before activating a validated candidate', async () => {
const dependencies = createDependencies()
const session = createAuthSession(dependencies)
+5 -4
View File
@@ -66,11 +66,12 @@ function removeStoredCredential(storage?: Storage): void {
}
}
export function clearAuthenticatedClientState(queryClient: QueryClient): void {
void queryClient.cancelQueries({ queryKey: controlQueryKeys.all })
export function clearAuthenticatedClientState(queryClient: QueryClient): Promise<void> {
const cancellation = queryClient.cancelQueries({ queryKey: controlQueryKeys.all })
queryClient.removeQueries({ queryKey: controlQueryKeys.all })
queryClient.removeQueries({ queryKey: authSessionQueryKey })
queryClient.getMutationCache().clear()
return cancellation
}
export function createAuthSession(deps: AuthSessionDependencies): AuthSession {
@@ -88,7 +89,7 @@ export function createAuthSession(deps: AuthSessionDependencies): AuthSession {
removeStoredCredential(deps.storage)
state.phase = 'anonymous'
state.retryAfterSeconds = 0
clearAuthenticatedClientState(deps.queryClient)
void clearAuthenticatedClientState(deps.queryClient)
}
function applyValidationError(error: unknown): void {
@@ -178,7 +179,7 @@ export function createAuthSession(deps: AuthSessionDependencies): AuthSession {
throw new InvalidResponseError()
}
clearAuthenticatedClientState(deps.queryClient)
await clearAuthenticatedClientState(deps.queryClient)
credentialRevision += 1
credential = candidate
writeStoredCredential(deps.storage, candidate)