diff --git a/web/e2e/csp-smoke.spec.ts b/web/e2e/csp-smoke.spec.ts index ca524633..40686a48 100644 --- a/web/e2e/csp-smoke.spec.ts +++ b/web/e2e/csp-smoke.spec.ts @@ -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 { + 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() + } }) diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 4f4b1d04..85babc04 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -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: [ { diff --git a/web/src/app/AppShell.vue b/web/src/app/AppShell.vue index 95bacf6a..7b0e42f6 100644 --- a/web/src/app/AppShell.vue +++ b/web/src/app/AppShell.vue @@ -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 { diff --git a/web/src/features/auth/auth-session.test.ts b/web/src/features/auth/auth-session.test.ts index a7e17ff9..d98d528b 100644 --- a/web/src/features/auth/auth-session.test.ts +++ b/web/src/features/auth/auth-session.test.ts @@ -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() + 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) diff --git a/web/src/features/auth/auth-session.ts b/web/src/features/auth/auth-session.ts index 7fdf9a67..df1f34ec 100644 --- a/web/src/features/auth/auth-session.ts +++ b/web/src/features/auth/auth-session.ts @@ -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 { + 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)