fix(web): 修复会话隔离与响应式壳
This commit is contained in:
5 files changed
+120
-8
No files matched your search
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user