From 8df50b65eb60e2b03900f089dc14c92ba659f10f Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Fri, 10 Jul 2026 13:50:15 +0200 Subject: [PATCH] test: add browser auth e2e coverage --- .gitignore | 3 + webui/.eslintignore | 3 + webui/.prettierignore | 3 + webui/e2e/fixtures/api.ts | 156 ++++++++++++++ webui/e2e/fixtures/auth.ts | 154 ++++++++++++++ webui/e2e/fixtures/data.ts | 41 ++++ webui/e2e/fixtures/env.ts | 73 +++++++ webui/e2e/fixtures/network.ts | 156 ++++++++++++++ webui/e2e/setup/bootstrap-chromium.setup.ts | 11 + webui/e2e/setup/bootstrap-firefox.setup.ts | 11 + webui/e2e/setup/bootstrap-webkit.setup.ts | 7 + webui/e2e/specs/bootstrap-auth.spec.ts | 75 +++++++ webui/e2e/specs/production-smoke.spec.ts | 24 +++ webui/e2e/specs/session-access.spec.ts | 137 +++++++++++++ webui/e2e/specs/token-secret.spec.ts | 190 ++++++++++++++++++ webui/package.json | 6 + webui/playwright.config.ts | 97 +++++++++ webui/pnpm-lock.yaml | 61 +++++- .../basic/__tests__/ab-bottom-sheet.test.ts | 38 +++- .../src/components/basic/ab-bottom-sheet.vue | 34 +++- webui/src/components/basic/ab-modal.vue | 2 + .../setting/__tests__/config-access.test.ts | 9 + .../src/components/setting/config-access.vue | 18 +- .../src/components/setup/wizard-container.vue | 14 +- .../components/setup/wizard-step-account.vue | 6 + .../setup/wizard-step-downloader.vue | 11 +- .../setup/wizard-step-notification.vue | 4 + .../src/components/setup/wizard-step-rss.vue | 8 +- webui/tsconfig.e2e.json | 10 + webui/unocss.config.ts | 4 +- 30 files changed, 1349 insertions(+), 17 deletions(-) create mode 100644 webui/e2e/fixtures/api.ts create mode 100644 webui/e2e/fixtures/auth.ts create mode 100644 webui/e2e/fixtures/data.ts create mode 100644 webui/e2e/fixtures/env.ts create mode 100644 webui/e2e/fixtures/network.ts create mode 100644 webui/e2e/setup/bootstrap-chromium.setup.ts create mode 100644 webui/e2e/setup/bootstrap-firefox.setup.ts create mode 100644 webui/e2e/setup/bootstrap-webkit.setup.ts create mode 100644 webui/e2e/specs/bootstrap-auth.spec.ts create mode 100644 webui/e2e/specs/production-smoke.spec.ts create mode 100644 webui/e2e/specs/session-access.spec.ts create mode 100644 webui/e2e/specs/token-secret.spec.ts create mode 100644 webui/playwright.config.ts create mode 100644 webui/tsconfig.e2e.json diff --git a/.gitignore b/.gitignore index 633c7f39..27198e3f 100644 --- a/.gitignore +++ b/.gitignore @@ -191,6 +191,9 @@ lerna-debug.log* node_modules dist webui/.vite/deps/* +webui/e2e/.auth/ +webui/playwright-report/ +webui/test-results/ dist.zip dist-ssr *.local diff --git a/webui/.eslintignore b/webui/.eslintignore index 88bef34a..3ff7c675 100644 --- a/webui/.eslintignore +++ b/webui/.eslintignore @@ -1,3 +1,6 @@ /build /dist /dev-dist +/e2e/.auth +/playwright-report +/test-results diff --git a/webui/.prettierignore b/webui/.prettierignore index 7163ffe0..e7f1a232 100644 --- a/webui/.prettierignore +++ b/webui/.prettierignore @@ -1,6 +1,9 @@ /build /dist /dev-dist +/e2e/.auth +/playwright-report +/test-results /pnpm-lock.yaml auto-imports.d.ts components.d.ts diff --git a/webui/e2e/fixtures/api.ts b/webui/e2e/fixtures/api.ts new file mode 100644 index 00000000..9c6112d5 --- /dev/null +++ b/webui/e2e/fixtures/api.ts @@ -0,0 +1,156 @@ +import type { APIRequestContext, APIResponse } from '@playwright/test'; +import type { + ApiTokenCreated, + ApiTokenPublic, + Credentials, + SetupStatus, + TokenScope, + UserPublic, +} from './data'; +import { SESSION_SUCCESS } from './data'; + +async function requireStatus( + response: APIResponse, + expected: number +): Promise { + if (response.status() !== expected) { + throw new Error( + `${response.url()} returned ${response.status()} ` + + `${response.statusText()}, expected ${expected}` + ); + } + return response; +} + +export class E2EApi { + constructor(readonly request: APIRequestContext) {} + + async setupStatus(): Promise { + const response = await requireStatus( + await this.request.get('/api/v1/setup/status'), + 200 + ); + return response.json(); + } + + async attemptLogin(credentials: Credentials): Promise { + return this.request.post('/api/v1/auth/login', { + form: { + username: credentials.username, + password: credentials.password, + }, + }); + } + + async login(credentials: Credentials): Promise { + const response = await requireStatus( + await this.attemptLogin(credentials), + 200 + ); + const payload = await response.json(); + if (JSON.stringify(payload) !== JSON.stringify(SESSION_SUCCESS)) + throw new Error('Login response did not match the session-only contract'); + } + + async me(expectedStatus = 200): Promise { + const response = await requireStatus( + await this.request.get('/api/v1/auth/me'), + expectedStatus + ); + return expectedStatus === 200 ? response.json() : null; + } + + async logout(): Promise { + await requireStatus(await this.request.post('/api/v1/auth/logout'), 200); + } + + async updateCurrent(credentials: Credentials): Promise { + const response = await requireStatus( + await this.request.post('/api/v1/auth/update', { + data: credentials, + }), + 200 + ); + const payload = await response.json(); + if (JSON.stringify(payload) !== JSON.stringify(SESSION_SUCCESS)) + throw new Error('Credential update exposed an unexpected response shape'); + } + + async listUsers(): Promise { + const response = await requireStatus( + await this.request.get('/api/v1/users'), + 200 + ); + return response.json(); + } + + async createUser(credentials: Credentials): Promise { + const response = await requireStatus( + await this.request.post('/api/v1/users', { + data: { ...credentials, enabled: true }, + }), + 201 + ); + return response.json(); + } + + async deleteUser(userId: number): Promise { + await requireStatus( + await this.request.delete(`/api/v1/users/${userId}`), + 204 + ); + } + + async createToken(name: string, scope: TokenScope): Promise { + const response = await requireStatus( + await this.request.post('/api/v1/tokens', { + data: { name, scope }, + }), + 201 + ); + return response.json(); + } + + async listTokens(): Promise { + const response = await requireStatus( + await this.request.get('/api/v1/tokens'), + 200 + ); + return response.json(); + } + + async revokeToken(tokenId: number): Promise { + await requireStatus( + await this.request.delete(`/api/v1/tokens/${tokenId}`), + 204 + ); + } + + async bearerGet( + path: string, + token: string, + expectedStatus: number + ): Promise { + return requireStatus( + await this.request.get(path, { + headers: { Authorization: `Bearer ${token}` }, + }), + expectedStatus + ); + } + + async disableMcpIpBypass(): Promise { + const getResponse = await requireStatus( + await this.request.get('/api/v1/config/get'), + 200 + ); + const config = (await getResponse.json()) as Record & { + security: Record; + }; + config.security.mcp_whitelist = []; + await requireStatus( + await this.request.patch('/api/v1/config/update', { data: config }), + 200 + ); + } +} diff --git a/webui/e2e/fixtures/auth.ts b/webui/e2e/fixtures/auth.ts new file mode 100644 index 00000000..ba14fbe0 --- /dev/null +++ b/webui/e2e/fixtures/auth.ts @@ -0,0 +1,154 @@ +import { mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import type { + Browser, + BrowserContext, + Page, + Response, + TestInfo, +} from '@playwright/test'; +import { expect } from '@playwright/test'; +import { E2EApi } from './api'; +import type { Credentials } from './data'; +import { SESSION_SUCCESS } from './data'; +import type { E2EEnvironment } from './env'; +import { NetworkGuard } from './network'; + +export async function forceEnglish(page: Page): Promise { + await page.addInitScript(() => localStorage.setItem('lang', 'en')); +} + +export async function loginThroughUI( + page: Page, + credentials: Credentials, + expectedStatus = 200 +): Promise { + await page.goto('/#/login'); + await page.getByLabel('Username').fill(credentials.username); + await page.getByLabel('Password').fill(credentials.password); + const responsePromise = page.waitForResponse( + (response) => + response.url().endsWith('/api/v1/auth/login') && + response.request().method() === 'POST' + ); + await page.getByRole('button', { name: 'Login', exact: true }).click(); + const response = await responsePromise; + expect(response.status()).toBe(expectedStatus); + if (expectedStatus === 200) { + expect(await response.json()).toEqual(SESSION_SUCCESS); + await expect(page).toHaveURL(/#\/bangumi$/); + } + return response; +} + +export async function completeFirstRunSetup( + page: Page, + environment: E2EEnvironment +): Promise { + await forceEnglish(page); + await page.goto('/#/setup'); + await expect(page.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '1' + ); + await page.getByRole('button', { name: 'Get Started' }).click(); + + await page.getByLabel('Username').fill(environment.username); + await page.getByLabel('Password', { exact: true }).fill(environment.password); + await page.getByLabel('Confirm Password').fill(environment.password); + await page.getByRole('button', { name: 'Next' }).click(); + + await page.getByLabel('Host').fill(environment.downloaderURL); + await page.getByLabel('Username').fill(environment.downloaderUsername); + await page.getByLabel('Password').fill(environment.downloaderPassword); + await page.getByLabel('Download Path').fill('/downloads/Bangumi'); + await page.getByRole('button', { name: 'Next' }).click(); + + await page.getByRole('button', { name: 'Skip' }).click(); + await page.getByRole('button', { name: 'Skip' }).click(); + + const completeResponse = page.waitForResponse( + (response) => + response.url().endsWith('/api/v1/setup/complete') && + response.request().method() === 'POST' + ); + await page.getByRole('button', { name: 'Complete Setup' }).click(); + const response = await completeResponse; + expect(response.status()).toBe(200); + expect(await response.json()).toMatchObject({ status: true }); + await expect(page).toHaveURL(/#\/login$/); +} + +export async function bootstrapAuthenticatedState( + page: Page, + environment: E2EEnvironment, + storageStatePath: string +): Promise { + const api = new E2EApi(page.context().request); + const setup = await api.setupStatus(); + if (setup.need_setup) await completeFirstRunSetup(page, environment); + await loginThroughUI(page, environment); + + const tokenCookie = (await page.context().cookies()).find( + (cookie) => cookie.name === 'token' + ); + expect(tokenCookie).toMatchObject({ httpOnly: true, sameSite: 'Strict' }); + + await api.disableMcpIpBypass(); + await mkdir(dirname(storageStatePath), { recursive: true }); + await page.context().storageState({ path: storageStatePath }); +} + +export interface AuthenticatedContext { + context: BrowserContext; + page: Page; + guard: NetworkGuard; +} + +export async function closeAuthenticatedContexts( + contexts: AuthenticatedContext[], + testInfo: TestInfo +): Promise { + const guardResults = await Promise.allSettled( + contexts.map(({ guard }) => guard.finish(testInfo)) + ); + const closeResults = await Promise.allSettled( + contexts.map(({ context }) => context.close()) + ); + const failure = [...guardResults, ...closeResults].find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ); + if (failure) throw failure.reason; +} + +export async function newAuthenticatedContext( + browser: Browser, + environment: E2EEnvironment, + credentials: Credentials, + testInfo: TestInfo +): Promise { + const mobile = testInfo.project.name.includes('webkit'); + const context = await browser.newContext({ + baseURL: environment.baseURL, + locale: 'en-US', + serviceWorkers: 'block', + // The Playwright `browser` fixture applies the project's authenticated + // storage state to manually-created contexts unless it is overridden. + // Secondary-user and logout tests need a genuinely empty session. + storageState: { cookies: [], origins: [] }, + viewport: mobile + ? { width: 390, height: 844 } + : { width: 1280, height: 720 }, + }); + try { + const page = await context.newPage(); + await forceEnglish(page); + const guard = new NetworkGuard(page, environment); + await guard.install(); + await loginThroughUI(page, credentials); + return { context, page, guard }; + } catch (error) { + await context.close().catch(() => undefined); + throw error; + } +} diff --git a/webui/e2e/fixtures/data.ts b/webui/e2e/fixtures/data.ts new file mode 100644 index 00000000..4f7d514f --- /dev/null +++ b/webui/e2e/fixtures/data.ts @@ -0,0 +1,41 @@ +export interface Credentials { + username: string; + password: string; +} + +export interface SetupStatus { + need_setup: boolean; + version: string; +} + +export interface UserPublic { + id: number; + username: string; + enabled: boolean; + created_at: string; + updated_at: string; +} + +export type TokenScope = 'api' | 'mcp'; + +export interface ApiTokenPublic { + id: number; + user_id: number; + name: string; + scope: TokenScope; + prefix: string; + created_at: string; + last_used_at: string | null; + expires_at: string | null; + revoked_at: string | null; +} + +export interface ApiTokenCreated extends ApiTokenPublic { + token: string; +} + +export const SESSION_SUCCESS = { authenticated: true } as const; + +export function changedPassword(prefix: string): string { + return `${prefix}-Changed9!`; +} diff --git a/webui/e2e/fixtures/env.ts b/webui/e2e/fixtures/env.ts new file mode 100644 index 00000000..7e551e2f --- /dev/null +++ b/webui/e2e/fixtures/env.ts @@ -0,0 +1,73 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const fixtureDirectory = fileURLToPath(new URL('.', import.meta.url)); + +export interface E2EEnvironment { + baseURL: string; + mockURL: string; + mockInternalURL: string; + fakeQbURL: string; + fakeQbInternalURL: string; + realQbURL: string | null; + realQbInternalURL: string | null; + downloaderURL: string; + downloaderUsername: string; + downloaderPassword: string; + username: string; + password: string; + artifactDirectory: string; + downloadDirectory: string; + profile: 'browser' | 'downloader'; +} + +function required(name: string): string { + const value = process.env[name]?.trim(); + if (!value) + throw new Error(`${name} must be set by the hermetic stack runner`); + return value.replace(/\/$/, ''); +} + +function optional(name: string): string | null { + const value = process.env[name]?.trim(); + return value ? value.replace(/\/$/, '') : null; +} + +export function getE2EEnvironment(): E2EEnvironment { + const profile = process.env.AB_E2E_PROFILE ?? 'browser'; + if (profile !== 'browser' && profile !== 'downloader') + throw new Error(`Unsupported AB_E2E_PROFILE: ${profile}`); + + const fakeQbURL = required('AB_E2E_FAKE_QB_URL'); + const fakeQbInternalURL = required('AB_E2E_FAKE_QB_INTERNAL_URL'); + const realQbURL = optional('AB_E2E_QB_URL'); + const realQbInternalURL = optional('AB_E2E_QB_INTERNAL_URL'); + if (profile === 'downloader' && (!realQbURL || !realQbInternalURL)) + throw new Error( + 'AB_E2E_QB_URL and AB_E2E_QB_INTERNAL_URL are required for downloader profile' + ); + + return { + baseURL: required('AB_E2E_BASE_URL'), + mockURL: required('AB_E2E_MOCK_URL'), + mockInternalURL: required('AB_E2E_MOCK_INTERNAL_URL'), + fakeQbURL, + fakeQbInternalURL, + realQbURL, + realQbInternalURL, + downloaderURL: + profile === 'downloader' ? realQbInternalURL! : fakeQbInternalURL, + downloaderUsername: process.env.AB_E2E_QB_USERNAME?.trim() || 'admin', + downloaderPassword: process.env.AB_E2E_QB_PASSWORD?.trim() || 'adminadmin', + username: required('AB_E2E_USERNAME'), + password: required('AB_E2E_PASSWORD'), + artifactDirectory: resolve(required('AB_E2E_ARTIFACT_DIR')), + downloadDirectory: resolve(required('AB_E2E_DOWNLOAD_DIR')), + profile, + }; +} + +export function authStatePath(browser: string): string { + const profile = process.env.AB_E2E_PROFILE ?? 'browser'; + return resolve(fixtureDirectory, `../.auth/${profile}-${browser}.json`); +} diff --git a/webui/e2e/fixtures/network.ts b/webui/e2e/fixtures/network.ts new file mode 100644 index 00000000..4b996ab1 --- /dev/null +++ b/webui/e2e/fixtures/network.ts @@ -0,0 +1,156 @@ +import type { Page, Response, TestInfo } from '@playwright/test'; +import type { E2EEnvironment } from './env'; + +const MAX_RESPONSE_BODY = 4096; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function redactArtifact( + value: string, + environment: E2EEnvironment +): string { + let redacted = value; + const secrets = [ + environment.password, + environment.downloaderPassword, + process.env.AB_E2E_QB_PASSWORD ?? '', + ].filter(Boolean); + for (const secret of secrets) + redacted = redacted.replace( + new RegExp(escapeRegExp(secret), 'g'), + '[REDACTED]' + ); + + return redacted + .replace(/(Bearer\s+)[^\s"',]+/gi, '$1[REDACTED]') + .replace(/ab_(?:api|mcp|session)_[A-Za-z0-9._~-]+/g, '[REDACTED]') + .replace( + /("?(?:password|token|authorization|cookie)"?\s*[:=]\s*)"?[^\s,"'}]+/gi, + '$1[REDACTED]' + ); +} + +interface FailedResponseArtifact { + status: number; + url: string; + body: string; +} + +export class NetworkGuard { + private readonly allowedOrigins: Set; + private readonly blocked: string[] = []; + private readonly consoleMessages: string[] = []; + private readonly pageErrors: string[] = []; + private readonly failedRequests: string[] = []; + private readonly failedResponses: FailedResponseArtifact[] = []; + private readonly pending = new Set>(); + + constructor( + private readonly page: Page, + private readonly environment: E2EEnvironment + ) { + this.allowedOrigins = new Set( + [environment.baseURL, environment.mockURL].map( + (value) => new URL(value).origin + ) + ); + } + + private isAllowed(rawURL: string): boolean { + const url = new URL(rawURL); + if (url.protocol === 'ws:') url.protocol = 'http:'; + if (url.protocol === 'wss:') url.protocol = 'https:'; + if (url.protocol === 'http:' || url.protocol === 'https:') + return this.allowedOrigins.has(url.origin); + return ['about:', 'blob:', 'data:'].includes(url.protocol); + } + + async install(): Promise { + const context = this.page.context(); + await context.route('**/*', async (route) => { + const url = route.request().url(); + if (!this.isAllowed(url)) { + this.blocked.push(url); + await route.abort('blockedbyclient'); + return; + } + await route.continue(); + }); + await context.routeWebSocket('**/*', async (webSocket) => { + const url = webSocket.url(); + if (!this.isAllowed(url)) { + this.blocked.push(url); + await webSocket.close({ code: 1008, reason: 'E2E network allowlist' }); + return; + } + webSocket.connectToServer(); + }); + + this.page.on('console', (message) => { + this.consoleMessages.push(`${message.type()}: ${message.text()}`); + }); + this.page.on('pageerror', (error) => { + this.pageErrors.push(error.stack ?? error.message); + }); + this.page.on('requestfailed', (request) => { + this.failedRequests.push( + `${request.method()} ${request.url()}: ` + + `${request.failure()?.errorText ?? 'unknown failure'}` + ); + }); + this.page.on('response', (response) => { + if (response.status() < 400) return; + const capture = this.captureResponse(response).finally(() => { + this.pending.delete(capture); + }); + this.pending.add(capture); + }); + } + + private async captureResponse(response: Response): Promise { + let body = ''; + try { + body = (await response.text()).slice(0, MAX_RESPONSE_BODY); + } catch { + body = '[unavailable]'; + } + this.failedResponses.push({ + status: response.status(), + url: response.url(), + body, + }); + } + + async finish(testInfo: TestInfo): Promise { + await Promise.allSettled(this.pending); + if (testInfo.status !== testInfo.expectedStatus) { + const diagnostics = redactArtifact( + JSON.stringify( + { + blocked: this.blocked, + console: this.consoleMessages, + pageErrors: this.pageErrors, + failedRequests: this.failedRequests, + failedResponses: this.failedResponses, + }, + null, + 2 + ), + this.environment + ); + await testInfo.attach('browser-diagnostics.json', { + body: diagnostics, + contentType: 'application/json', + }); + } + + if (this.blocked.length > 0) { + const urls = this.blocked + .map((url) => redactArtifact(url, this.environment)) + .join(', '); + throw new Error(`Browser attempted public network access: ${urls}`); + } + } +} diff --git a/webui/e2e/setup/bootstrap-chromium.setup.ts b/webui/e2e/setup/bootstrap-chromium.setup.ts new file mode 100644 index 00000000..d8dd3a59 --- /dev/null +++ b/webui/e2e/setup/bootstrap-chromium.setup.ts @@ -0,0 +1,11 @@ +import { bootstrapAuthenticatedState } from '../fixtures/auth'; +import { authStatePath } from '../fixtures/env'; +import { test } from '../fixtures/test'; + +test('complete first-run setup for Chromium', async ({ page, environment }) => { + await bootstrapAuthenticatedState( + page, + environment, + authStatePath('chromium') + ); +}); diff --git a/webui/e2e/setup/bootstrap-firefox.setup.ts b/webui/e2e/setup/bootstrap-firefox.setup.ts new file mode 100644 index 00000000..5f872418 --- /dev/null +++ b/webui/e2e/setup/bootstrap-firefox.setup.ts @@ -0,0 +1,11 @@ +import { bootstrapAuthenticatedState } from '../fixtures/auth'; +import { authStatePath } from '../fixtures/env'; +import { test } from '../fixtures/test'; + +test('complete first-run setup for Firefox', async ({ page, environment }) => { + await bootstrapAuthenticatedState( + page, + environment, + authStatePath('firefox') + ); +}); diff --git a/webui/e2e/setup/bootstrap-webkit.setup.ts b/webui/e2e/setup/bootstrap-webkit.setup.ts new file mode 100644 index 00000000..87999629 --- /dev/null +++ b/webui/e2e/setup/bootstrap-webkit.setup.ts @@ -0,0 +1,7 @@ +import { bootstrapAuthenticatedState } from '../fixtures/auth'; +import { authStatePath } from '../fixtures/env'; +import { test } from '../fixtures/test'; + +test('complete first-run setup for WebKit', async ({ page, environment }) => { + await bootstrapAuthenticatedState(page, environment, authStatePath('webkit')); +}); diff --git a/webui/e2e/specs/bootstrap-auth.spec.ts b/webui/e2e/specs/bootstrap-auth.spec.ts new file mode 100644 index 00000000..a5c2dd11 --- /dev/null +++ b/webui/e2e/specs/bootstrap-auth.spec.ts @@ -0,0 +1,75 @@ +import { E2EApi } from '../fixtures/api'; +import { + closeAuthenticatedContexts, + forceEnglish, + loginThroughUI, + newAuthenticatedContext, +} from '../fixtures/auth'; +import { SESSION_SUCCESS } from '../fixtures/data'; +import { expect, test } from '../fixtures/test'; + +test('refresh keeps a cookie-only session without exposing a token', async ({ + page, +}) => { + const refreshResponse = page.waitForResponse( + (response) => + response.url().endsWith('/api/v1/auth/refresh_token') && + response.request().method() === 'POST' + ); + await page.goto('/#/bangumi'); + + const response = await refreshResponse; + expect(response.status()).toBe(200); + expect(await response.json()).toEqual(SESSION_SUCCESS); + + const tokenCookie = (await page.context().cookies()).find( + (cookie) => cookie.name === 'token' + ); + expect(tokenCookie).toMatchObject({ httpOnly: true, sameSite: 'Strict' }); +}); + +test('wrong password is rejected without leaving the login page', async ({ + page, + environment, +}) => { + await page.context().clearCookies(); + await forceEnglish(page); + await page.addInitScript(() => localStorage.setItem('isLoggedIn', 'false')); + await loginThroughUI( + page, + { username: environment.username, password: 'definitely-wrong' }, + 401 + ); + + await expect(page).toHaveURL(/#\/login$/); + expect( + (await page.context().cookies()).some((cookie) => cookie.name === 'token') + ).toBe(false); +}); + +test('logout and browser back cannot revive a revoked session', async ({ + browser, + environment, +}, testInfo) => { + const authenticated = await newAuthenticatedContext( + browser, + environment, + environment, + testInfo + ); + const { page } = authenticated; + try { + await page.goto('/#/bangumi'); + const api = new E2EApi(page.context().request); + await api.logout(); + + await page.reload(); + await expect(page).toHaveURL(/#\/login$/); + await page.goBack(); + await expect(page).not.toHaveURL(/#\/bangumi$/); + await page.goto('/#/bangumi'); + await expect(page).toHaveURL(/#\/login$/); + } finally { + await closeAuthenticatedContexts([authenticated], testInfo); + } +}); diff --git a/webui/e2e/specs/production-smoke.spec.ts b/webui/e2e/specs/production-smoke.spec.ts new file mode 100644 index 00000000..e0a99f27 --- /dev/null +++ b/webui/e2e/specs/production-smoke.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from '../fixtures/test'; + +test('production shell and health endpoint are served', async ({ + page, + request, +}) => { + const health = await request.get('/health'); + expect(health.ok()).toBe(true); + await expect(health.json()).resolves.toMatchObject({ + status: 'ok', + db_ok: true, + }); + + await page.goto('/'); + await expect(page.locator('html')).toHaveAttribute('lang', /.+/); + const assets = await page + .locator('script[src], link[rel="stylesheet"]') + .evaluateAll((elements) => + elements.map( + (element) => element.getAttribute('src') ?? element.getAttribute('href') + ) + ); + expect(assets.some((asset) => asset?.includes('/assets/'))).toBe(true); +}); diff --git a/webui/e2e/specs/session-access.spec.ts b/webui/e2e/specs/session-access.spec.ts new file mode 100644 index 00000000..4bbed1f0 --- /dev/null +++ b/webui/e2e/specs/session-access.spec.ts @@ -0,0 +1,137 @@ +import { request as playwrightRequest } from '@playwright/test'; +import { E2EApi } from '../fixtures/api'; +import type { AuthenticatedContext } from '../fixtures/auth'; +import { + closeAuthenticatedContexts, + loginThroughUI, + newAuthenticatedContext, +} from '../fixtures/auth'; +import type { ApiTokenCreated } from '../fixtures/data'; +import { SESSION_SUCCESS, changedPassword } from '../fixtures/data'; +import { expect, test } from '../fixtures/test'; + +test('password change rotates both browser contexts and rejects the old password', async ({ + api, + browser, + environment, + uniqueName, +}, testInfo) => { + const credentials = { + username: `u-${uniqueName.slice(-16)}`, + password: 'Initial-Password9!', + }; + const user = await api.createUser(credentials); + let first: AuthenticatedContext | undefined; + let second: AuthenticatedContext | undefined; + let cleanupFailure: unknown; + + try { + first = await newAuthenticatedContext( + browser, + environment, + credentials, + testInfo + ); + second = await newAuthenticatedContext( + browser, + environment, + credentials, + testInfo + ); + + await first.page.getByRole('button', { name: 'System menu' }).click(); + await first.page.getByRole('menuitem', { name: 'Profile' }).click(); + const dialog = first.page.getByRole('dialog').last(); + const nextCredentials = { + username: credentials.username, + password: changedPassword(uniqueName), + }; + await dialog.getByLabel('Username').fill(nextCredentials.username); + await dialog + .getByLabel('Password', { exact: true }) + .fill(nextCredentials.password); + const updateResponse = first.page.waitForResponse( + (response) => + response.url().endsWith('/api/v1/auth/update') && + response.request().method() === 'POST' + ); + await dialog.getByRole('button', { name: 'Update' }).click(); + expect(await (await updateResponse).json()).toEqual(SESSION_SUCCESS); + + await new E2EApi(second.context.request).me(401); + await second.context.clearCookies(); + await second.page.evaluate(() => + localStorage.setItem('isLoggedIn', 'false') + ); + await second.page.reload(); + await expect(second.page).toHaveURL(/#\/login$/); + await loginThroughUI(second.page, credentials, 401); + await loginThroughUI(second.page, nextCredentials); + await expect(second.page).toHaveURL(/#\/bangumi$/); + } finally { + const contexts = [first, second].filter( + (value): value is AuthenticatedContext => value !== undefined + ); + const cleanup = await Promise.allSettled([ + closeAuthenticatedContexts(contexts, testInfo), + api.deleteUser(user.id), + ]); + const failure = cleanup.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ); + cleanupFailure = failure?.reason; + } + if (cleanupFailure) throw cleanupFailure; +}); + +test('API and MCP tokens are isolated and Bearer cannot control accounts', async ({ + api, + environment, + uniqueName, +}) => { + const bearerContext = await playwrightRequest.newContext({ + baseURL: environment.baseURL, + }); + const bearerApi = new E2EApi(bearerContext); + const tokens: ApiTokenCreated[] = []; + let cleanupFailure: unknown; + + try { + const apiToken = await api.createToken(`${uniqueName}-api`, 'api'); + tokens.push(apiToken); + const mcpToken = await api.createToken(`${uniqueName}-mcp`, 'mcp'); + tokens.push(mcpToken); + + await bearerApi.bearerGet('/api/v1/bangumi/get/all', apiToken.token, 200); + await bearerApi.bearerGet('/api/v1/users', apiToken.token, 403); + await bearerApi.bearerGet('/api/v1/tokens', apiToken.token, 403); + await bearerApi.bearerGet('/api/v1/bangumi/get/all', mcpToken.token, 401); + await bearerApi.bearerGet('/mcp/sse', apiToken.token, 403); + + // An explicit invalid Authorization header must beat the valid admin cookie. + await api.bearerGet('/api/v1/bangumi/get/all', mcpToken.token, 401); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + try { + const response = await fetch(`${environment.baseURL}/mcp/sse`, { + headers: { Authorization: `Bearer ${mcpToken.token}` }, + signal: controller.signal, + }); + expect(response.status).toBe(200); + } finally { + clearTimeout(timeout); + controller.abort(); + } + } finally { + const cleanup = await Promise.allSettled([ + bearerContext.dispose(), + ...tokens.map((token) => api.revokeToken(token.id)), + ]); + const failure = cleanup.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ); + cleanupFailure = failure?.reason; + } + if (cleanupFailure) throw cleanupFailure; +}); diff --git a/webui/e2e/specs/token-secret.spec.ts b/webui/e2e/specs/token-secret.spec.ts new file mode 100644 index 00000000..4a6d6578 --- /dev/null +++ b/webui/e2e/specs/token-secret.spec.ts @@ -0,0 +1,190 @@ +import { createHash } from 'node:crypto'; +import type { Page, Route } from '@playwright/test'; +import type { ApiTokenCreated } from '../fixtures/data'; +import { expect, test } from '../fixtures/test'; + +// A screenshot or video of the reveal dialog is itself a secret leak. This +// spec relies on the redacted NetworkGuard diagnostics instead. +test.use({ screenshot: 'off', trace: 'off', video: 'off' }); + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +async function expectSecretMatches(page: Page, secret: string): Promise { + const revealed = await page.getByLabel('One-time token').textContent(); + expect(sha256(revealed ?? '')).toBe(sha256(secret)); +} + +async function expectSecretAbsent(page: Page, secret: string): Promise { + const body = await page.locator('body').innerText(); + expect(body.includes(secret)).toBe(false); +} + +async function openAccess(page: Page): Promise { + await page.goto('/#/config'); + await expect(page).toHaveURL(/#\/config$/); + const accessPanel = page + .getByRole('button', { name: 'Users & Access', exact: true }) + .last(); + await expect(accessPanel).toBeVisible(); +} + +async function openBangumi(page: Page): Promise { + await page.goto('/#/bangumi'); + await expect(page).toHaveURL(/#\/bangumi$/); + await expect( + page.getByRole('button', { name: 'Add RSS Feed', exact: true }) + ).toBeVisible(); +} + +async function createTokenFromUI( + page: Page, + name: string +): Promise { + await page.getByRole('button', { name: 'Create token' }).click(); + await page.getByLabel('Token name').fill(name); + const createResponse = page.waitForResponse( + (response) => + response.url().endsWith('/api/v1/tokens') && + response.request().method() === 'POST' + ); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + const response = await createResponse; + expect(response.status()).toBe(201); + const created = (await response.json()) as ApiTokenCreated; + return created; +} + +test('one-time token disappears on close and never appears in the list', async ({ + page, + api, + uniqueName, +}) => { + await openAccess(page); + let created: ApiTokenCreated | null = null; + try { + created = await createTokenFromUI(page, `${uniqueName}-close`); + await expectSecretMatches(page, created.token); + const revealDialog = page.getByRole('dialog', { name: 'Token created' }); + await revealDialog.getByRole('button', { name: 'Close' }).click(); + await expect(page.getByLabel('One-time token')).toHaveCount(0); + + await openBangumi(page); + await openAccess(page); + const tokenRow = page.getByRole('group', { name: created.name }); + await expect(tokenRow).toContainText(created.prefix); + await expectSecretAbsent(page, created.token); + expect((await tokenRow.innerText()).includes(created.token)).toBe(false); + } finally { + if (created) await api.revokeToken(created.id); + } +}); + +function requireCreatedToken(created: ApiTokenCreated | null): ApiTokenCreated { + if (!created) throw new Error('The delayed token request was never created'); + return created; +} + +test('late token response cannot restore a secret after navigation', async ({ + page, + api, + uniqueName, +}) => { + let releaseResponse!: () => void; + const release = new Promise((resolve) => { + releaseResponse = resolve; + }); + let upstreamReady!: () => void; + const ready = new Promise((resolve) => { + upstreamReady = resolve; + }); + let routeSettled!: () => void; + const settled = new Promise((resolve) => { + routeSettled = resolve; + }); + let created: ApiTokenCreated | null = null; + let handlerStarted = false; + let responseDelivered = false; + let deliveryFailure: unknown; + let cleanupFailure: unknown; + const tokenName = `${uniqueName}-late`; + + const handleTokenRoute = async (route: Route) => { + if (route.request().method() !== 'POST') { + await route.continue(); + return; + } + handlerStarted = true; + try { + const response = await route.fetch(); + expect(response.status()).toBe(201); + created = (await response.json()) as ApiTokenCreated; + upstreamReady(); + await release; + try { + await route.fulfill({ response }); + responseDelivered = true; + } catch (error) { + deliveryFailure = error; + } + } finally { + routeSettled(); + } + }; + await page.route('**/api/v1/tokens', handleTokenRoute); + + try { + await openAccess(page); + await page.getByRole('button', { name: 'Create token' }).click(); + await page.getByLabel('Token name').fill(tokenName); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await ready; + + // An in-document hash change keeps the pending fetch alive in WebKit. The + // destination sentinel proves KeepAlive deactivated Config before the + // intercepted response is delivered. The modal makes the navigation + // links inert while the create request is pending, so drive the same hash + // router transition directly. + await page.evaluate(() => { + window.location.hash = '#/bangumi'; + }); + await expect(page).toHaveURL(/#\/bangumi$/); + await expect( + page.getByRole('button', { name: 'Add RSS Feed', exact: true }) + ).toBeVisible(); + releaseResponse(); + await settled; + if (deliveryFailure) throw deliveryFailure; + expect(responseDelivered).toBe(true); + + const issued = requireCreatedToken(created); + await expectSecretAbsent(page, issued.token); + await openAccess(page); + + const tokenRow = page.getByRole('group', { name: issued.name }); + await expect(tokenRow).toContainText(issued.prefix); + await expect(page.getByLabel('One-time token')).toHaveCount(0); + await expectSecretAbsent(page, issued.token); + } finally { + releaseResponse(); + const unroute = await Promise.allSettled([ + page.unroute('**/api/v1/tokens', handleTokenRoute), + ]); + if (handlerStarted) await settled; + + const revoke = await Promise.allSettled([ + api.listTokens().then(async (tokens) => { + const matching = tokens.filter( + (token) => token.name === tokenName && token.revoked_at === null + ); + await Promise.all(matching.map((token) => api.revokeToken(token.id))); + }), + ]); + const failure = [...unroute, ...revoke].find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ); + cleanupFailure = failure?.reason; + } + if (cleanupFailure) throw cleanupFailure; +}); diff --git a/webui/package.json b/webui/package.json index 45bafd3f..5235a937 100644 --- a/webui/package.json +++ b/webui/package.json @@ -15,6 +15,10 @@ "lint:fix": "eslint . --fix", "preview": "vite preview", "test": "vitest", + "test:e2e": "playwright test", + "test:e2e:chromium": "playwright test --project=chromium-desktop", + "test:e2e:webkit": "playwright test --project=webkit-mobile", + "test:e2e:typecheck": "tsc --noEmit -p tsconfig.e2e.json", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "generate-pwa-assets": "pwa-assets-generator --preset minimal public/images/logo.svg" @@ -35,7 +39,9 @@ "devDependencies": { "@antfu/eslint-config": "^0.38.6", "@icon-park/vue-next": "^1.4.2", + "@iconify-json/simple-icons": "1.2.89", "@intlify/unplugin-vue-i18n": "^0.11.0", + "@playwright/test": "1.61.1", "@storybook/addon-essentials": "^7.6.20", "@storybook/addon-interactions": "^7.6.20", "@storybook/addon-links": "^7.6.20", diff --git a/webui/playwright.config.ts b/webui/playwright.config.ts new file mode 100644 index 00000000..fad72427 --- /dev/null +++ b/webui/playwright.config.ts @@ -0,0 +1,97 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, devices } from '@playwright/test'; + +const profile = process.env.AB_E2E_PROFILE ?? 'browser'; +const baseURL = process.env.AB_E2E_BASE_URL ?? 'http://127.0.0.1:0'; +const projectDirectory = fileURLToPath(new URL('.', import.meta.url)); +const authDirectory = resolve(projectDirectory, 'e2e/.auth'); +function authFile(browser: string) { + return resolve(authDirectory, `${profile}-${browser}.json`); +} +const downloaderOnly = profile === 'downloader'; + +const commonUse = { + baseURL, + locale: 'en-US', + serviceWorkers: 'block' as const, + // Raw Playwright traces serialize storage state, request bodies, and + // response payloads. Authenticated E2E traces would therefore publish + // session cookies and one-time tokens with CI artifacts. The redacted + // NetworkGuard diagnostics below remain the supported failure record. + trace: 'off' as const, + screenshot: 'only-on-failure' as const, + video: 'retain-on-failure' as const, +}; + +export default defineConfig({ + testDir: './e2e', + outputDir: 'test-results', + fullyParallel: false, + workers: 1, + retries: 0, + timeout: 60_000, + expect: { timeout: 10_000 }, + forbidOnly: Boolean(process.env.CI), + reporter: [ + ['list'], + ['html', { outputFolder: 'playwright-report', open: 'never' }], + ], + use: commonUse, + projects: [ + { + name: 'setup-chromium', + testMatch: /setup\/bootstrap-chromium\.setup\.ts/, + use: { ...devices['Desktop Chrome'], ...commonUse }, + }, + { + name: 'chromium-desktop', + testMatch: /specs\/.*\.spec\.ts/, + dependencies: ['setup-chromium'], + grep: downloaderOnly ? /@downloader/ : undefined, + grepInvert: downloaderOnly ? undefined : /@(?:downloader|mobile)/, + use: { + ...devices['Desktop Chrome'], + ...commonUse, + storageState: authFile('chromium'), + }, + }, + { + name: 'setup-webkit', + testMatch: /setup\/bootstrap-webkit\.setup\.ts/, + use: { + ...devices['iPhone 13'], + ...commonUse, + viewport: { width: 390, height: 844 }, + }, + }, + { + name: 'webkit-mobile', + testMatch: /specs\/.*\.spec\.ts/, + dependencies: ['setup-webkit'], + grepInvert: /@downloader/, + use: { + ...devices['iPhone 13'], + ...commonUse, + viewport: { width: 390, height: 844 }, + storageState: authFile('webkit'), + }, + }, + { + name: 'setup-firefox', + testMatch: /setup\/bootstrap-firefox\.setup\.ts/, + use: { ...devices['Desktop Firefox'], ...commonUse }, + }, + { + name: 'firefox-nightly', + testMatch: /specs\/.*\.spec\.ts/, + dependencies: ['setup-firefox'], + grepInvert: /@(?:downloader|mobile)/, + use: { + ...devices['Desktop Firefox'], + ...commonUse, + storageState: authFile('firefox'), + }, + }, + ], +}); diff --git a/webui/pnpm-lock.yaml b/webui/pnpm-lock.yaml index 122282ec..fe959546 100644 --- a/webui/pnpm-lock.yaml +++ b/webui/pnpm-lock.yaml @@ -48,9 +48,15 @@ importers: '@icon-park/vue-next': specifier: ^1.4.2 version: 1.4.2(vue@3.5.8(typescript@4.9.5)) + '@iconify-json/simple-icons': + specifier: 1.2.89 + version: 1.2.89 '@intlify/unplugin-vue-i18n': specifier: ^0.11.0 version: 0.11.0(rollup@2.79.1)(vue-i18n@9.14.5(vue@3.5.8(typescript@4.9.5)))(webpack-sources@3.2.3) + '@playwright/test': + specifier: 1.61.1 + version: 1.61.1 '@storybook/addon-essentials': specifier: ^7.6.20 version: 7.6.20(@types/react@18.3.8)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(webpack-sources@3.2.3) @@ -146,7 +152,7 @@ importers: version: 0.16.7(vite@4.5.5(@types/node@18.19.50)(sass@1.62.1)(terser@5.33.0))(workbox-build@7.0.0(@types/babel__core@7.20.5))(workbox-window@7.0.0) vitest: specifier: ^0.30.1 - version: 0.30.1(happy-dom@12.10.3)(sass@1.62.1)(terser@5.33.0) + version: 0.30.1(happy-dom@12.10.3)(playwright@1.61.1)(sass@1.62.1)(terser@5.33.0) vue-tsc: specifier: ^1.8.27 version: 1.8.27(typescript@4.9.5) @@ -1039,6 +1045,9 @@ packages: peerDependencies: vue: 3.x + '@iconify-json/simple-icons@1.2.89': + resolution: {integrity: sha512-hRaCY5s2G5oWAIhc4LCGYn6g6RrwLL4zhoLOT+KUO3joVCxVlZKA+839bv/47Nbe9/ZD4UA6dznZ4XPYcI53wA==} + '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -1171,6 +1180,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.28': resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} @@ -3473,6 +3487,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4662,6 +4681,16 @@ packages: pkg-types@1.2.0: resolution: {integrity: sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -5857,8 +5886,8 @@ packages: vue-component-type-helpers@2.1.6: resolution: {integrity: sha512-ng11B8B/ZADUMMOsRbqv0arc442q7lifSubD0v8oDXIFoMg/mXwAPUunrroIDkY+mcD0dHKccdaznSVp8EoX3w==} - vue-component-type-helpers@3.3.6: - resolution: {integrity: sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==} + vue-component-type-helpers@3.3.7: + resolution: {integrity: sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -7220,6 +7249,10 @@ snapshots: dependencies: vue: 3.5.8(typescript@4.9.5) + '@iconify-json/simple-icons@1.2.89': + dependencies: + '@iconify/types': 2.0.0 + '@iconify/types@2.0.0': {} '@iconify/utils@2.1.33': @@ -7402,6 +7435,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@polka/url@1.0.0-next.28': {} '@radix-ui/number@1.0.1': @@ -8386,7 +8423,7 @@ snapshots: ts-dedent: 2.2.0 type-fest: 2.19.0 vue: 3.5.8(typescript@4.9.5) - vue-component-type-helpers: 3.3.6 + vue-component-type-helpers: 3.3.7 transitivePeerDependencies: - encoding - supports-color @@ -10500,6 +10537,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -11674,6 +11714,14 @@ snapshots: mlly: 1.7.1 pathe: 1.1.2 + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} polished@4.3.1: @@ -12915,7 +12963,7 @@ snapshots: sass: 1.62.1 terser: 5.33.0 - vitest@0.30.1(happy-dom@12.10.3)(sass@1.62.1)(terser@5.33.0): + vitest@0.30.1(happy-dom@12.10.3)(playwright@1.61.1)(sass@1.62.1)(terser@5.33.0): dependencies: '@types/chai': 4.3.19 '@types/chai-subset': 1.3.5 @@ -12945,6 +12993,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: happy-dom: 12.10.3 + playwright: 1.61.1 transitivePeerDependencies: - less - lightningcss @@ -12963,7 +13012,7 @@ snapshots: vue-component-type-helpers@2.1.6: {} - vue-component-type-helpers@3.3.6: {} + vue-component-type-helpers@3.3.7: {} vue-demi@0.14.10(vue@3.5.8(typescript@4.9.5)): dependencies: diff --git a/webui/src/components/basic/__tests__/ab-bottom-sheet.test.ts b/webui/src/components/basic/__tests__/ab-bottom-sheet.test.ts index 026b9a57..831d2b26 100644 --- a/webui/src/components/basic/__tests__/ab-bottom-sheet.test.ts +++ b/webui/src/components/basic/__tests__/ab-bottom-sheet.test.ts @@ -31,12 +31,46 @@ async function mountSheet(props = {}) { } describe('ab-bottom-sheet', () => { + it('should expose its title as the dialog accessible name', async () => { + await mountSheet(); + + const dialog = document.querySelector('[role="dialog"]'); + const labelledBy = dialog?.getAttribute('aria-labelledby'); + expect(labelledBy).toBeTruthy(); + expect(document.getElementById(labelledBy!)?.textContent).toContain( + 'Edit rule' + ); + }); + + it('should provide an accessible close action', async () => { + const wrapper = await mountSheet({ closeLabel: 'Dismiss sheet' }); + + const closeButton = document.querySelector( + 'button[aria-label="Dismiss sheet"]' + ); + expect(closeButton).not.toBeNull(); + expect(closeButton!.classList.contains('ab-icon-btn--md')).toBe(true); + closeButton!.click(); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('update:show')).toEqual([[false]]); + expect(wrapper.emitted('close')).toHaveLength(1); + }); + + it('should hide the close action when requested', async () => { + await mountSheet({ showClose: false }); + + expect(document.querySelector('button[aria-label="Close"]')).toBeNull(); + }); + it('should render the fullscreen panel class when requested', async () => { await mountSheet({ fullscreen: true, avoidKeyboard: false }); + const panel = document.querySelector('[data-testid="bottom-sheet-panel"]'); + expect(panel).not.toBeNull(); expect( - document.querySelector('.ab-bottom-sheet__panel--fullscreen') - ).not.toBeNull(); + panel?.classList.contains('ab-bottom-sheet__panel--fullscreen') + ).toBe(true); }); it('should not translate the sheet for keyboard changes when avoidance is disabled', async () => { diff --git a/webui/src/components/basic/ab-bottom-sheet.vue b/webui/src/components/basic/ab-bottom-sheet.vue index 69768be5..3874c424 100644 --- a/webui/src/components/basic/ab-bottom-sheet.vue +++ b/webui/src/components/basic/ab-bottom-sheet.vue @@ -4,21 +4,28 @@ import { usePointerSwipe } from '@vueuse/core'; import { Dialog, DialogPanel, + DialogTitle, TransitionChild, TransitionRoot, } from '@headlessui/vue'; +import { Close } from '@icon-park/vue-next'; +import AbIconButton from './ab-icon-button.vue'; const props = withDefaults( defineProps<{ show: boolean; title?: string; closeable?: boolean; + showClose?: boolean; + closeLabel?: string; maxHeight?: string; fullscreen?: boolean; avoidKeyboard?: boolean; }>(), { closeable: true, + showClose: true, + closeLabel: 'Close', maxHeight: '85dvh', fullscreen: false, avoidKeyboard: true, @@ -140,6 +147,7 @@ function close() {
-
-

