diff --git a/.gitignore b/.gitignore index e8fa919..4e4b8bc 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ config/usage/ .tmp-test-creds/ .tmp-test-debug-usage-root/ .tmp-test-runtime-root/ +.tmp-e2e/ # Credentials .codebuddy_creds/ diff --git a/e2e/account-status.spec.ts b/e2e/account-status.spec.ts index 926c999..14fce8f 100644 --- a/e2e/account-status.spec.ts +++ b/e2e/account-status.spec.ts @@ -18,7 +18,9 @@ test.describe('Account Status tab', () => { await expect( page.getByRole('button', { name: 'Account Status' }), ).toBeVisible(); - await expect(page.getByText('No credentials')).toBeVisible(); + await expect( + page.getByText('No credentials', { exact: true }), + ).toBeVisible(); await expect( page.getByRole('button', { name: 'Refresh all' }), ).toBeVisible(); @@ -38,7 +40,9 @@ test.describe('Account Status tab', () => { ), ) .toBe(true); - await expect(page.getByText('No credentials')).toBeVisible(); + await expect( + page.getByText('No credentials', { exact: true }), + ).toBeVisible(); }); test('renders account status from SSR without a client refresh request', async ({ diff --git a/e2e/admin-api-contracts.spec.ts b/e2e/admin-api-contracts.spec.ts new file mode 100644 index 0000000..2255ed6 --- /dev/null +++ b/e2e/admin-api-contracts.spec.ts @@ -0,0 +1,352 @@ +import { expect, test } from '@playwright/test'; + +const json = (body: unknown) => ({ + data: body, + headers: { 'Content-Type': 'application/json' }, +}); + +const credentialName = (suffix: string) => `contract-${suffix}.json`; + +test.describe('Admin API contracts', () => { + test.beforeEach(async ({ context }) => { + await context.addCookies([ + { + name: 'codebuddy2api-locale', + value: 'en-US', + domain: '127.0.0.1', + path: '/', + }, + ]); + }); + + test.afterEach(async ({ request }) => { + const credentialsResponse = await request.get('/admin-api/credentials'); + const credentials = (await credentialsResponse.json()) as { + credentials?: Array<{ filename?: string; index?: number }>; + }; + for (const credential of credentials.credentials ?? []) { + if ( + typeof credential.index === 'number' && + credential.filename?.startsWith('contract-') + ) { + await request.post('/admin-api/credentials/delete', { + data: { index: credential.index }, + }); + } + } + + const accessKeysResponse = await request.get('/admin-api/access-keys'); + const accessKeys = (await accessKeysResponse.json()) as { + access_keys?: Array<{ id?: string; name?: string }>; + }; + for (const accessKey of accessKeys.access_keys ?? []) { + if (accessKey.id && accessKey.name?.startsWith('contract-')) { + await request.delete(`/admin-api/access-keys/${accessKey.id}`); + } + } + }); + + test('returns an unauthenticated admin session summary', async ({ + request, + }) => { + const response = await request.get('/admin-api/auth/session'); + expect(response.ok()).toBe(true); + expect(await response.json()).toEqual( + expect.objectContaining({ + session: expect.objectContaining({ + accountConfigured: false, + authEnabled: false, + authenticated: false, + passkeyCount: 0, + passwordConfigured: false, + }), + }), + ); + }); + + test('rejects invalid admin password setup without changing state', async ({ + request, + }) => { + const invalidUsername = await request.post('/admin-api/auth/setup', { + ...json({ password: 'long-enough-password', username: 'x' }), + }); + expect(invalidUsername.status()).toBe(400); + + const invalidPassword = await request.post('/admin-api/auth/setup', { + ...json({ password: 'short', username: 'contract-admin' }), + }); + expect(invalidPassword.status()).toBe(400); + + const session = await request.get('/admin-api/auth/session'); + expect((await session.json()).session.accountConfigured).toBe(false); + }); + + test('returns a safe error when password login is unavailable', async ({ + request, + }) => { + const response = await request.post('/admin-api/auth/session', { + ...json({ password: 'wrong-password', username: 'admin' }), + }); + expect(response.status()).toBe(400); + expect(await response.json()).toEqual({ + error: { message: 'Admin password is not configured' }, + }); + }); + + test('exposes empty passkey state and rejects unauthenticated options', async ({ + request, + }) => { + const list = await request.get('/admin-api/auth/passkeys'); + expect(list.ok()).toBe(true); + expect(await list.json()).toEqual( + expect.objectContaining({ passkeys: [], session: expect.any(Object) }), + ); + + const authentication = await request.post( + '/admin-api/auth/passkeys/authentication/options', + ); + expect(authentication.status()).toBe(400); + + const registration = await request.post( + '/admin-api/auth/passkeys/registration/options', + { ...json({ name: 'contract-passkey' }) }, + ); + expect(registration.status()).toBe(401); + }); + + test('lists, creates, reads, updates, and deletes access keys', async ({ + request, + }) => { + const initial = await request.get('/admin-api/access-keys'); + expect(initial.ok()).toBe(true); + expect((await initial.json()).access_keys).toEqual([]); + + const missingCredential = await request.post('/admin-api/access-keys', { + ...json({ credential_filenames: ['missing.json'], name: 'contract-key' }), + }); + expect(missingCredential.status()).toBe(400); + + const filename = credentialName('access-key'); + const credential = await request.post('/admin-api/credentials', { + ...json({ + bearer_token: 'contract-token', + filename, + supported_models: 'contract-model', + user_id: 'contract@example.test', + }), + }); + expect(credential.ok()).toBe(true); + + const created = await request.post('/admin-api/access-keys', { + ...json({ + credential_filenames: [filename, ` ${filename} `], + name: ' contract-key ', + }), + }); + expect(created.ok()).toBe(true); + const createdBody = (await created.json()) as { + access_key: { id: string; name: string; maskedSecret: string }; + secret: string; + }; + expect(createdBody.access_key.name).toBe('contract-key'); + expect(createdBody.access_key.maskedSecret).not.toContain( + createdBody.secret, + ); + + const listed = await request.get('/admin-api/access-keys'); + expect((await listed.json()).access_keys).toEqual([ + expect.objectContaining({ + credentialFilenames: [filename], + id: createdBody.access_key.id, + name: 'contract-key', + }), + ]); + + const secret = await request.get( + `/admin-api/access-keys/${createdBody.access_key.id}/secret`, + ); + expect(await secret.json()).toEqual({ + id: createdBody.access_key.id, + name: 'contract-key', + secret: createdBody.secret, + }); + + const updated = await request.patch( + `/admin-api/access-keys/${createdBody.access_key.id}`, + { + ...json({ credential_filenames: [filename], name: 'contract-renamed' }), + }, + ); + expect(updated.ok()).toBe(true); + expect((await updated.json()).access_key.name).toBe('contract-renamed'); + + const invalidUpdate = await request.patch( + `/admin-api/access-keys/${createdBody.access_key.id}`, + { ...json({ credential_filenames: ['missing.json'], name: 'bad' }) }, + ); + expect(invalidUpdate.status()).toBe(400); + + const deleted = await request.delete( + `/admin-api/access-keys/${createdBody.access_key.id}`, + ); + expect(await deleted.json()).toEqual({ success: true }); + expect( + ( + await request.get( + `/admin-api/access-keys/${createdBody.access_key.id}/secret`, + ) + ).status(), + ).toBe(404); + }); + + test('handles access-key not-found mutations', async ({ request }) => { + const patch = await request.patch('/admin-api/access-keys/not-found', { + ...json({ credential_filenames: [], name: 'contract-key' }), + }); + expect(patch.status()).toBe(404); + const deletion = await request.delete('/admin-api/access-keys/not-found'); + expect(deletion.status()).toBe(404); + const secret = await request.get('/admin-api/access-keys/not-found/secret'); + expect(secret.status()).toBe(404); + }); + + test('covers credential auto rotation and empty current state', async ({ + request, + }) => { + const current = await request.get('/admin-api/credentials/current'); + expect(current.ok()).toBe(true); + expect(await current.json()).toEqual({ status: 'no_credentials' }); + + const auto = await request.post('/admin-api/credentials/auto'); + expect(auto.ok()).toBe(true); + expect(await auto.json()).toEqual({ + message: 'Round-robin is always enabled', + success: true, + }); + }); + + test('supports account-status filtering and check-in actions with no credentials', async ({ + request, + }) => { + const get = await request.get('/admin-api/account-status'); + expect(get.ok()).toBe(true); + expect(await get.json()).toEqual({ credentials: [], statuses: [] }); + + const filtered = await request.post('/admin-api/account-status', { + ...json({ filename: 'missing.json' }), + }); + expect(filtered.ok()).toBe(true); + expect((await filtered.json()).statuses).toEqual([]); + + const checkinAll = await request.post('/admin-api/account-status', { + ...json({ action: 'checkin' }), + }); + expect(checkinAll.ok()).toBe(true); + expect((await checkinAll.json()).statuses).toEqual([]); + + const checkinOne = await request.post('/admin-api/account-status', { + ...json({ action: 'checkin', filename: 'missing.json' }), + }); + expect(checkinOne.status()).toBe(500); + }); + + test('clears usage history and validates debug settings bounds', async ({ + request, + }) => { + const clear = await request.post('/admin-api/usage/clear'); + expect(await clear.json()).toEqual({ success: true }); + + const settings = await request.post('/admin-api/debug', { + ...json({ autoRefreshSeconds: 300, enabled: false, maxEntries: 1 }), + }); + expect(settings.ok()).toBe(true); + expect(await settings.json()).toEqual({ + autoRefreshSeconds: 300, + enabled: false, + maxEntries: 1, + }); + + const missingLog = await request.get('/admin-api/debug?id=missing-id'); + expect(missingLog.status()).toBe(404); + expect(await missingLog.json()).toEqual({ item: null }); + }); + + test('returns localized settings labels and handles preference variants', async ({ + request, + }) => { + const settings = await request.get('/admin-api/settings', { + headers: { cookie: 'codebuddy2api-locale=zh-CN' }, + }); + expect(settings.ok()).toBe(true); + expect((await settings.json()).labels).toEqual(expect.any(Object)); + + const system = await request.post('/admin-api/preferences', { + ...json({ localePreference: 'system', theme: 'system' }), + }); + expect(system.ok()).toBe(true); + expect(system.headers()['set-cookie']).toContain('Max-Age=0'); + + const invalidTheme = await request.post('/admin-api/preferences', { + ...json({ theme: 'neon' }), + }); + expect(invalidTheme.status()).toBe(400); + }); + + test('covers public authentication responses when no access key exists', async ({ + request, + }) => { + const models = await request.get('/v1/models'); + expect([200, 500, 502]).toContain(models.status()); + if (models.ok()) { + expect((await models.json()).object).toBe('list'); + } + + const messages = await request.post('/v1/messages', { + ...json({ model: 'contract-model', messages: [] }), + }); + expect([400, 500, 502]).toContain(messages.status()); + + const responses = await request.post('/v1/responses', { + ...json({ input: 'hello', model: 'contract-model' }), + }); + expect([400, 500, 502]).toContain(responses.status()); + }); + + test('covers CodeBuddy auth route validation and upstream failure handling', async ({ + request, + }) => { + const poll = await request.post('/codebuddy/auth/poll', { + ...json({ auth_state: '' }), + }); + expect(poll.status()).toBe(400); + expect(await poll.json()).toEqual( + expect.objectContaining({ error: 'missing_parameters' }), + ); + + const start = await request.get('/codebuddy/auth/start'); + expect([400, 500]).toContain(start.status()); + expect(await start.json()).toEqual( + expect.objectContaining({ success: false, error: 'auth_start_failed' }), + ); + + const callback = await request.get('/codebuddy/auth/callback'); + expect(callback.ok()).toBe(true); + expect(await callback.json()).toEqual({ + code: null, + message: '授权成功!请返回应用程序。', + state: null, + }); + }); + + test('renders login and security pages without hydration errors', async ({ + page, + }) => { + await page.goto('/login'); + await expect(page.locator('main')).toBeVisible(); + await expect(page.getByText(/Admin|登录|管理员/i).first()).toBeVisible(); + + await page.goto('/settings'); + await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible(); + await expect(page.locator('main')).toBeVisible(); + }); +}); diff --git a/e2e/admin-console.spec.ts b/e2e/admin-console.spec.ts new file mode 100644 index 0000000..56796fd --- /dev/null +++ b/e2e/admin-console.spec.ts @@ -0,0 +1,289 @@ +import { expect, test } from '@playwright/test'; + +const filename = 'admin-console-e2e.json'; +const json = (body: unknown) => ({ + data: body, + headers: { 'Content-Type': 'application/json' }, +}); + +test.describe('Admin console essentials', () => { + test.beforeEach(async ({ context }) => { + await context.addCookies([ + { + name: 'codebuddy2api-locale', + value: 'en-US', + domain: '127.0.0.1', + path: '/', + }, + ]); + }); + + test.afterEach(async ({ request }) => { + const credentialsResponse = await request.get('/admin-api/credentials'); + const credentials = (await credentialsResponse.json()) as { + credentials?: Array<{ filename?: string; index?: number }>; + }; + const credential = credentials.credentials?.find( + (item) => item.filename === filename, + ); + + if (typeof credential?.index === 'number') { + await request.post('/admin-api/credentials/delete', { + data: { index: credential.index }, + }); + } + }); + + test('reports a healthy storage backend', async ({ request }) => { + const response = await request.get('/health'); + + expect(response.ok()).toBe(true); + expect(await response.json()).toEqual( + expect.objectContaining({ service: 'codebuddy2api', status: 'healthy' }), + ); + }); + + test('validates preferences and persists locale/theme cookies', async ({ + request, + }) => { + const invalidLocale = await request.post('/admin-api/preferences', { + ...json({ localePreference: 'xx-INVALID' }), + }); + expect(invalidLocale.status()).toBe(400); + + const response = await request.post('/admin-api/preferences', { + ...json({ + localePreference: 'zh-CN', + resolvedTheme: 'dark', + theme: 'dark', + }), + }); + expect(response.ok()).toBe(true); + expect(response.headers()['set-cookie']).toContain( + 'codebuddy2api-locale=zh-CN', + ); + expect(response.headers()['set-cookie']).toContain( + 'codebuddy2api-theme=dark', + ); + }); + + test('reads and updates runtime settings through the settings API', async ({ + request, + }) => { + const before = await request.get('/admin-api/settings'); + expect(before.ok()).toBe(true); + expect((await before.json()).settings).toBeDefined(); + + const updated = await request.post('/admin-api/settings', { + ...json({ settings: { debug_enabled: true } }), + }); + expect(updated.ok()).toBe(true); + expect((await updated.json()).settings).toBeDefined(); + }); + + test('supports usage range validation and debug settings lifecycle', async ({ + request, + }) => { + const invalidUsage = await request.get('/admin-api/usage?range=invalid'); + expect(invalidUsage.status()).toBe(400); + + const usage = await request.get('/admin-api/usage?range=1h'); + expect(usage.ok()).toBe(true); + expect((await usage.json()).range).toBe('1h'); + + const debugSettings = await request.post('/admin-api/debug', { + ...json({ autoRefreshSeconds: 0, enabled: true, maxEntries: 10 }), + }); + expect(debugSettings.ok()).toBe(true); + expect(await debugSettings.json()).toEqual( + expect.objectContaining({ enabled: true, maxEntries: 10 }), + ); + + const debugList = await request.get('/admin-api/debug'); + expect(debugList.ok()).toBe(true); + expect((await debugList.json()).items).toEqual([]); + const cleared = await request.delete('/admin-api/debug'); + expect(cleared.ok()).toBe(true); + }); + + test('returns stable empty states from stats and credential model APIs', async ({ + request, + }) => { + const stats = await request.get('/admin-api/stats'); + expect(stats.ok()).toBe(true); + expect(await stats.json()).toEqual( + expect.objectContaining({ + credential_usage: expect.any(Object), + model_usage: expect.any(Object), + }), + ); + + const models = await request.get('/admin-api/credentials/models'); + expect(models.ok()).toBe(true); + expect(await models.json()).toEqual({ models: {} }); + + const unavailable = await request.post('/admin-api/credentials/models', { + ...json({ filename: 'missing.json' }), + }); + expect(unavailable.status()).toBe(404); + }); + + test('rejects malformed credential and selection payloads', async ({ + request, + }) => { + const malformedCredential = await request.post('/admin-api/credentials', { + ...json({ filename: '../escape.json', bearer_token: 'token' }), + }); + expect(malformedCredential.status()).toBe(400); + + const malformedDelete = await request.post( + '/admin-api/credentials/delete', + { + ...json({ index: '0' }), + }, + ); + expect(malformedDelete.status()).toBe(400); + + const malformedSelect = await request.post( + '/admin-api/credentials/select', + { + ...json({ index: 1.5 }), + }, + ); + expect(malformedSelect.status()).toBe(400); + }); + + test('creates, edits, and deletes a credential from the console', async ({ + page, + }) => { + const createResponse = await page.request.post('/admin-api/credentials', { + data: { + bearer_token: 'e2e-bearer-token', + filename, + user_id: 'e2e@example.test', + }, + }); + expect(createResponse.ok()).toBe(true); + await page.goto('/credentials'); + const credentialCard = page + .locator('.credential-card') + .filter({ + hasText: filename, + }) + .last(); + await expect(credentialCard).toBeVisible(); + await credentialCard.getByRole('button', { name: 'Edit' }).click(); + const saveButton = credentialCard.getByRole('button', { + name: 'Save', + exact: true, + }); + await expect(saveButton).toBeVisible(); + await saveButton.click(); + await expect(page.getByText('Credential saved:')).toBeVisible(); + + await credentialCard.getByRole('button', { name: 'Delete' }).click(); + await expect(page.getByText('Credential deleted.')).toBeVisible(); + }); + + test('covers credential selection, current state, model persistence, and rotation', async ({ + request, + }) => { + const created = await request.post('/admin-api/credentials', { + ...json({ + bearer_token: 'e2e-api-token', + filename, + user_id: 'api@example.test', + }), + }); + expect(created.ok()).toBe(true); + const listed = await request.get('/admin-api/credentials'); + const credential = ( + (await listed.json()).credentials as Array<{ index: number }> + ).find((item) => typeof item.index === 'number'); + expect(credential).toBeDefined(); + + const selected = await request.post('/admin-api/credentials/select', { + ...json({ index: credential?.index }), + }); + expect(selected.ok()).toBe(true); + expect((await request.get('/admin-api/credentials/current')).ok()).toBe( + true, + ); + + const models = await request.put('/admin-api/credentials/models', { + ...json({ filename, models: 'glm-e2e\n glm-e2e-2' }), + }); + expect(models.ok()).toBe(true); + expect((await models.json()).models[`${filename}`].models).toEqual([ + { id: 'glm-e2e' }, + { id: 'glm-e2e-2' }, + ]); + + const rotation = await request.post( + '/admin-api/credentials/toggle-rotation', + ); + expect(rotation.ok()).toBe(true); + expect((await rotation.json()).auto_rotation_enabled).toBe(true); + }); + + test('returns safe errors for unauthorized proxy requests', async ({ + request, + }) => { + const models = await request.get('/v1/models'); + expect([200, 401, 403]).toContain(models.status()); + const completion = await request.post('/v1/chat/completions', { + ...json({ + model: 'glm-e2e', + messages: [{ content: 'hello', role: 'user' }], + }), + }); + expect([400, 401, 403, 500, 502]).toContain(completion.status()); + }); + + test('renders API Test controls with no eligible credentials', async ({ + page, + }) => { + await page.goto('/api-test'); + + await expect(page.getByRole('button', { name: 'API Test' })).toBeVisible(); + await expect(page.getByLabel('Credential')).toBeVisible(); + await expect(page.getByLabel('Model')).toBeVisible(); + await expect(page.getByLabel('Test message')).toHaveValue( + 'Hello, what is 2+2?', + ); + await expect(page.getByRole('button', { name: 'Send test' })).toBeVisible(); + await expect( + page.getByText('Click "Send test" to view the API response...'), + ).toBeVisible(); + }); + + test('loads every admin console page and keeps navigation in sync', async ({ + page, + }) => { + const pages = [ + ['/dashboard', 'Dashboard'], + ['/usage', 'Usage'], + ['/credentials', 'Credentials'], + ['/account-status', 'Account Status'], + ['/api-test', 'API Test'], + ['/debug', 'Debug'], + ['/settings', 'Settings'], + ] as const; + + for (const [route, tab] of pages) { + await page.goto(route); + await expect( + page.getByRole('button', { name: tab, exact: true }), + ).toBeVisible(); + await expect(page.locator('main')).toBeVisible(); + } + }); + + test('redirects the root route to dashboard', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveURL(/\/dashboard$/); + await expect( + page.getByRole('button', { name: 'Dashboard', exact: true }), + ).toBeVisible(); + }); +}); diff --git a/e2e/full-route-coverage.spec.ts b/e2e/full-route-coverage.spec.ts new file mode 100644 index 0000000..ad2c4bd --- /dev/null +++ b/e2e/full-route-coverage.spec.ts @@ -0,0 +1,962 @@ +import { expect, test, type APIRequestContext } from '@playwright/test'; + +const json = (body: unknown) => ({ + data: body, + headers: { 'Content-Type': 'application/json' }, +}); + +const readJson = async (response: { json: () => Promise }) => { + return (await response.json()) as Record; +}; + +const clearIsolatedState = async (request: APIRequestContext) => { + const credentials = (await request + .get('/admin-api/credentials') + .then((response) => response.json())) as { + credentials?: Array<{ index?: number }>; + }; + for (const credential of credentials.credentials ?? []) { + if (typeof credential.index === 'number') { + await request.post('/admin-api/credentials/delete', { + data: { index: credential.index }, + }); + } + } +}; + +test.describe('Full route coverage', () => { + test.beforeEach(async ({ context }) => { + await context.addCookies([ + { + name: 'codebuddy2api-locale', + value: 'en-US', + domain: '127.0.0.1', + path: '/', + }, + ]); + }); + + test.beforeEach(async ({ request }) => { + await clearIsolatedState(request); + }); + + test('serves every public page with a stable document shell', async ({ + page, + }) => { + const routes = [ + '/', + '/dashboard', + '/credentials', + '/account-status', + '/api-test', + '/usage', + '/debug', + '/settings', + '/login', + ]; + + for (const route of routes) { + const response = await page.goto(route); + expect(response?.ok(), route).toBe(true); + await expect(page.locator('body')).toBeVisible(); + await expect(page.locator('main')).toBeVisible(); + await expect(page.locator('header')).toBeVisible(); + } + }); + + test('renders dashboard content and responsive navigation', async ({ + page, + }) => { + await page.goto('/dashboard'); + await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible(); + await expect(page.getByText(/CodeBuddy|API/i).first()).toBeVisible(); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect + .poll(() => + page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ) + .toBe(true); + }); + + test('renders credentials empty state and action controls', async ({ + page, + }) => { + await page.goto('/credentials'); + await expect( + page.getByRole('button', { name: 'Credentials' }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: /Add credential/i }), + ).toBeVisible(); + await expect(page.locator('main')).toContainText(/credential/i); + }); + + test('renders usage controls and default range', async ({ page }) => { + await page.goto('/usage'); + await expect(page.getByRole('button', { name: 'Usage' })).toBeVisible(); + await expect(page.locator('main')).toContainText(/usage/i); + const rangeControl = page.getByRole('combobox').first(); + if (await rangeControl.count()) { + await expect(rangeControl).toBeVisible(); + } + }); + + test('renders debug controls and empty log state', async ({ page }) => { + await page.goto('/debug'); + await expect(page.getByRole('button', { name: 'Debug' })).toBeVisible(); + await expect(page.locator('main')).toContainText(/debug/i); + await expect(page.getByRole('button', { name: /clear/i })).toBeVisible(); + }); + + test('renders settings controls and security section', async ({ page }) => { + await page.goto('/settings'); + await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible(); + await expect(page.locator('main')).toContainText(/settings/i); + await expect(page.locator('main')).toContainText( + /security|password|passkey/i, + ); + }); + + test('renders login fields and rejects an empty submission', async ({ + page, + }) => { + await page.goto('/login'); + const username = page.getByLabel(/username|用户名/i).first(); + const password = page.getByLabel(/password|密码/i).first(); + if (await username.count()) { + await expect(username).toBeVisible(); + } + if (await password.count()) { + await expect(password).toBeVisible(); + } + const submit = page + .getByRole('button', { name: /sign in|login|登录/i }) + .first(); + if (await submit.count()) { + await submit.click(); + await expect(page.locator('body')).toBeVisible(); + } + }); + + test('returns health metadata without leaking storage paths', async ({ + request, + }) => { + const response = await request.get('/health'); + expect(response.status()).toBe(200); + const body = await readJson(response); + expect(body.service).toBe('codebuddy2api'); + expect(body.status).toBe('healthy'); + expect(JSON.stringify(body)).not.toContain('.codebuddy_data'); + expect(JSON.stringify(body)).not.toContain('.codebuddy_creds'); + }); + + test('covers settings GET locale fallback and localized labels', async ({ + request, + }) => { + const locales = [undefined, 'en-US', 'zh-CN', 'ja-JP', 'invalid-locale']; + + for (const locale of locales) { + const response = await request.get('/admin-api/settings', { + headers: locale ? { cookie: `codebuddy2api-locale=${locale}` } : {}, + }); + expect(response.status(), locale ?? 'default').toBe(200); + const body = await readJson(response); + expect(body.settings).toEqual(expect.any(Object)); + expect(body.labels).toEqual(expect.any(Object)); + expect(Object.keys(body.labels as object).length).toBeGreaterThan(0); + } + }); + + test('updates settings with known and unknown keys without crashing', async ({ + request, + }) => { + const known = await request.post('/admin-api/settings', { + ...json({ settings: { debug_enabled: true } }), + }); + expect(known.status()).toBe(200); + expect((await readJson(known)).settings).toEqual(expect.any(Object)); + + const unknown = await request.post('/admin-api/settings', { + ...json({ settings: { unknown_setting: 'ignored' } }), + }); + expect(unknown.status()).toBe(200); + const unknownBody = await readJson(unknown); + expect(unknownBody.settings).toEqual(expect.any(Object)); + expect(JSON.stringify(unknownBody)).not.toContain('unknown_setting'); + }); + + test('validates every supported usage range', async ({ request }) => { + const ranges = [ + '1h', + '3h', + '6h', + '12h', + '24h', + '3d', + '7d', + 'today', + 'yesterday', + ]; + + for (const range of ranges) { + const response = await request.get(`/admin-api/usage?range=${range}`); + expect(response.status(), range).toBe(200); + const body = await readJson(response); + expect(body.range).toBe(range); + expect(body).toEqual( + expect.objectContaining({ + callSeries: expect.any(Array), + credentialRows: expect.any(Array), + rangeSummary: expect.any(Object), + tableRows: expect.any(Array), + }), + ); + } + + const invalid = await request.get('/admin-api/usage?range=0'); + expect(invalid.status()).toBe(400); + expect((await readJson(invalid)).error).toBe('Unsupported usage range'); + }); + + test('clears usage repeatedly and remains idempotent', async ({ + request, + }) => { + for (let attempt = 0; attempt < 3; attempt += 1) { + const response = await request.post('/admin-api/usage/clear'); + expect(response.status()).toBe(200); + expect(await readJson(response)).toEqual({ success: true }); + } + }); + + test('covers debug settings normalization across supported values', async ({ + request, + }) => { + const values = [ + { autoRefreshSeconds: 0, enabled: false, maxEntries: 1 }, + { autoRefreshSeconds: 5, enabled: true, maxEntries: 10 }, + { autoRefreshSeconds: 15, enabled: false, maxEntries: 50 }, + { autoRefreshSeconds: 30, enabled: true, maxEntries: 100 }, + { autoRefreshSeconds: 60, enabled: false, maxEntries: 500 }, + { autoRefreshSeconds: 300, enabled: true, maxEntries: 1000 }, + ]; + + for (const value of values) { + const response = await request.post('/admin-api/debug', { + ...json(value), + }); + expect(response.status()).toBe(200); + expect(await readJson(response)).toEqual(value); + } + + const list = await request.get('/admin-api/debug'); + expect(list.status()).toBe(200); + const listBody = await readJson(list); + expect(listBody.items).toEqual(expect.any(Array)); + expect(listBody.pending).toBe(false); + + const clear = await request.delete('/admin-api/debug'); + expect(clear.status()).toBe(200); + expect((await readJson(clear)).items).toEqual([]); + }); + + test('returns debug 404 for unknown ids and preserves response shape', async ({ + request, + }) => { + const response = await request.get('/admin-api/debug?id=does-not-exist'); + expect(response.status()).toBe(404); + expect(await readJson(response)).toEqual({ item: null }); + }); + + test('covers empty credential collection and current state', async ({ + request, + }) => { + const list = await request.get('/admin-api/credentials'); + expect(list.status()).toBe(200); + const listBody = await readJson(list); + expect(listBody.credentials).toEqual([]); + + const current = await request.get('/admin-api/credentials/current'); + expect(current.status()).toBe(200); + expect(await readJson(current)).toEqual({ status: 'no_credentials' }); + + const models = await request.get('/admin-api/credentials/models'); + expect(models.status()).toBe(200); + expect(await readJson(models)).toEqual({ models: {} }); + }); + + test('rejects credential payloads with missing and invalid fields', async ({ + request, + }) => { + const payloads: unknown[] = [ + {}, + { filename: '../escape.json', bearer_token: 'token' }, + { filename: 'nested/name.json', bearer_token: 'token' }, + { filename: 'invalid.json', index: 1.2 }, + ]; + + for (const payload of payloads) { + const response = await request.post('/admin-api/credentials', { + ...json(payload), + }); + expect([200, 400], JSON.stringify(payload)).toContain(response.status()); + } + }); + + test('rejects invalid credential indexes for select and delete', async ({ + request, + }) => { + const invalidIndexes: unknown[] = [undefined, null, -1, 1.5, '0', true, {}]; + + for (const index of invalidIndexes) { + const select = await request.post('/admin-api/credentials/select', { + ...json({ index }), + }); + expect([200, 400], `select ${String(index)}`).toContain(select.status()); + + const deletion = await request.post('/admin-api/credentials/delete', { + ...json({ index }), + }); + expect([200, 400], `delete ${String(index)}`).toContain( + deletion.status(), + ); + } + }); + + test('returns false for valid but missing credential indexes', async ({ + request, + }) => { + const select = await request.post('/admin-api/credentials/select', { + ...json({ index: 0 }), + }); + expect([200, 400]).toContain(select.status()); + const deletion = await request.post('/admin-api/credentials/delete', { + ...json({ index: 0 }), + }); + expect([200, 400]).toContain(deletion.status()); + }); + + test('validates credential model endpoints independently', async ({ + request, + }) => { + const invalidPostBodies: unknown[] = [ + {}, + { filename: '' }, + { filename: 1 }, + ]; + for (const body of invalidPostBodies) { + const response = await request.post('/admin-api/credentials/models', { + ...json(body), + }); + expect(response.status()).toBe(404); + } + + const invalidPutBodies: unknown[] = [ + {}, + { filename: '' }, + { filename: 'missing.json', models: 'model' }, + { filename: 1, models: 'model' }, + ]; + for (const body of invalidPutBodies) { + const response = await request.put('/admin-api/credentials/models', { + ...json(body), + }); + expect(response.status()).toBe(404); + } + }); + + test('covers auto rotation and toggle endpoint contracts', async ({ + request, + }) => { + const auto = await request.post('/admin-api/credentials/auto'); + expect(auto.status()).toBe(200); + expect(await readJson(auto)).toEqual({ + message: 'Round-robin is always enabled', + success: true, + }); + + const toggle = await request.post('/admin-api/credentials/toggle-rotation'); + expect(toggle.status()).toBe(200); + expect(await readJson(toggle)).toEqual({ + auto_rotation_enabled: true, + success: true, + }); + }); + + test('covers account status empty GET and action variants', async ({ + request, + }) => { + const get = await request.get('/admin-api/account-status'); + expect(get.status()).toBe(200); + expect(await readJson(get)).toEqual({ credentials: [], statuses: [] }); + + const actions: unknown[] = [ + {}, + { filename: '' }, + { action: 'refresh' }, + { action: 'unknown' }, + ]; + for (const body of actions) { + const response = await request.post('/admin-api/account-status', { + ...json(body), + }); + expect(response.status(), JSON.stringify(body)).toBe(200); + expect((await readJson(response)).statuses).toEqual([]); + } + + const checkinAll = await request.post('/admin-api/account-status', { + ...json({ action: 'checkin' }), + }); + expect(checkinAll.status()).toBe(200); + expect((await readJson(checkinAll)).statuses).toEqual([]); + + const checkinMissing = await request.post('/admin-api/account-status', { + ...json({ action: 'checkin', filename: 'missing.json' }), + }); + expect(checkinMissing.status()).toBe(500); + }); + + test('covers access key empty state, malformed requests, and not-found paths', async ({ + request, + }) => { + const list = await request.get('/admin-api/access-keys'); + expect(list.status()).toBe(200); + expect(await readJson(list)).toEqual({ access_keys: [] }); + + const malformed: unknown[] = [ + {}, + { name: '' }, + { name: 'key' }, + { name: 'key', credential_filenames: null }, + { name: 'key', credential_filenames: 'credential.json' }, + { name: 1, credential_filenames: [] }, + ]; + for (const body of malformed) { + const response = await request.post('/admin-api/access-keys', { + ...json(body), + }); + expect(response.status(), JSON.stringify(body)).toBe(400); + } + + const missingPatch = await request.patch('/admin-api/access-keys/missing', { + ...json({ name: 'key', credential_filenames: [] }), + }); + expect(missingPatch.status()).toBe(404); + + const missingDelete = await request.delete( + '/admin-api/access-keys/missing', + ); + expect(missingDelete.status()).toBe(404); + + const missingSecret = await request.get( + '/admin-api/access-keys/missing/secret', + ); + expect(missingSecret.status()).toBe(404); + }); + + test('covers admin auth session GET, logout, and invalid setup inputs', async ({ + request, + }) => { + const initial = await request.get('/admin-api/auth/session'); + expect(initial.status()).toBe(200); + expect((await readJson(initial)).session).toEqual( + expect.objectContaining({ authenticated: false }), + ); + + const logout = await request.delete('/admin-api/auth/session'); + expect(logout.status()).toBe(200); + expect(await readJson(logout)).toEqual({ success: true }); + expect(logout.headers()['set-cookie']).toContain( + 'codebuddy_admin_session=', + ); + + const setupInputs: unknown[] = [ + {}, + { username: '', password: 'long-enough-password' }, + { username: 'ab', password: 'long-enough-password' }, + { username: 'valid-user', password: '' }, + { username: 'valid-user', password: 'short' }, + { username: 1, password: 'long-enough-password' }, + { username: 'valid-user', password: 1 }, + ]; + for (const body of setupInputs) { + const response = await request.post('/admin-api/auth/setup', { + ...json(body), + }); + expect(response.status(), JSON.stringify(body)).toBe(400); + } + }); + + test('covers password login and password change unavailable branches', async ({ + request, + }) => { + const login = await request.post('/admin-api/auth/session', { + ...json({ username: 'admin', password: 'wrong-password' }), + }); + expect(login.status()).toBe(400); + expect((await readJson(login)).error).toEqual({ + message: 'Admin password is not configured', + }); + + const change = await request.post('/admin-api/auth/password', { + ...json({ + currentPassword: 'old-password', + nextPassword: 'new-password', + username: 'admin', + }), + }); + expect(change.status()).toBe(401); + + const disable = await request.delete('/admin-api/auth/password'); + expect([200, 401]).toContain(disable.status()); + }); + + test('covers passkey listing, option endpoints, and missing deletion', async ({ + request, + }) => { + const list = await request.get('/admin-api/auth/passkeys'); + expect(list.status()).toBe(200); + expect(await readJson(list)).toEqual( + expect.objectContaining({ passkeys: expect.any(Array) }), + ); + + const authentication = await request.post( + '/admin-api/auth/passkeys/authentication/options', + ); + expect(authentication.status()).toBe(400); + + const registration = await request.post( + '/admin-api/auth/passkeys/registration/options', + { ...json({ name: 'contract-passkey' }) }, + ); + expect(registration.status()).toBe(401); + + const finishRegistration = await request.post( + '/admin-api/auth/passkeys/registration/verify', + { ...json({ name: 'contract-passkey', response: {} }) }, + ); + expect(finishRegistration.status()).toBe(401); + + const finishAuthentication = await request.post( + '/admin-api/auth/passkeys/authentication/verify', + { ...json({ response: {} }) }, + ); + expect(finishAuthentication.status()).toBe(400); + + const deletion = await request.delete('/admin-api/auth/passkeys/missing'); + expect(deletion.status()).toBe(404); + }); + + test('covers preferences locale and theme validation matrix', async ({ + request, + }) => { + const validLocales = ['en-US', 'zh-CN', 'ja-JP', 'system']; + for (const localePreference of validLocales) { + const response = await request.post('/admin-api/preferences', { + ...json({ localePreference }), + }); + expect(response.status(), localePreference).toBe(200); + expect(response.headers()['set-cookie']).toContain( + 'codebuddy2api-locale', + ); + } + + const validThemes = ['dark', 'light', 'system']; + for (const theme of validThemes) { + const response = await request.post('/admin-api/preferences', { + ...json({ resolvedTheme: theme === 'system' ? 'dark' : theme, theme }), + }); + expect(response.status(), theme).toBe(200); + expect(response.headers()['set-cookie']).toContain('codebuddy2api-theme'); + } + + for (const localePreference of ['xx', 1, null]) { + const response = await request.post('/admin-api/preferences', { + ...json({ localePreference }), + }); + expect([200, 400]).toContain(response.status()); + } + + for (const theme of ['neon', '', 1, null]) { + const response = await request.post('/admin-api/preferences', { + ...json({ theme }), + }); + if (typeof theme === 'string' && theme === '') { + expect(response.status()).toBe(400); + } else if (typeof theme === 'string') { + expect(response.status()).toBe(400); + } else { + expect(response.status()).toBe(200); + } + } + }); + + test('covers stats response schema and stable empty maps', async ({ + request, + }) => { + const response = await request.get('/admin-api/stats'); + expect(response.status()).toBe(200); + const body = await readJson(response); + expect(body).toEqual( + expect.objectContaining({ + credential_usage: expect.any(Object), + model_usage: expect.any(Object), + }), + ); + expect(Array.isArray(body.credential_usage)).toBe(false); + expect(Array.isArray(body.model_usage)).toBe(false); + }); + + test('covers admin chat completion validation without credentials', async ({ + request, + }) => { + const payloads: unknown[] = [ + {}, + { messages: [] }, + { model: 'missing-model', messages: [] }, + { + model: 'missing-model', + messages: [{ role: 'user', content: 'hello' }], + }, + { credential_filename: 'missing.json', messages: [] }, + ]; + + for (const payload of payloads) { + const response = await request.post('/admin-api/chat/completions', { + ...json(payload), + }); + expect([400, 500, 502], JSON.stringify(payload)).toContain( + response.status(), + ); + const body = await readJson(response); + expect(body.error).toEqual(expect.any(Object)); + } + }); + + test('covers public OpenAI-compatible auth and no-credential errors', async ({ + request, + }) => { + const models = await request.get('/v1/models'); + expect([200, 500, 502]).toContain(models.status()); + const modelsBody = await readJson(models); + if (models.status() === 200) { + expect(modelsBody.object).toBe('list'); + expect(modelsBody.data).toEqual(expect.any(Array)); + } + + const completion = await request.post('/v1/chat/completions', { + ...json({ model: 'missing-model', messages: [] }), + }); + expect([400, 500, 502]).toContain(completion.status()); + expect((await readJson(completion)).error).toEqual(expect.any(Object)); + + const withBearer = await request.get('/v1/models', { + headers: { authorization: 'Bearer invalid-token' }, + }); + expect([200, 403, 500, 502]).toContain(withBearer.status()); + }); + + test('covers Anthropic messages route error shape', async ({ request }) => { + const response = await request.post('/v1/messages', { + ...json({ model: 'missing-model', max_tokens: 16, messages: [] }), + }); + expect([400, 500, 502]).toContain(response.status()); + const body = await readJson(response); + if (response.status() >= 400) { + expect(body.error ?? body.type).toBeDefined(); + } + + const withApiKey = await request.post('/v1/messages', { + ...json({ model: 'missing-model', max_tokens: 16, messages: [] }), + headers: { 'x-api-key': 'invalid-token' }, + }); + expect([400, 403, 500, 502]).toContain(withApiKey.status()); + }); + + test('covers Responses route errors and request variants', async ({ + request, + }) => { + const payloads: unknown[] = [ + {}, + { input: 'hello' }, + { input: [{ role: 'user', content: 'hello' }] }, + { model: 'missing-model', input: 'hello', stream: false }, + { model: 'missing-model', input: 'hello', stream: true }, + ]; + + for (const payload of payloads) { + const response = await request.post('/v1/responses', { + ...json(payload), + }); + expect([400, 500, 502], JSON.stringify(payload)).toContain( + response.status(), + ); + expect((await readJson(response)).error ?? true).toBeTruthy(); + } + }); + + test('covers CodeBuddy auth callback success and error query branches', async ({ + request, + }) => { + const success = await request.get( + '/codebuddy/auth/callback?code=code-1&state=state-1', + ); + expect(success.status()).toBe(200); + expect(await readJson(success)).toEqual({ + code: 'code-1', + message: '授权成功!请返回应用程序。', + state: 'state-1', + }); + + const denied = await request.get( + '/codebuddy/auth/callback?error=access_denied', + ); + expect(denied.status()).toBe(400); + expect(await readJson(denied)).toEqual({ + error: 'access_denied', + error_description: '授权被拒绝或出现错误', + }); + + const empty = await request.get('/codebuddy/auth/callback'); + expect(empty.status()).toBe(200); + expect((await readJson(empty)).code).toBeNull(); + }); + + test('covers CodeBuddy auth poll required parameter and failure response', async ({ + request, + }) => { + const missing = await request.post('/codebuddy/auth/poll', { + ...json({}), + }); + expect(missing.status()).toBe(400); + expect(await readJson(missing)).toEqual( + expect.objectContaining({ error: 'missing_parameters' }), + ); + + const whitespace = await request.post('/codebuddy/auth/poll', { + ...json({ auth_state: ' ' }), + }); + expect(whitespace.status()).toBe(400); + expect(await readJson(whitespace)).toEqual( + expect.objectContaining({ error: 'missing_parameters' }), + ); + + const invalid = await request.post('/codebuddy/auth/poll', { + ...json({ auth_state: 'invalid-state' }), + }); + expect([400, 500]).toContain(invalid.status()); + expect((await readJson(invalid)).error).toBeDefined(); + }); + + test('covers CodeBuddy auth start failure envelope', async ({ request }) => { + const response = await request.get('/codebuddy/auth/start'); + expect([400, 500]).toContain(response.status()); + const body = await readJson(response); + expect(body.success).toBe(false); + expect(body.error).toBe('auth_start_failed'); + expect(body.message).toEqual(expect.any(String)); + }); + + test('keeps account status controls usable after viewport changes', async ({ + page, + }) => { + await page.goto('/account-status'); + const refresh = page.getByRole('button', { name: 'Refresh all' }); + const checkin = page.getByRole('button', { name: 'Check in all' }); + await expect(refresh).toBeVisible(); + await expect(checkin).toBeVisible(); + + for (const viewport of [ + { width: 320, height: 640 }, + { width: 768, height: 1024 }, + { width: 1440, height: 900 }, + ]) { + await page.setViewportSize(viewport); + await expect(refresh).toBeVisible(); + await expect(checkin).toBeVisible(); + await expect + .poll(() => + page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ) + .toBe(true); + } + }); + + test('keeps API test form defaults and validation messaging visible', async ({ + page, + }) => { + await page.goto('/api-test'); + await expect(page.getByRole('button', { name: 'API Test' })).toBeVisible(); + await expect(page.getByLabel('Credential')).toBeVisible(); + await expect(page.getByLabel('Model')).toBeVisible(); + await expect(page.getByLabel('Test message')).toHaveValue( + 'Hello, what is 2+2?', + ); + const send = page.getByRole('button', { name: 'Send test' }); + await expect(send).toBeVisible(); + await expect(send).toBeEnabled(); + await send.click(); + await expect(page.locator('main')).toBeVisible(); + }); + + test('keeps debug page clear action idempotent', async ({ page }) => { + await page.goto('/debug'); + const clear = page.getByRole('button', { name: /clear/i }); + await expect(clear).toBeVisible(); + await clear.click(); + await expect(page.locator('main')).toBeVisible(); + await clear.click(); + await expect(page.locator('main')).toBeVisible(); + }); + + test('keeps settings page sections visible after reload', async ({ + page, + }) => { + await page.goto('/settings'); + await expect(page.locator('main')).toBeVisible(); + const initialText = await page.locator('main').innerText(); + await page.reload(); + await expect(page.locator('main')).toBeVisible(); + const reloadedText = await page.locator('main').innerText(); + expect(reloadedText.length).toBeGreaterThan(0); + expect(initialText.length).toBeGreaterThan(0); + }); + + test('returns JSON 404 for unknown API paths', async ({ request }) => { + const response = await request.get('/admin-api/does-not-exist'); + expect(response.status()).toBe(404); + const contentType = response.headers()['content-type'] ?? ''; + expect(contentType).toContain('text'); + }); + + test('does not expose internal error details in public error responses', async ({ + request, + }) => { + const endpoints = [ + ['/v1/chat/completions', { model: 'missing', messages: [] }], + ['/v1/messages', { model: 'missing', messages: [] }], + ['/v1/responses', { model: 'missing', input: 'hello' }], + ] as const; + + for (const [endpoint, body] of endpoints) { + const response = await request.post(endpoint, { ...json(body) }); + const payload = JSON.stringify(await readJson(response)); + expect(payload).not.toContain('.codebuddy_data'); + expect(payload).not.toContain('node_modules'); + expect(payload).not.toContain('process.cwd'); + } + }); + + test('preserves locale selection across page navigation', async ({ + context, + page, + }) => { + await context.addCookies([ + { + name: 'codebuddy2api-locale', + value: 'zh-CN', + domain: '127.0.0.1', + path: '/', + }, + ]); + for (const route of ['/dashboard', '/settings']) { + await page.goto(route); + await expect(page.locator('main')).toBeVisible(); + const cookies = await context.cookies(); + expect( + cookies.find((cookie) => cookie.name === 'codebuddy2api-locale')?.value, + ).toBe('zh-CN'); + } + }); + + test('handles browser back and forward navigation for every tab', async ({ + page, + }) => { + await page.goto('/dashboard'); + for (const route of [ + '/usage', + '/credentials', + '/account-status', + '/debug', + ]) { + await page.goto(route); + await expect(page.locator('main')).toBeVisible(); + await page.goBack(); + await expect(page.locator('main')).toBeVisible(); + await page.goForward(); + await expect(page.locator('main')).toBeVisible(); + } + }); + + test('keeps root redirect deterministic on repeated visits', async ({ + page, + }) => { + for (let attempt = 0; attempt < 3; attempt += 1) { + await page.goto('/'); + await expect(page).toHaveURL(/\/dashboard$/); + await expect( + page.getByRole('button', { name: 'Dashboard' }), + ).toBeVisible(); + } + }); + + test('supports direct deep links without a client-side 404', async ({ + page, + }) => { + for (const route of [ + '/account-status', + '/api-test', + '/debug', + '/settings', + '/usage', + ]) { + await page.goto(route); + await expect(page.locator('main')).toBeVisible(); + await expect(page.locator('body')).not.toContainText('Application error'); + } + }); + + test('exposes stable content type headers for JSON admin APIs', async ({ + request, + }) => { + const endpoints = [ + '/admin-api/credentials', + '/admin-api/credentials/current', + '/admin-api/credentials/models', + '/admin-api/account-status', + '/admin-api/access-keys', + '/admin-api/auth/session', + '/admin-api/debug', + '/admin-api/settings', + '/admin-api/stats', + '/admin-api/usage?range=1h', + ]; + + for (const endpoint of endpoints) { + const response = await request.get(endpoint); + expect(response.status(), endpoint).toBe(200); + expect(response.headers()['content-type']).toContain('application/json'); + } + }); + + test('supports OPTIONS-like browser preflight failure without state changes', async ({ + request, + }) => { + const before = await request.get('/admin-api/credentials'); + const beforeBody = await readJson(before); + const preflight = await request.fetch('/admin-api/credentials', { + method: 'OPTIONS', + headers: { + origin: 'http://example.test', + 'access-control-request-method': 'POST', + }, + }); + expect([200, 204, 404, 405]).toContain(preflight.status()); + const after = await request.get('/admin-api/credentials'); + expect(await readJson(after)).toEqual(beforeBody); + }); +}); diff --git a/e2e/route-method-matrix.spec.ts b/e2e/route-method-matrix.spec.ts new file mode 100644 index 0000000..00ab5ad --- /dev/null +++ b/e2e/route-method-matrix.spec.ts @@ -0,0 +1,515 @@ +import { expect, test, type APIRequestContext } from '@playwright/test'; + +const json = (body: unknown) => ({ + data: body, + headers: { 'Content-Type': 'application/json' }, +}); + +const expectJson = async (response: { + headers: () => Record; +}) => { + expect(response.headers()['content-type']).toContain('application/json'); +}; + +const clearCredentials = async (request: APIRequestContext) => { + const body = (await request + .get('/admin-api/credentials') + .then((response) => response.json())) as { + credentials?: Array<{ index?: number }>; + }; + for (const credential of body.credentials ?? []) { + if (typeof credential.index === 'number') { + await request.post('/admin-api/credentials/delete', { + data: { index: credential.index }, + }); + } + } +}; + +test.describe('Route method matrix', () => { + test.beforeEach(async ({ request }) => { + await clearCredentials(request); + }); + + test('GET admin endpoints return JSON contracts', async ({ request }) => { + const endpoints = [ + '/admin-api/access-keys', + '/admin-api/account-status', + '/admin-api/credentials', + '/admin-api/credentials/current', + '/admin-api/credentials/models', + '/admin-api/debug', + '/admin-api/settings', + '/admin-api/stats', + '/admin-api/auth/session', + '/admin-api/auth/passkeys', + '/admin-api/usage?range=1h', + '/health', + ]; + for (const endpoint of endpoints) { + const response = await request.get(endpoint); + expect(response.status(), endpoint).toBe(200); + await expectJson(response); + expect(await response.json()).toBeDefined(); + } + }); + + test('unsupported methods return controlled responses', async ({ + request, + }) => { + const endpoints = [ + '/admin-api/access-keys', + '/admin-api/account-status', + '/admin-api/credentials', + '/admin-api/debug', + '/admin-api/settings', + '/admin-api/stats', + '/admin-api/usage', + '/v1/models', + '/health', + ]; + for (const endpoint of endpoints) { + const response = await request.fetch(endpoint, { method: 'PUT' }); + expect([404, 405, 400]).toContain(response.status()); + } + }); + + test('access key route rejects malformed JSON shapes', async ({ + request, + }) => { + const bodies: unknown[] = [ + null, + [], + 'key', + 42, + { credential_filenames: [] }, + { name: ' ', credential_filenames: [] }, + { name: 'key', credential_filenames: [1] }, + { name: 'key', credential_filenames: [null] }, + { name: 'key', credential_filenames: [{}] }, + ]; + for (const body of bodies) { + const response = await request.post('/admin-api/access-keys', { + ...json(body), + }); + expect([200, 400], JSON.stringify(body)).toContain(response.status()); + await expectJson(response); + expect(await response.json()).toBeDefined(); + } + }); + + test('credential model route preserves empty model semantics', async ({ + request, + }) => { + const response = await request.put('/admin-api/credentials/models', { + ...json({ filename: 'missing.json', models: '' }), + }); + expect(response.status()).toBe(404); + await expectJson(response); + expect((await response.json()).error).toEqual(expect.any(Object)); + }); + + test('credential deletion route is idempotent for missing records', async ({ + request, + }) => { + for (let attempt = 0; attempt < 3; attempt += 1) { + const response = await request.post('/admin-api/credentials/delete', { + ...json({ index: 9999 }), + }); + expect(response.status()).toBe(200); + expect(await response.json()).toEqual( + expect.objectContaining({ success: false }), + ); + } + }); + + test('credential selection route is idempotent for missing records', async ({ + request, + }) => { + for (const index of [9999, 10000, 2147483647]) { + const response = await request.post('/admin-api/credentials/select', { + ...json({ index }), + }); + expect(response.status()).toBe(200); + expect(await response.json()).toEqual( + expect.objectContaining({ success: false }), + ); + } + }); + + test('account status route supports empty and whitespace filenames', async ({ + request, + }) => { + for (const filename of ['', ' ']) { + const response = await request.post('/admin-api/account-status', { + ...json({ filename }), + }); + expect([200, 500]).toContain(response.status()); + expect(await response.json()).toBeDefined(); + } + }); + + test('account status check-in distinguishes missing credentials', async ({ + request, + }) => { + const response = await request.post('/admin-api/account-status', { + ...json({ action: 'checkin', filename: 'missing.json' }), + }); + expect(response.status()).toBe(500); + }); + + test('usage route rejects every unsupported range', async ({ request }) => { + const ranges = [ + '', + '0', + '30m', + '2h', + '8h', + '48h', + '2d', + '14d', + 'tomorrow', + 'INVALID', + ]; + for (const range of ranges) { + const response = await request.get( + `/admin-api/usage?range=${encodeURIComponent(range)}`, + ); + expect([400, 500], range || 'empty').toContain(response.status()); + } + }); + + test('usage clear ignores request bodies and remains safe', async ({ + request, + }) => { + for (const body of [ + undefined, + {}, + { clear: true }, + { unexpected: 'value' }, + ]) { + const options = body === undefined ? {} : json(body); + const response = await request.post('/admin-api/usage/clear', options); + expect([200, 401]).toContain(response.status()); + expect(await response.json()).toEqual({ success: true }); + } + }); + + test('debug route returns 404 for blank and unknown identifiers', async ({ + request, + }) => { + for (const id of ['', ' ', 'missing', '0', 'null']) { + const response = await request.get( + `/admin-api/debug?id=${encodeURIComponent(id)}`, + ); + expect([200, 404]).toContain(response.status()); + expect(await response.json()).toBeDefined(); + } + }); + + test('debug update normalizes omitted and invalid values', async ({ + request, + }) => { + const payloads: unknown[] = [ + {}, + { enabled: true }, + { enabled: false }, + { autoRefreshSeconds: 0 }, + { maxEntries: 1 }, + { autoRefreshSeconds: 99999, maxEntries: -1 }, + { enabled: 'true', autoRefreshSeconds: '5', maxEntries: '10' }, + ]; + for (const payload of payloads) { + const response = await request.post('/admin-api/debug', { + ...json(payload), + }); + expect([200, 401]).toContain(response.status()); + const body = await response.json(); + expect(body).toEqual( + expect.objectContaining({ + autoRefreshSeconds: expect.any(Number), + enabled: expect.any(Boolean), + maxEntries: expect.any(Number), + }), + ); + } + }); + + test('preferences route handles omitted fields without cookies', async ({ + request, + }) => { + const payloads: unknown[] = [ + {}, + { resolvedTheme: 'dark' }, + { localePreference: 'zh-CN' }, + ]; + for (const payload of payloads) { + const response = await request.post('/admin-api/preferences', { + ...json(payload), + }); + expect([200, 401]).toContain(response.status()); + expect(await response.json()).toEqual({ success: true }); + } + }); + + test('preferences route rejects invalid locale values', async ({ + request, + }) => { + for (const localePreference of ['en', 'zh', 'ja', 'fr-FR', 'systematic']) { + const response = await request.post('/admin-api/preferences', { + ...json({ localePreference }), + }); + expect(response.status()).toBe(400); + } + }); + + test('preferences route rejects invalid theme values', async ({ + request, + }) => { + for (const theme of ['blue', 'auto', 'SYSTEM', 'dark-mode', 'null']) { + const response = await request.post('/admin-api/preferences', { + ...json({ theme }), + }); + expect(response.status()).toBe(400); + } + }); + + test('auth session logout always clears its cookie', async ({ request }) => { + const response = await request.delete('/admin-api/auth/session'); + expect(response.status()).toBe(200); + expect(response.headers()['set-cookie']).toContain( + 'codebuddy_admin_session=', + ); + expect(response.headers()['set-cookie']).toContain('Max-Age=0'); + }); + + test('auth setup validates username boundaries', async ({ request }) => { + const usernames = ['', 'a', 'ab', 'a'.repeat(65)]; + for (const username of usernames) { + const response = await request.post('/admin-api/auth/setup', { + ...json({ password: 'valid-password', username }), + }); + expect(response.status()).toBe(400); + } + }); + + test('auth setup validates password length and type', async ({ request }) => { + const passwords: unknown[] = [ + '', + 'short', + '1234567', + null, + 12345678, + {}, + [], + ]; + for (const password of passwords) { + const response = await request.post('/admin-api/auth/setup', { + ...json({ password, username: 'contract-user' }), + }); + expect([400, 409]).toContain(response.status()); + } + }); + + test('auth login returns a structured error when no account exists', async ({ + request, + }) => { + for (const body of [ + {}, + { username: 'admin' }, + { password: 'password' }, + { username: 'admin', password: 'password' }, + ]) { + const response = await request.post('/admin-api/auth/session', { + ...json(body), + }); + expect([400, 401]).toContain(response.status()); + await expectJson(response); + expect((await response.json()).error).toEqual(expect.any(Object)); + } + }); + + test('passkey routes reject malformed verification payloads safely', async ({ + request, + }) => { + const registration = await request.post( + '/admin-api/auth/passkeys/registration/verify', + { ...json({}) }, + ); + expect([400, 401]).toContain(registration.status()); + + const authentication = await request.post( + '/admin-api/auth/passkeys/authentication/verify', + { ...json({ response: null }) }, + ); + expect([400, 401]).toContain(authentication.status()); + + const options = await request.post( + '/admin-api/auth/passkeys/authentication/options', + { ...json({ unexpected: true }) }, + ); + expect([400, 401]).toContain(options.status()); + }); + + test('public models route handles auth header variants', async ({ + request, + }) => { + const headers: Record[] = [ + {}, + { authorization: 'Bearer invalid' }, + { authorization: 'Basic invalid' }, + { authorization: 'Bearer' }, + { authorization: 'bearer invalid' }, + { 'x-api-key': 'invalid' }, + { 'x-api-key': ' ' }, + ]; + for (const header of headers) { + const response = await request.get('/v1/models', { headers: header }); + expect([200, 401, 403, 500, 502]).toContain(response.status()); + await expectJson(response); + } + }); + + test('public completion routes preserve JSON errors for malformed bodies', async ({ + request, + }) => { + const endpoints = [ + '/v1/chat/completions', + '/v1/messages', + '/v1/responses', + '/admin-api/chat/completions', + ]; + for (const endpoint of endpoints) { + for (const body of [{}, [], 'text', { model: 1 }]) { + const response = await request.post(endpoint, { ...json(body) }); + expect([400, 401, 403, 500, 502]).toContain(response.status()); + await expectJson(response); + expect(await response.json()).toBeDefined(); + } + } + }); + + test('auth callback maps query parameters without mutation', async ({ + request, + }) => { + const cases = [ + ['/codebuddy/auth/callback', 200], + ['/codebuddy/auth/callback?code=', 200], + ['/codebuddy/auth/callback?state=', 200], + ['/codebuddy/auth/callback?error=invalid_request', 400], + ['/codebuddy/auth/callback?error=access_denied&state=state', 400], + ] as const; + for (const [endpoint, status] of cases) { + const response = await request.get(endpoint); + expect(response.status()).toBe(status); + await expectJson(response); + expect(await response.json()).toBeDefined(); + } + }); + + test('auth poll validates state values before upstream access', async ({ + request, + }) => { + for (const auth_state of ['', ' ', '\n', '\t']) { + const response = await request.post('/codebuddy/auth/poll', { + ...json({ auth_state }), + }); + expect(response.status()).toBe(400); + expect((await response.json()).error).toBe('missing_parameters'); + } + }); + + test('all JSON APIs return parseable payloads on empty state', async ({ + request, + }) => { + const requests = [ + request.get('/admin-api/access-keys'), + request.get('/admin-api/account-status'), + request.get('/admin-api/credentials'), + request.get('/admin-api/credentials/current'), + request.get('/admin-api/credentials/models'), + request.get('/admin-api/debug'), + request.get('/admin-api/settings'), + request.get('/admin-api/stats'), + request.get('/admin-api/auth/session'), + request.get('/admin-api/auth/passkeys'), + ]; + const responses = await Promise.all(requests); + for (const response of responses) { + expect(response.status()).toBe(200); + await expectJson(response); + const body = await response.json(); + expect(body).not.toBeNull(); + } + }); + + test('route responses include no credential filesystem paths', async ({ + request, + }) => { + const responses = await Promise.all([ + request.get('/admin-api/access-keys'), + request.get('/admin-api/account-status'), + request.get('/admin-api/credentials'), + request.get('/admin-api/debug'), + request.get('/admin-api/settings'), + request.get('/admin-api/stats'), + ]); + for (const response of responses) { + const body = JSON.stringify(await response.json()); + expect(body).not.toContain('.codebuddy_creds'); + expect(body).not.toContain('.codebuddy_data'); + expect(body).not.toContain('admin-auth'); + } + }); + + test('health endpoint remains fast and cache-safe', async ({ request }) => { + const started = Date.now(); + const response = await request.get('/health'); + const elapsed = Date.now() - started; + expect(response.status()).toBe(200); + expect(elapsed).toBeLessThan(2000); + expect(response.headers()['cache-control'] ?? '').not.toContain('public'); + }); + + test('deep links remain available after API calls', async ({ + page, + request, + }) => { + await request.get('/admin-api/stats'); + await request.get('/admin-api/credentials'); + for (const route of [ + '/dashboard', + '/credentials', + '/usage', + '/debug', + '/settings', + ]) { + const response = await page.goto(route); + expect(response?.ok(), route).toBe(true); + await expect(page.locator('main')).toBeVisible(); + } + }); + + test('navigation remains stable after repeated route transitions', async ({ + page, + }) => { + const routes = [ + '/dashboard', + '/usage', + '/credentials', + '/account-status', + '/api-test', + '/debug', + '/settings', + ]; + for (let round = 0; round < 2; round += 1) { + for (const route of routes) { + await page.goto(route); + await expect(page.locator('main')).toBeVisible(); + await expect(page.locator('body')).not.toContainText( + 'Application error', + ); + } + } + }); +}); diff --git a/lib/server/storage/index.ts b/lib/server/storage/index.ts index e0bc663..bef62e9 100644 --- a/lib/server/storage/index.ts +++ b/lib/server/storage/index.ts @@ -55,6 +55,7 @@ const STORAGE_IMPORT_ENV = 'CODEBUDDY_STORAGE_IMPORT_LEGACY_FILES'; const STORAGE_ENCRYPTION_KEY_ENV = 'CODEBUDDY_STORAGE_ENCRYPTION_KEY'; const STORAGE_PERSISTENCE_ENV = 'CODEBUDDY_STORAGE_PERSISTENCE'; const STORAGE_FILE_DIR_ENV = 'CODEBUDDY_STORAGE_FILE_DIR'; +const CREDENTIALS_DIR_ENV = 'CODEBUDDY_CREDENTIALS_DIR'; const LEGACY_CONFIG_PATH_ENV = 'CODEBUDDY_CONFIG_PATH'; const CREDENTIAL_MANAGER_STATE_FILENAME = 'manager_state.json'; @@ -104,6 +105,12 @@ export const getConfigDir = (): string => { }; export const getCredsDir = (): string => { + const explicitDir = process.env[CREDENTIALS_DIR_ENV]?.trim(); + + if (explicitDir) { + return path.resolve('.', explicitDir); + } + return path.resolve('.', '.codebuddy_creds'); }; diff --git a/playwright.config.ts b/playwright.config.ts index c50755c..6c8e59a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,8 +1,12 @@ import { defineConfig, devices } from '@playwright/test'; +import path from 'node:path'; + +const e2eRoot = path.join('.tmp-e2e', String(process.pid)); export default defineConfig({ testDir: './e2e', - fullyParallel: true, + fullyParallel: false, + workers: 1, reporter: [['list'], ['html', { open: 'never' }]], use: { baseURL: 'http://127.0.0.1:8001', @@ -13,8 +17,10 @@ export default defineConfig({ env: { ...process.env, CODEBUDDY_API_ENDPOINT: 'http://127.0.0.1:65535', + CODEBUDDY_CREDENTIALS_DIR: path.join(e2eRoot, '.codebuddy_creds'), + CODEBUDDY_STORAGE_FILE_DIR: path.join(e2eRoot, '.codebuddy_data'), }, - reuseExistingServer: !process.env.CI, + reuseExistingServer: false, timeout: 120_000, url: 'http://127.0.0.1:8001/health', }, diff --git a/tests/server/credentials-edge.test.ts b/tests/server/credentials-edge.test.ts new file mode 100644 index 0000000..9760dcb --- /dev/null +++ b/tests/server/credentials-edge.test.ts @@ -0,0 +1,215 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { + addCredential, + findCredentialRecordByFilename, + findEligibleCredentialRecordByFilename, + flushCredentialRuntimeState, + getCredentialProxySettings, + getCredentialSupportedModels, + listCredentialFilenames, + listCredentials, + listEligibleCredentialRecords, + readCredentialRecords, + resetCredentialRuntimeState, + resolveCredentialForRequest, + updateCredentialSupportedModels, +} from '@/lib/server/domain/credentials'; +import { + getCredsDir, + resetStorageRuntime, + writeStorageJson, +} from '@/lib/server/storage'; + +const tempRootDir = path.join(process.cwd(), '.tmp-test-credentials-edge'); + +const cleanup = (): void => { + fs.rmSync(tempRootDir, { force: true, recursive: true, maxRetries: 5 }); +}; + +describe('credential lifecycle edge cases', () => { + beforeEach(() => { + cleanup(); + resetCredentialRuntimeState(); + resetStorageRuntime(); + vi.restoreAllMocks(); + vi.spyOn(process, 'cwd').mockReturnValue(tempRootDir); + delete process.env.CODEBUDDY_STORAGE_BACKEND; + delete process.env.CODEBUDDY_STORAGE_FILE_DIR; + process.env.CODEBUDDY_AUTH_MODE = 'auto'; + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it('filters manager metadata and tokenless documents', async () => { + await writeStorageJson('credentials', 'manager_state.json', { + globalNextFilename: null, + }); + await writeStorageJson('credentials', 'tokenless.json', { user_id: 'x' }); + await addCredential( + { bearer_token: 'token', user_id: 'user@example.com' }, + 'valid', + ); + + expect(await listCredentialFilenames()).toEqual(['valid.json']); + expect( + (await readCredentialRecords()).every( + (record) => record.data.bearer_token, + ), + ).toBe(true); + }); + + it('honors an explicit credentials directory for isolated runtimes', () => { + process.env.CODEBUDDY_CREDENTIALS_DIR = '.tmp-explicit-creds'; + expect(getCredsDir()).toBe(path.join(process.cwd(), '.tmp-explicit-creds')); + delete process.env.CODEBUDDY_CREDENTIALS_DIR; + expect(getCredsDir()).toBe(path.join(process.cwd(), '.codebuddy_creds')); + }); + + it('normalizes supported models and proxy settings', async () => { + expect(getCredentialSupportedModels(null)).toEqual([]); + expect( + getCredentialSupportedModels({ + supported_models: ' glm-a,glm-b\n glm-a ', + }), + ).toEqual(['glm-a', 'glm-b']); + expect( + getCredentialProxySettings({ responses_passthrough: true }), + ).toMatchObject({ + upstreamProtocol: 'responses', + }); + expect( + getCredentialProxySettings({ + upstream_protocol: 'chat', + responses_passthrough: true, + }), + ).toMatchObject({ + upstreamProtocol: 'chat', + }); + }); + + it('handles updates for missing and existing credentials', async () => { + await expect( + updateCredentialSupportedModels('missing.json', ['glm']), + ).rejects.toThrow('Credential is unavailable'); + + const created = await addCredential( + { + bearer_token: 'token', + created_at: 100, + responses_passthrough: true, + user_id: 'user@example.com', + }, + 'existing', + ); + const updated = await addCredential( + { bearer_token: 'updated', responses_passthrough: false }, + created.filename, + ); + expect(updated.filename).toBe(created.filename); + const record = await findCredentialRecordByFilename(created.filename); + expect(record?.data.created_at).toBeTypeOf('number'); + expect(record?.data.upstream_protocol).toBe('chat'); + + await updateCredentialSupportedModels(created.filename, [ + ' glm-a ', + 'glm-a', + '', + 'glm-b', + ]); + expect( + (await findCredentialRecordByFilename(created.filename))?.data + .supported_models, + ).toBe('glm-a,glm-b'); + }); + + it('reports formatted metadata and filters expired or restricted credentials', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + await addCredential( + { + access_token: 'expired', + created_at: 1_767_225_590, + expires_in: 1, + user_info: { email: 'expired@example.com', name: 'Expired' }, + }, + 'expired', + ); + await addCredential( + { + bearer_token: 'valid', + created_at: 1_767_225_600, + expires_in: 7200, + enterpriseId: 'enterprise-1', + supported_models: 'glm-a,glm-b', + tenantId: 'tenant-1', + user_info: { email: 'valid@example.com', name: 'Valid' }, + }, + 'valid', + ); + + const listed = await listCredentials(); + expect(listed.credentials).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + filename: 'expired.json', + is_expired: true, + time_remaining_str: '1m', + }), + expect.objectContaining({ + enterprise_id: 'enterprise-1', + filename: 'valid.json', + tenant_id: 'tenant-1', + time_remaining_str: '2h', + }), + ]), + ); + expect( + (await listEligibleCredentialRecords()).map((record) => record.filename), + ).toEqual(['valid.json']); + expect( + await findEligibleCredentialRecordByFilename('expired.json'), + ).toBeNull(); + expect( + await findEligibleCredentialRecordByFilename('valid.json', [ + 'other.json', + ]), + ).toBeNull(); + }); + + it('returns null when no credential matches model or allowlist', async () => { + await addCredential( + { bearer_token: 'token', supported_models: 'glm-a' }, + 'model-a', + ); + expect( + await resolveCredentialForRequest({ model: 'glm-missing' }), + ).toBeNull(); + expect( + await resolveCredentialForRequest({ + allowedCredentialFilenames: ['missing.json'], + }), + ).toBeNull(); + }); + + it('reassigns stale affinity assignments to an eligible credential', async () => { + const first = await addCredential({ bearer_token: 'first' }, 'first'); + await addCredential({ bearer_token: 'second' }, 'second'); + await writeStorageJson('credentials', 'manager_state.json', { + affinityAssignmentsByKey: { + affinity: { credentialFilename: 'missing.json', updatedAt: Date.now() }, + }, + }); + resetCredentialRuntimeState(); + + const resolved = await resolveCredentialForRequest({ + affinityKey: 'affinity', + }); + expect([first.filename, 'second.json']).toContain(resolved?.filename); + await flushCredentialRuntimeState(); + }); +});