{{ title }}

+
+ + {{ title }} + + + +
@@ -248,6 +270,10 @@ function close() { } &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; padding: 0 20px 12px; border-bottom: 1px solid var(--color-border); } @@ -259,6 +285,10 @@ function close() { margin: 0; } + &__close { + margin-left: auto; + } + &__content { flex: 1; overflow-y: auto; diff --git a/webui/src/components/basic/ab-modal.vue b/webui/src/components/basic/ab-modal.vue index 46edd5cf..6e9893a9 100644 --- a/webui/src/components/basic/ab-modal.vue +++ b/webui/src/components/basic/ab-modal.vue @@ -58,6 +58,8 @@ function close() { :show="show" :title="title" :closeable="closable" + :show-close="showClose" + :close-label="$t('common.close')" :max-height="maxHeight" :fullscreen="mobileFullscreen" :avoid-keyboard="avoidKeyboard" diff --git a/webui/src/components/setting/__tests__/config-access.test.ts b/webui/src/components/setting/__tests__/config-access.test.ts index 8850b9d1..8dd78013 100644 --- a/webui/src/components/setting/__tests__/config-access.test.ts +++ b/webui/src/components/setting/__tests__/config-access.test.ts @@ -256,6 +256,12 @@ describe('config access', () => { ); expect(wrapper.text()).toContain('expired-token'); expect(wrapper.text()).toContain('revoked-token'); + expect(wrapper.find('[role="group"][aria-label="alice"]').exists()).toBe( + true + ); + expect( + wrapper.find('[role="group"][aria-label="active-token"]').exists() + ).toBe(true); wrapper.unmount(); }); @@ -312,6 +318,9 @@ describe('config access', () => { await flushPromises(); expect(state.issuedToken).toBe('ab_api_one_time_secret'); expect(wrapper.text()).toContain('ab_api_one_time_secret'); + expect(wrapper.find('[aria-label="access.one_time_token"]').text()).toBe( + 'ab_api_one_time_secret' + ); await wrapper .find('[data-title="access.token_created"] .stub-modal-close') diff --git a/webui/src/components/setting/config-access.vue b/webui/src/components/setting/config-access.vue index 22835903..d3cdf98d 100644 --- a/webui/src/components/setting/config-access.vue +++ b/webui/src/components/setting/config-access.vue @@ -189,7 +189,13 @@ function tokenStatus(token: ApiTokenPublic): ApiTokenStatus {
{{ $t('access.loading') }}
-
+
{{ user.username }} @@ -230,7 +236,13 @@ function tokenStatus(token: ApiTokenPublic): ApiTokenStatus { {{ $t('access.no_tokens') }}
-
+
{{ token.name }} @@ -342,7 +354,7 @@ function tokenStatus(token: ApiTokenPublic): ApiTokenStatus { >

{{ $t('access.token_once') }}

- {{ issuedToken }} + {{ issuedToken }}