web: e2e — mocks réécrits d'après les vraies formes de réponse (enveloppes, /v1/me, clés, usage, limits, admin), vérification CSRF sur chaque mutation, specs auth/dashboard/admin mises à jour
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 changed files +380 −131
added
hfmarketdata/web/e2e/admin.spec.js
+134 −0
@@ -0,0 +1,134 @@ | ||
| 1 | +import { expect, test } from '@playwright/test' | |
| 2 | +import { ADMIN, USER, err, key, mockSession, ok, usageSeries } from './mocks.js' | |
| 3 | + | |
| 4 | +const users = () => [ | |
| 5 | + { ...USER, id: 1, keys_active: 1 }, | |
| 6 | + { ...USER, id: 3, email: 'bob@example.com', name: 'Bob', status: 'invited', email_verified: false, last_login_at: null, keys_active: 1, created_at: '2026-09-03T00:00:00Z' }, | |
| 7 | + { ...ADMIN, keys_active: 0 }, | |
| 8 | +] | |
| 9 | + | |
| 10 | +function adminHandler(state) { | |
| 11 | + return async (url, route, req) => { | |
| 12 | + const m = req.method() | |
| 13 | + if (url.pathname === '/v1/admin/users' && m === 'GET') { | |
| 14 | + const s = (url.searchParams.get('search') || '').toLowerCase() | |
| 15 | + const st = url.searchParams.get('status') | |
| 16 | + const list = state.users.filter(u => (!s || `${u.email} ${u.name}`.toLowerCase().includes(s)) && (!st || u.status === st)) | |
| 17 | + return ok(list, 200, { next_cursor: null, admins_active: state.users.filter(u => u.role === 'admin' && u.status === 'active').length }) | |
| 18 | + } | |
| 19 | + if (url.pathname === '/v1/admin/users' && m === 'POST') { | |
| 20 | + const b = req.postDataJSON() | |
| 21 | + const u = { ...USER, id: 4, email: b.email, name: b.name, tier: b.tier || 'free', status: 'invited', email_verified: false, last_login_at: null, keys_active: 1, created_at: new Date().toISOString() } | |
| 22 | + state.users.push(u) | |
| 23 | + return ok({ user: u, key: { id: 12, prefix: 'hfmd_live_NEWUSER1', name: 'default' }, invitation: { delivered: false, queued: false, link: 'https://www.hfmarketdata.io/accept-invite?token=INV-abc', delivery_error: 'no_provider' } }, 201) | |
| 24 | + } | |
| 25 | + const one = url.pathname.match(/^\/v1\/admin\/users\/(\d+)$/) | |
| 26 | + if (one && m === 'GET') { | |
| 27 | + const u = state.users.find(x => x.id === Number(one[1])) | |
| 28 | + return ok({ user: u, keys: [key({ id: 10 + u.id, name: 'default', prefix: 'hfmd_live_ab12cd34', principal: `key:${10 + u.id}` })], pending_invite_link: u.status === 'invited' ? 'https://www.hfmarketdata.io/accept-invite?token=PENDING-bob' : null, pending_reset_link: null, usage: usageSeries('7d') }) | |
| 29 | + } | |
| 30 | + if (one && m === 'PATCH') { | |
| 31 | + const b = req.postDataJSON() | |
| 32 | + const u = state.users.find(x => x.id === Number(one[1])) | |
| 33 | + if (u.role === 'admin' && (b.role === 'user' || b.status === 'disabled') && state.users.filter(x => x.role === 'admin' && x.status === 'active').length === 1) return err('LAST_ADMIN', 'last admin', 409) | |
| 34 | + state.patches.push([u.id, b]); Object.assign(u, b) | |
| 35 | + return ok(u) | |
| 36 | + } | |
| 37 | + if (one && m === 'DELETE') { const u = state.users.find(x => x.id === Number(one[1])); Object.assign(u, { status: 'deleted', email: `deleted-${u.id}@deleted.invalid`, name: '' }); state.deletes.push(u.id); return ok(u) } | |
| 38 | + const rk = url.pathname.match(/^\/v1\/admin\/users\/(\d+)\/keys\/(\d+)$/) | |
| 39 | + if (rk && m === 'DELETE') { state.revoked.push(Number(rk[2])); return ok(key({ id: Number(rk[2]), status: 'revoked' })) } | |
| 40 | + if (url.pathname === '/v1/admin/usage') { | |
| 41 | + state.usageQueries.push(url.search) | |
| 42 | + return ok({ per_day: [{ day: '2026-09-03', requests: 6000, rows: 3_000_000, rows_parquet: 0, status_429: 1, principals: 12 }, { day: '2026-09-04', requests: 6345, rows: 3_789_000, rows_parquet: 500, status_429: 2, principals: 14 }], | |
| 43 | + top: [{ principal: 'key:1', requests: 9000, rows: 5_000_000, status_429: 3, user: { id: 1, email: 'ada@example.com', tier: 'free', key_name: 'laptop', prefix: 'hfmd_live_ab12cd34' } }, { principal: 'ip:3f9a1c0b7d2e4f61', requests: 12, rows: 300, status_429: 0, user: null }], | |
| 44 | + totals: { requests: 12345, rows: 6_789_000, rows_parquet: 500, status_429: 3 } }) | |
| 45 | + } | |
| 46 | + if (url.pathname === '/v1/admin/audit') { | |
| 47 | + state.auditQueries.push(url.search) | |
| 48 | + const rows = [{ id: 91, ts: '2026-09-04T10:00:00Z', actor: 'user:2', action: 'user.update', target: 'user:1', meta: { tier: 'high_usage' } }, { id: 90, ts: '2026-09-04T09:00:00Z', actor: 'user:1', action: 'key.create', target: 'key:1', meta: null }] | |
| 49 | + const actor = url.searchParams.get('actor') | |
| 50 | + return ok(rows.filter(r => !actor || r.actor === actor), 200, { next_cursor: null }) | |
| 51 | + } | |
| 52 | + return null | |
| 53 | + } | |
| 54 | +} | |
| 55 | +const fresh = () => ({ user: ADMIN, users: users(), patches: [], deletes: [], revoked: [], usageQueries: [], auditQueries: [] }) | |
| 56 | + | |
| 57 | +test('admin is forbidden for plain users', async ({ page }) => { | |
| 58 | + await mockSession(page, { user: USER }) | |
| 59 | + await page.goto('/admin') | |
| 60 | + await expect(page.getByText(/requires the admin role/)).toBeVisible() | |
| 61 | +}) | |
| 62 | + | |
| 63 | +test('users: table renders (no crash on nested objects), last-admin banner, inline edit, LAST_ADMIN error, search, invite result', async ({ page }) => { | |
| 64 | + const state = fresh() | |
| 65 | + await mockSession(page, state, adminHandler(state)) | |
| 66 | + await page.goto('/admin') | |
| 67 | + const table = page.getByTestId('users-table') | |
| 68 | + await expect(table).toContainText('ada@example.com') | |
| 69 | + await expect(table).toContainText('root@example.com') | |
| 70 | + await expect(page.getByTestId('last-admin-banner')).toContainText('only active administrator') | |
| 71 | + await page.getByLabel('Tier of ada@example.com').selectOption('high_usage') | |
| 72 | + await expect.poll(() => state.patches).toEqual([[1, { tier: 'high_usage' }]]) | |
| 73 | + // the admin cannot edit their own role/status (controls disabled) | |
| 74 | + await expect(page.getByLabel('Role of root@example.com')).toBeDisabled() | |
| 75 | + await expect(page.getByLabel('Status of root@example.com')).toBeDisabled() | |
| 76 | + await page.getByLabel('Search users').fill('bob') | |
| 77 | + await expect(table).not.toContainText('ada@example.com') | |
| 78 | + await expect(table).toContainText('bob@example.com') | |
| 79 | + await page.getByLabel('Search users').fill('') | |
| 80 | + await expect(table).toContainText('ada@example.com') | |
| 81 | + // invite → link + key prefix (never a full key) | |
| 82 | + await page.getByLabel('Name').fill('Carol') | |
| 83 | + await page.getByLabel('E-mail').fill('carol@example.com') | |
| 84 | + await page.getByTestId('invite-submit').click() | |
| 85 | + await expect(page.getByTestId('invite-link')).toHaveText('https://www.hfmarketdata.io/accept-invite?token=INV-abc') | |
| 86 | + await expect(page.getByTestId('invite-key')).toContainText('hfmd_live_NEWUSER1…') | |
| 87 | + await expect(table).toContainText('carol@example.com') | |
| 88 | + expect(state.csrfErrors).toEqual([]) | |
| 89 | +}) | |
| 90 | + | |
| 91 | +test('users: detail panel (7-day usage, pending invitation, keys, revoke), delete with confirmation', async ({ page }) => { | |
| 92 | + const state = fresh() | |
| 93 | + await mockSession(page, state, adminHandler(state)) | |
| 94 | + await page.goto('/admin') | |
| 95 | + await page.getByTestId('detail-3').click() | |
| 96 | + const detail = page.getByTestId('user-detail') | |
| 97 | + await expect(detail).toContainText('bob@example.com') | |
| 98 | + await expect(detail.getByTestId('detail-usage')).toContainText('requests') | |
| 99 | + await expect(detail.getByTestId('pending-invite')).toHaveText('https://www.hfmarketdata.io/accept-invite?token=PENDING-bob') | |
| 100 | + await expect(detail.getByTestId('detail-keys')).toContainText('hfmd_live_ab12cd34…') | |
| 101 | + await detail.getByRole('button', { name: 'Revoke' }).click() | |
| 102 | + await page.getByTestId('confirm-admin-revoke').click() | |
| 103 | + await expect.poll(() => state.revoked).toEqual([13]) | |
| 104 | + // delete Bob | |
| 105 | + const row = page.getByTestId('users-table').locator('tr', { hasText: 'bob@example.com' }) | |
| 106 | + await row.getByRole('button', { name: 'Delete' }).click() | |
| 107 | + await expect(page.getByRole('alertdialog')).toContainText('Delete bob@example.com?') | |
| 108 | + await page.getByTestId('confirm-delete-user').click() | |
| 109 | + await expect.poll(() => state.deletes).toEqual([3]) | |
| 110 | + await expect(page.getByTestId('toast').filter({ hasText: 'deleted' })).toBeVisible() | |
| 111 | + expect(state.csrfErrors).toEqual([]) | |
| 112 | +}) | |
| 113 | + | |
| 114 | +test('global usage uses days/top and per_day/top/totals; audit filters by actor server-side', async ({ page }) => { | |
| 115 | + const state = fresh() | |
| 116 | + await mockSession(page, state, adminHandler(state)) | |
| 117 | + await page.goto('/admin/usage') | |
| 118 | + await expect(page.getByTestId('adm-requests')).toHaveText('12,345') | |
| 119 | + const top = page.getByTestId('top-principals') | |
| 120 | + await expect(top).toContainText('key:1') | |
| 121 | + await expect(top).toContainText('ada@example.com') | |
| 122 | + await expect(top).toContainText('keyless (IP)') | |
| 123 | + await page.getByRole('radio', { name: '7 days' }).click() | |
| 124 | + await expect.poll(() => state.usageQueries).toEqual(['?days=30&top=50', '?days=7&top=50']) | |
| 125 | + await expect(page.getByRole('img', { name: /Requests per day/ })).toBeVisible() | |
| 126 | + await page.getByRole('link', { name: 'Audit log' }).click() | |
| 127 | + const audit = page.getByTestId('audit-table') | |
| 128 | + await expect(audit).toContainText('user.update') | |
| 129 | + await expect(audit).toContainText('key.create') | |
| 130 | + await page.getByLabel('Actor').fill('user:2') | |
| 131 | + await page.getByTestId('audit-apply').click() | |
| 132 | + await expect(audit).not.toContainText('key.create') | |
| 133 | + await expect.poll(() => state.auditQueries.at(-1)).toBe('?limit=200&actor=user%3A2') | |
| 134 | +}) | |
modified
hfmarketdata/web/e2e/auth.spec.js
+69 −37
@@ -1,39 +1,50 @@ | ||
| 1 | 1 | import { expect, test } from '@playwright/test' |
| 2 | −import { USER, json, mockSession } from './mocks.js' | |
| 2 | +import { USER, err, key, mockSession, ok, usageSeries, liveLimits } from './mocks.js' | |
| 3 | 3 | |
| 4 | −const err = (code, message, status) => json({ error: { code, message, docs: `https://www.hfmarketdata.io/docs/errors#${code}` }, detail: message }, status) | |
| 4 | +const meRoutes = state => (url, route, req) => { | |
| 5 | + if (url.pathname === '/v1/me/keys' && req.method() === 'GET') return ok(state.keys || []) | |
| 6 | + if (url.pathname === '/v1/me/usage') return ok(usageSeries(url.searchParams.get('range') || '24h')) | |
| 7 | + if (url.pathname === '/v1/me/limits') return ok(liveLimits(state.keys || [])) | |
| 8 | + return null | |
| 9 | +} | |
| 5 | 10 | |
| 6 | −test('sign in: inline validation, wrong credentials, then success redirects to ?next', async ({ page }) => { | |
| 7 | − const state = { user: null } | |
| 11 | +test('sign in: inline validation, wrong credentials, lockout message, then success redirects to ?next', async ({ page }) => { | |
| 12 | + const state = { user: null, keys: [] } | |
| 13 | + let attempts = 0 | |
| 8 | 14 | await mockSession(page, state, async (url, route, req) => { |
| 9 | 15 | if (url.pathname === '/v1/auth/login') { |
| 10 | 16 | const body = req.postDataJSON() |
| 17 | + attempts++ | |
| 18 | + if (body.password === 'locked-out-please') return err('ACCOUNT_LOCKED', 'Too many failed sign-in attempts.', 423, { details: { retry_after: 120 } }) | |
| 11 | 19 | if (body.password !== 'correct-horse-battery') return err('INVALID_CREDENTIALS', 'Bad credentials', 401) |
| 12 | 20 | state.user = USER |
| 13 | − return json({ data: USER }) | |
| 21 | + return ok(USER) | |
| 14 | 22 | } |
| 15 | − if (url.pathname === '/v1/me/keys') return json({ data: [] }) | |
| 16 | − if (url.pathname === '/v1/me/usage') return json({ data: { series: [], totals: { requests: 0, rows: 0 } } }) | |
| 17 | − return null | |
| 23 | + return meRoutes(state)(url, route, req) | |
| 18 | 24 | }) |
| 19 | 25 | await page.goto('/signin?next=/dashboard/keys') |
| 20 | 26 | await page.getByRole('button', { name: 'Sign in' }).click() |
| 21 | 27 | await expect(page.getByText('Enter a valid e-mail address.')).toBeVisible() |
| 22 | 28 | await expect(page.getByText('Enter your password.')).toBeVisible() |
| 23 | 29 | await page.getByLabel('E-mail').fill('ada@example.com') |
| 24 | − await page.getByLabel('Password').fill('nope-nope-nope') | |
| 30 | + await page.getByLabel('Password', { exact: true }).fill('nope-nope-nope') | |
| 25 | 31 | await page.getByRole('button', { name: 'Sign in' }).click() |
| 26 | 32 | await expect(page.getByText('Incorrect e-mail or password.')).toBeVisible() |
| 27 | − await page.getByLabel('Password').fill('correct-horse-battery') | |
| 33 | + await page.getByLabel('Password', { exact: true }).fill('locked-out-please') | |
| 34 | + await page.getByRole('button', { name: 'Sign in' }).click() | |
| 35 | + await expect(page.getByText(/locked for 2 min/)).toBeVisible() | |
| 36 | + await page.getByLabel('Password', { exact: true }).fill('correct-horse-battery') | |
| 28 | 37 | await page.getByRole('button', { name: 'Sign in' }).click() |
| 29 | 38 | await expect(page).toHaveURL(/\/dashboard\/keys$/) |
| 30 | 39 | await expect(page.getByRole('heading', { name: 'API keys' })).toBeVisible() |
| 40 | + expect(attempts).toBe(3) | |
| 41 | + expect(state.csrfErrors).toEqual([]) | |
| 31 | 42 | }) |
| 32 | 43 | |
| 33 | −test('sign up: validation, EMAIL_TAKEN envelope, then 202 success state', async ({ page }) => { | |
| 34 | − let calls = 0 | |
| 35 | − await mockSession(page, { user: null }, async url => { | |
| 36 | − if (url.pathname === '/v1/auth/signup') { calls++; return calls === 1 ? err('EMAIL_TAKEN', 'taken', 409) : { status: 202, contentType: 'application/json', body: '{}' } } | |
| 44 | +test('sign up: validation, uniform 202 success state (no account enumeration)', async ({ page }) => { | |
| 45 | + const posted = [] | |
| 46 | + await mockSession(page, { user: null }, async (url, route, req) => { | |
| 47 | + if (url.pathname === '/v1/auth/signup') { posted.push(req.postDataJSON()); return ok({ status: 'verification_sent', email: 'ada@example.com' }, 202) } | |
| 37 | 48 | return null |
| 38 | 49 | }) |
| 39 | 50 | await page.goto('/signup') |
@@ -41,59 +52,76 @@ test('sign up: validation, EMAIL_TAKEN envelope, then 202 success state', async | ||
| 41 | 52 | await expect(page.getByText('Enter your name.')).toBeVisible() |
| 42 | 53 | await page.getByLabel('Name').fill('Ada Lovelace') |
| 43 | 54 | await page.getByLabel('E-mail').fill('ada@example.com') |
| 44 | − await page.getByLabel('Password').fill('short') | |
| 55 | + await page.getByLabel('Password', { exact: true }).fill('short') | |
| 45 | 56 | await page.getByRole('button', { name: 'Create free account' }).click() |
| 46 | 57 | await expect(page.getByText('Use at least 10 characters.')).toBeVisible() |
| 47 | − await page.getByLabel('Password').fill('a-long-enough-password') | |
| 48 | − await page.getByRole('button', { name: 'Create free account' }).click() | |
| 49 | − await expect(page.getByText(/An account already exists for this e-mail/)).toBeVisible() | |
| 58 | + await page.getByLabel('Password', { exact: true }).fill('a-long-enough-password') | |
| 50 | 59 | await page.getByRole('button', { name: 'Create free account' }).click() |
| 51 | 60 | await expect(page.getByTestId('signup-success')).toContainText('Check your inbox') |
| 52 | 61 | await expect(page.getByTestId('signup-success')).toContainText('ada@example.com') |
| 62 | + expect(posted).toEqual([{ email: 'ada@example.com', name: 'Ada Lovelace', password: 'a-long-enough-password' }]) | |
| 53 | 63 | }) |
| 54 | 64 | |
| 55 | −test('verify: with token → success; without token → missing; invalid → INVALID_TOKEN', async ({ page }) => { | |
| 56 | − await mockSession(page, { user: null }, async url => { | |
| 57 | − if (url.pathname === '/v1/auth/verify') return url.searchParams.get('token') === 'good' ? json({ ok: true }) : err('INVALID_TOKEN', 'expired', 400) | |
| 58 | − return null | |
| 65 | +test('verify: POSTs the token (never GET), scrubs it from the URL, signs in and offers the dashboard; missing / invalid states', async ({ page }) => { | |
| 66 | + const state = { user: null, keys: [] } | |
| 67 | + const calls = [] | |
| 68 | + await mockSession(page, state, async (url, route, req) => { | |
| 69 | + if (url.pathname === '/v1/auth/verify') { | |
| 70 | + calls.push([req.method(), req.postDataJSON?.()]) | |
| 71 | + if (req.method() !== 'POST') return err('INVALID_TOKEN', 'must POST', 405) | |
| 72 | + if (req.postDataJSON().token === 'good') { state.user = USER; return ok({ ...USER, kind: 'verify' }) } | |
| 73 | + return err('INVALID_TOKEN', 'expired', 400) | |
| 74 | + } | |
| 75 | + return meRoutes(state)(url, route, req) | |
| 59 | 76 | }) |
| 60 | 77 | await page.goto('/verify?token=good') |
| 61 | 78 | await expect(page.getByTestId('verify-success')).toContainText('E-mail verified') |
| 79 | + await expect(page).toHaveURL(/\/verify$/) // token scrubbed from the address bar | |
| 80 | + expect(calls).toEqual([['POST', { token: 'good' }]]) | |
| 81 | + await page.getByTestId('verify-go').click() // AuthProvider was refreshed: the dashboard opens without a redirect to /signin | |
| 82 | + await expect(page).toHaveURL(/\/dashboard\/keys$/) | |
| 83 | + await expect(page.getByTestId('dashboard')).toContainText('ada@example.com') | |
| 84 | + state.user = null | |
| 62 | 85 | await page.goto('/verify') |
| 63 | 86 | await expect(page.getByText('Missing token')).toBeVisible() |
| 64 | 87 | await page.goto('/verify?token=bad') |
| 65 | 88 | await expect(page.getByText('This link is invalid or has expired. Request a new one.')).toBeVisible() |
| 66 | 89 | }) |
| 67 | 90 | |
| 68 | −test('reset: request link, then set a new password via ?token=', async ({ page }) => { | |
| 91 | +test('reset: request link, then set a new password via ?token= with the revoke-keys choice', async ({ page }) => { | |
| 69 | 92 | const posted = [] |
| 70 | − await mockSession(page, { user: null }, async (url, route, req) => { | |
| 71 | − if (url.pathname === '/v1/auth/forgot' || url.pathname === '/v1/auth/reset') { posted.push([url.pathname, req.postDataJSON()]); return json({ ok: true }) } | |
| 72 | − return null | |
| 93 | + const state = { user: null, keys: [] } | |
| 94 | + await mockSession(page, state, async (url, route, req) => { | |
| 95 | + if (url.pathname === '/v1/auth/forgot') { posted.push([url.pathname, req.postDataJSON()]); return ok({ status: 'reset_sent' }, 202) } | |
| 96 | + if (url.pathname === '/v1/auth/reset') { posted.push([url.pathname, req.postDataJSON()]); state.user = USER; return ok({ ...USER, keys_revoked: 2 }) } | |
| 97 | + return meRoutes(state)(url, route, req) | |
| 73 | 98 | }) |
| 74 | 99 | await page.goto('/reset') |
| 75 | 100 | await page.getByLabel('E-mail').fill('ada@example.com') |
| 76 | 101 | await page.getByRole('button', { name: 'Send reset link' }).click() |
| 77 | 102 | await expect(page.getByTestId('reset-requested')).toContainText('Check your inbox') |
| 78 | − await page.goto('/reset?token=tok123') | |
| 103 | + await page.goto('/reset-password?token=tok123') | |
| 104 | + await expect(page).toHaveURL(/\/reset-password$/) | |
| 79 | 105 | await page.getByLabel('New password').fill('brand-new-password') |
| 80 | 106 | await page.getByLabel('Confirm password').fill('brand-new-passwor') |
| 81 | 107 | await page.getByRole('button', { name: 'Set password' }).click() |
| 82 | 108 | await expect(page.getByText('Passwords do not match.')).toBeVisible() |
| 83 | 109 | await page.getByLabel('Confirm password').fill('brand-new-password') |
| 110 | + await expect(page.getByTestId('revoke-keys')).toBeChecked() | |
| 84 | 111 | await page.getByRole('button', { name: 'Set password' }).click() |
| 85 | 112 | await expect(page.getByTestId('reset-done')).toContainText('Password updated') |
| 86 | − expect(posted).toEqual([['/v1/auth/forgot', { email: 'ada@example.com' }], ['/v1/auth/reset', { token: 'tok123', password: 'brand-new-password' }]]) | |
| 113 | + await expect(page.getByTestId('reset-done')).toContainText('2 API keys were revoked') | |
| 114 | + expect(posted).toEqual([['/v1/auth/forgot', { email: 'ada@example.com' }], ['/v1/auth/reset', { token: 'tok123', password: 'brand-new-password', revoke_keys: true }]]) | |
| 115 | + expect(state.csrfErrors).toEqual([]) | |
| 87 | 116 | }) |
| 88 | 117 | |
| 89 | 118 | test('invite: set password → accept-invite → lands on the dashboard signed in', async ({ page }) => { |
| 90 | − const state = { user: null } | |
| 91 | − await mockSession(page, state, async url => { | |
| 92 | − if (url.pathname === '/v1/auth/accept-invite') { state.user = USER; return json({ data: USER }) } | |
| 93 | − if (url.pathname.startsWith('/v1/me/')) return json({ data: [] }) | |
| 94 | − return null | |
| 119 | + const state = { user: null, keys: [key()] } | |
| 120 | + await mockSession(page, state, async (url, route, req) => { | |
| 121 | + if (url.pathname === '/v1/auth/accept-invite') { state.user = USER; return ok(USER) } | |
| 122 | + return meRoutes(state)(url, route, req) | |
| 95 | 123 | }) |
| 96 | − await page.goto('/invite?token=inv42') | |
| 124 | + await page.goto('/accept-invite?token=inv42') | |
| 97 | 125 | await expect(page.getByText(/You were invited to HF Market Data/)).toBeVisible() |
| 98 | 126 | await page.getByLabel('New password').fill('welcome-aboard-2026') |
| 99 | 127 | await page.getByLabel('Confirm password').fill('welcome-aboard-2026') |
@@ -102,11 +130,15 @@ test('invite: set password → accept-invite → lands on the dashboard signed i | ||
| 102 | 130 | await expect(page.getByTestId('dashboard')).toContainText('ada@example.com') |
| 103 | 131 | }) |
| 104 | 132 | |
| 105 | −test('accounts not deployed yet (404) is explained, not crashed', async ({ page }) => { | |
| 133 | +test('accounts not deployed yet (404) and network errors are explained, not crashed', async ({ page }) => { | |
| 106 | 134 | await mockSession(page, { user: null }) |
| 107 | 135 | await page.goto('/signin') |
| 108 | 136 | await page.getByLabel('E-mail').fill('ada@example.com') |
| 109 | − await page.getByLabel('Password').fill('whatever-password') | |
| 137 | + await page.getByLabel('Password', { exact: true }).fill('whatever-password') | |
| 138 | + await page.getByRole('button', { name: 'Sign in' }).click() | |
| 139 | + await expect(page.getByText(/not available on this server yet/)).toBeVisible() | |
| 140 | + await page.unrouteAll({ behavior: 'ignoreErrors' }) | |
| 141 | + await page.route('**/v1/auth/login', r => r.abort('connectionrefused')) | |
| 110 | 142 | await page.getByRole('button', { name: 'Sign in' }).click() |
| 111 | − await expect(page.getByText(/Accounts are not enabled on this server yet/)).toBeVisible() | |
| 143 | + await expect(page.getByText(/Network error/)).toBeVisible() | |
| 112 | 144 | }) |
modified
hfmarketdata/web/e2e/dashboard.spec.js
+119 −86
@@ -1,29 +1,34 @@ | ||
| 1 | 1 | import { expect, test } from '@playwright/test' |
| 2 | −import { ADMIN, USER, bars, json, mockSession } from './mocks.js' | |
| 3 | − | |
| 4 | −const now = Date.now() | |
| 5 | −const series = Array.from({ length: 24 }, (_, i) => ({ ts: new Date(now - (23 - i) * 3600_000).toISOString().slice(0, 16) + ':00Z', requests: 10 + i, rows: 1000 * (i + 1), status_429: i === 5 ? 2 : 0 })) | |
| 6 | −const KEY = 'hfmd_live_ab12cd34EFGH5678ijkl9012MNOP3456' | |
| 2 | +import { KEY, USER, bars, err, key, liveLimits, mockSession, ok, usageSeries } from './mocks.js' | |
| 7 | 3 | |
| 8 | 4 | function meHandler(state) { |
| 9 | 5 | return async (url, route, req) => { |
| 10 | 6 | const m = req.method() |
| 11 | − if (url.pathname === '/v1/me/usage') return json({ data: { series, totals: { requests: series.reduce((a, s) => a + s.requests, 0), rows: series.reduce((a, s) => a + s.rows, 0) }, tier: 'free', limits: USER.limits } }) | |
| 12 | − if (url.pathname === '/v1/me/keys' && m === 'GET') return json({ data: state.keys }) | |
| 7 | + if (url.pathname === '/v1/me/usage') { state.usageCalls.push(url.search); return ok({ ...usageSeries(url.searchParams.get('range') || '24h'), key_id: url.searchParams.get('key_id') ? Number(url.searchParams.get('key_id')) : null }) } | |
| 8 | + if (url.pathname === '/v1/me/limits') return ok(liveLimits(state.keys)) | |
| 9 | + if (url.pathname === '/v1/me/keys' && m === 'GET') return ok(state.keys) | |
| 13 | 10 | if (url.pathname === '/v1/me/keys' && m === 'POST') { |
| 14 | − const k = { id: state.keys.length + 1, name: req.postDataJSON().name, prefix: 'hfmd_live_ab12cd34', status: 'active', created_at: new Date().toISOString(), last_used_at: null } | |
| 15 | − state.keys.push(k); state.posts.push(['POST', url.pathname, req.postDataJSON()]) | |
| 16 | − return json({ data: { ...k, key: KEY } }) | |
| 11 | + const b = req.postDataJSON() | |
| 12 | + const k = key({ id: state.keys.length + 1, name: b.name, note: b.note || null, expires_at: b.expires_in_days ? new Date(Date.now() + b.expires_in_days * 86400_000).toISOString() : null, created_at: new Date().toISOString(), principal: `key:${state.keys.length + 1}` }) | |
| 13 | + state.keys.push(k); state.posts.push(['POST', url.pathname, b]) | |
| 14 | + return ok({ ...k, key: KEY }, 201) | |
| 17 | 15 | } |
| 18 | − const del = url.pathname.match(/^\/v1\/me\/keys\/(\d+)$/) | |
| 19 | − if (del && m === 'DELETE') { state.keys = state.keys.map(k => (k.id === Number(del[1]) ? { ...k, status: 'revoked' } : k)); state.posts.push(['DELETE', url.pathname]); return { status: 204, body: '' } } | |
| 16 | + const one = url.pathname.match(/^\/v1\/me\/keys\/(\d+)$/) | |
| 17 | + if (one && m === 'DELETE') { state.keys = state.keys.map(k => (k.id === Number(one[1]) ? { ...k, status: 'revoked', revoked_at: new Date().toISOString() } : k)); state.posts.push(['DELETE', url.pathname]); return ok(state.keys.find(k => k.id === Number(one[1]))) } | |
| 18 | + if (one && m === 'PATCH') { const b = req.postDataJSON(); state.keys = state.keys.map(k => (k.id === Number(one[1]) ? { ...k, ...b } : k)); state.posts.push(['PATCH', url.pathname, b]); return ok(state.keys.find(k => k.id === Number(one[1]))) } | |
| 20 | 19 | const rot = url.pathname.match(/^\/v1\/me\/keys\/(\d+)\/rotate$/) |
| 21 | − if (rot && m === 'POST') { state.posts.push(['POST', url.pathname]); return json({ data: { id: 99, name: 'rotated', prefix: 'hfmd_live_zz99', status: 'active', key: 'hfmd_live_zz99ROTATEDKEYzz99ROTATEDKEYzz' } }) } | |
| 22 | − if (url.pathname === '/v1/bars/stock/AAPL') { state.auth = req.headers().authorization || null; return json({ count: 3, data: bars(3) }) } | |
| 23 | − if (url.pathname === '/v1/auth/forgot') { state.posts.push(['POST', url.pathname, req.postDataJSON()]); return json({ ok: true }) } | |
| 20 | + if (rot && m === 'POST') { state.posts.push(['POST', url.pathname]); const nk = key({ id: 99, name: 'rotated', prefix: 'hfmd_live_zz99yy76' }); state.keys = state.keys.map(k => (k.id === Number(rot[1]) ? { ...k, status: 'revoked' } : k)).concat(nk); return ok({ ...nk, key: 'hfmd_live_zz99ROTATEDKEYzz99ROTATEDKEYzz', rotated_from: Number(rot[1]) }, 201) } | |
| 21 | + if (url.pathname === '/v1/bars/stock/AAPL') { state.auth = req.headers().authorization || null; return json3(bars(3)) } | |
| 22 | + if (url.pathname === '/v1/me/password' && m === 'POST') { const b = req.postDataJSON(); state.posts.push(['POST', url.pathname, b]); return b.current_password === 'correct-horse-battery' ? ok({ status: 'password_changed' }) : err('INVALID_CREDENTIALS', 'The current password is wrong.', 401) } | |
| 23 | + if (url.pathname === '/v1/me/email' && m === 'POST') { state.posts.push(['POST', url.pathname, req.postDataJSON()]); return ok({ status: 'confirmation_sent', new_email: req.postDataJSON().new_email }, 202) } | |
| 24 | + if (url.pathname === '/v1/me/sessions/revoke-all' && m === 'POST') { state.posts.push(['POST', url.pathname]); return ok({ status: 'sessions_revoked' }) } | |
| 25 | + if (url.pathname === '/v1/me' && m === 'PATCH') { const b = req.postDataJSON(); state.user = { ...state.user, ...b }; state.posts.push(['PATCH', url.pathname, b]); return ok(state.user) } | |
| 26 | + if (url.pathname === '/v1/me' && m === 'DELETE') { const b = req.postDataJSON(); state.posts.push(['DELETE', url.pathname, b]); if (b.password !== 'correct-horse-battery') return err('INVALID_CREDENTIALS', 'wrong', 401); state.user = null; return ok({ status: 'deleted' }) } | |
| 24 | 27 | return null |
| 25 | 28 | } |
| 26 | 29 | } |
| 30 | +const json3 = data => ({ status: 200, contentType: 'application/json', headers: { 'X-Row-Count': String(data.length), 'X-RateLimit-Limit-Requests': '120', 'X-RateLimit-Remaining-Requests': '119', 'X-RateLimit-Limit-Rows': '1000000', 'X-RateLimit-Remaining-Rows': '999997', 'X-RateLimit-Reset': String(Math.floor(Date.now() / 1000) + 60) }, body: JSON.stringify({ count: data.length, data }) }) | |
| 31 | +const fresh = (over = {}) => ({ user: USER, keys: [], posts: [], usageCalls: [], auth: null, ...over }) | |
| 27 | 32 | |
| 28 | 33 | test('route guard redirects anonymous visitors to /signin?next=', async ({ page }) => { |
| 29 | 34 | await mockSession(page, { user: null }) |
@@ -32,29 +37,39 @@ test('route guard redirects anonymous visitors to /signin?next=', async ({ page | ||
| 32 | 37 | await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible() |
| 33 | 38 | }) |
| 34 | 39 | |
| 35 | −test('overview: tier, limits, today totals, last request', async ({ page }) => { | |
| 36 | − const state = { user: USER, keys: [{ id: 1, name: 'laptop', prefix: 'hfmd_live_ab12cd34', status: 'active', created_at: '2026-09-01T10:00:00Z', last_used_at: new Date(now - 120_000).toISOString() }], posts: [] } | |
| 40 | +test('overview: tier, today totals from the points, 429 count, active keys, live quota bars', async ({ page }) => { | |
| 41 | + const state = fresh({ keys: [key({ last_used_at: new Date(Date.now() - 120_000).toISOString() })] }) | |
| 37 | 42 | await mockSession(page, state, meHandler(state)) |
| 38 | 43 | await page.goto('/dashboard') |
| 39 | 44 | const dash = page.getByTestId('dashboard') |
| 40 | 45 | await expect(dash).toContainText('Tier: free') |
| 41 | 46 | await expect(dash).toContainText('120 req / minute') |
| 42 | − await expect(dash).toContainText('Requests today') | |
| 43 | − await expect(dash).toContainText('1 active key') | |
| 44 | − await expect(dash).toContainText(/Last request/) | |
| 47 | + await expect(page.getByTestId('stat-requests')).not.toHaveText('—') | |
| 48 | + await expect(page.getByTestId('stat-429')).not.toHaveText('—') | |
| 49 | + await expect(page.getByTestId('stat-keys')).toHaveText('1') | |
| 50 | + const quota = page.getByTestId('quota-now') | |
| 51 | + await expect(quota).toContainText('laptop') | |
| 52 | + await expect(quota.getByTestId('quota-bar')).toHaveCount(2) | |
| 53 | + await expect(quota).toContainText('3 / 120') | |
| 54 | + await expect(quota).toContainText('60,000 / 1,000,000') | |
| 55 | + expect(state.csrfErrors).toEqual([]) | |
| 45 | 56 | }) |
| 46 | 57 | |
| 47 | −test('API keys: create with one-time reveal, use in playground (Bearer injected, masked), revoke with confirm', async ({ page }) => { | |
| 48 | − const state = { user: USER, keys: [], posts: [], auth: null } | |
| 58 | +test('API keys: create (note + expiry) with one-time reveal, use in playground, revoke with confirm, rotate, rename', async ({ page }) => { | |
| 59 | + const state = fresh() | |
| 49 | 60 | await mockSession(page, state, meHandler(state)) |
| 50 | 61 | await page.goto('/dashboard/keys') |
| 51 | 62 | await expect(page.getByTestId('keys-table')).toContainText('No key yet') |
| 52 | 63 | await page.getByLabel('New key name').fill('research laptop') |
| 64 | + await page.getByLabel('Expires').selectOption('90') | |
| 65 | + await page.getByLabel('Note (optional)').fill('desk machine') | |
| 53 | 66 | await page.getByTestId('create-key').click() |
| 54 | 67 | const reveal = page.getByTestId('key-reveal') |
| 55 | 68 | await expect(reveal).toContainText('Store it now') |
| 56 | 69 | await expect(page.getByTestId('key-value')).toHaveText(KEY) |
| 70 | + expect(state.posts[0]).toEqual(['POST', '/v1/me/keys', { name: 'research laptop', note: 'desk machine', expires_in_days: 90 }]) | |
| 57 | 71 | await expect(page.getByTestId('keys-table')).toContainText('research laptop') |
| 72 | + await expect(page.getByTestId('keys-table')).toContainText('desk machine') | |
| 58 | 73 | await expect(page.getByTestId('keys-table')).toContainText('hfmd_live_ab12cd34…') |
| 59 | 74 | // Use it in the playground for the session → Authorization: Bearer on requests; the code export never shows the key |
| 60 | 75 | await reveal.getByRole('button', { name: /Use in playground/ }).click() |
@@ -66,43 +81,56 @@ test('API keys: create with one-time reveal, use in playground (Bearer injected, | ||
| 66 | 81 | expect(state.auth).toBe(`Bearer ${KEY}`) |
| 67 | 82 | await expect(page.locator('.pg-codex')).toContainText('Authorization: Bearer $HFMD_API_KEY') |
| 68 | 83 | await expect(page.locator('.pg-codex')).not.toContainText(KEY) |
| 69 | − // never persisted | |
| 70 | 84 | const stored = await page.evaluate(() => JSON.stringify({ l: { ...localStorage }, s: { ...sessionStorage } })) |
| 71 | 85 | expect(stored).not.toContain('hfmd_live_') |
| 72 | − // revoke with confirmation | |
| 86 | + // the reveal is gone once closed — the key is shown exactly once | |
| 73 | 87 | await page.getByRole('link', { name: 'API keys' }).click() |
| 88 | + await expect(page.getByTestId('key-reveal')).toHaveCount(0) | |
| 89 | + // revoke with confirmation (bodiless DELETE still carries JSON + X-Requested-With) | |
| 74 | 90 | await page.getByRole('button', { name: 'Revoke' }).click() |
| 75 | 91 | await expect(page.getByRole('alertdialog')).toContainText('Revoke permanently?') |
| 76 | 92 | await page.getByTestId('confirm-revoke').click() |
| 77 | 93 | await expect(page.getByTestId('keys-table')).toContainText('revoked') |
| 78 | 94 | expect(state.posts).toContainEqual(['DELETE', '/v1/me/keys/1']) |
| 95 | + await expect(page.getByTestId('toast')).toContainText('Key revoked') | |
| 79 | 96 | // rotate reveals a new key |
| 80 | − state.keys.push({ id: 2, name: 'second', prefix: 'hfmd_live_ff00', status: 'active', created_at: '2026-09-02T00:00:00Z' }) | |
| 97 | + state.keys.push(key({ id: 2, name: 'second', prefix: 'hfmd_live_ff00aa11', created_at: '2026-09-02T00:00:00Z', principal: 'key:2' })) | |
| 81 | 98 | await page.reload() |
| 82 | 99 | await page.getByRole('button', { name: 'Rotate' }).click() |
| 83 | 100 | await page.getByTestId('confirm-rotate').click() |
| 84 | 101 | await expect(page.getByTestId('key-reveal')).toContainText('Key rotated') |
| 85 | 102 | await expect(page.getByTestId('key-value')).toContainText('hfmd_live_zz99') |
| 103 | + await page.getByTestId('key-reveal-close').click() | |
| 104 | + // rename | |
| 105 | + await page.getByRole('button', { name: 'Edit' }).first().click() | |
| 106 | + await page.getByLabel('Name', { exact: true }).fill('renamed key') | |
| 107 | + await page.getByRole('button', { name: 'Save' }).click() | |
| 108 | + await expect(page.getByTestId('keys-table')).toContainText('renamed key') | |
| 109 | + expect(state.posts).toContainEqual(['PATCH', '/v1/me/keys/99', { name: 'renamed key', note: '' }]) | |
| 110 | + expect(state.csrfErrors).toEqual([]) | |
| 86 | 111 | }) |
| 87 | 112 | |
| 88 | −test('usage: range toggle, per-day table, CSV export', async ({ page }) => { | |
| 89 | − const state = { user: USER, keys: [], posts: [] } | |
| 90 | − const ranges = [] | |
| 91 | − await mockSession(page, state, async (url, route, req) => { if (url.pathname === '/v1/me/usage') ranges.push(url.searchParams.get('range')); return meHandler(state)(url, route, req) }) | |
| 113 | +test('usage: range toggle, key filter, per-day table, server CSV link, 429 chart', async ({ page }) => { | |
| 114 | + const state = fresh({ keys: [key(), key({ id: 2, name: 'second', prefix: 'hfmd_live_ff00aa11', principal: 'key:2' })] }) | |
| 115 | + await mockSession(page, state, meHandler(state)) | |
| 92 | 116 | await page.goto('/dashboard/usage') |
| 93 | − await expect(page.getByTestId('usage-table')).toContainText(new Date(now).toISOString().slice(0, 10)) | |
| 94 | − const dayRows = await page.getByTestId('usage-table').locator('tbody tr').count() // 24 hourly points → 1 or 2 UTC days | |
| 117 | + await expect(page.getByTestId('usage-table')).toContainText(new Date().toISOString().slice(0, 10)) | |
| 118 | + const dayRows = await page.getByTestId('usage-table').locator('tbody tr').count() | |
| 95 | 119 | expect(dayRows).toBeGreaterThanOrEqual(1) |
| 96 | 120 | expect(dayRows).toBeLessThanOrEqual(2) |
| 97 | − await expect(page.getByRole('link', { name: 'Export CSV' })).toHaveAttribute('download', 'hfmd-usage-7d.csv') | |
| 121 | + await expect(page.getByTestId('usage-csv')).toHaveAttribute('href', '/v1/me/usage.csv?range=7d') | |
| 122 | + await expect(page.getByTestId('usage-csv')).toHaveAttribute('download', 'hfmd-usage-7d.csv') | |
| 98 | 123 | await page.getByRole('radio', { name: '30d' }).click() |
| 99 | − await expect(page.getByRole('link', { name: 'Export CSV' })).toHaveAttribute('download', 'hfmd-usage-30d.csv') | |
| 100 | − expect(ranges).toEqual(['7d', '30d']) | |
| 101 | − await expect(page.getByRole('img', { name: /Requests per interval/ })).toBeVisible() | |
| 124 | + await expect(page.getByTestId('usage-csv')).toHaveAttribute('download', 'hfmd-usage-30d.csv') | |
| 125 | + await page.getByTestId('usage-key').selectOption('2') | |
| 126 | + await expect(page.getByTestId('usage-csv')).toHaveAttribute('href', '/v1/me/usage.csv?range=30d&key_id=2') | |
| 127 | + expect(state.usageCalls).toEqual(['?range=7d', '?range=30d', '?range=30d&key_id=2']) | |
| 128 | + await expect(page.getByRole('img', { name: /Requests per day/ })).toBeVisible() | |
| 129 | + await expect(page.getByRole('img', { name: /429 responses/ })).toBeVisible() | |
| 102 | 130 | }) |
| 103 | 131 | |
| 104 | 132 | test('playground tab: paste a key (validated) for the session, forget it', async ({ page }) => { |
| 105 | − const state = { user: USER, keys: [], posts: [] } | |
| 133 | + const state = fresh() | |
| 106 | 134 | await mockSession(page, state, meHandler(state)) |
| 107 | 135 | await page.goto('/dashboard/playground') |
| 108 | 136 | await expect(page.getByText('Choose the key to use for this session')).toBeVisible() |
@@ -116,62 +144,67 @@ test('playground tab: paste a key (validated) for the session, forget it', async | ||
| 116 | 144 | await expect(page.getByText('Choose the key to use for this session')).toBeVisible() |
| 117 | 145 | }) |
| 118 | 146 | |
| 119 | −test('account: password reset link, tier and mailto', async ({ page }) => { | |
| 120 | − const state = { user: USER, keys: [], posts: [] } | |
| 147 | +test('account: password change (wrong current → error), e-mail change, quota alerts, sign out everywhere, delete', async ({ page }) => { | |
| 148 | + const state = fresh() | |
| 121 | 149 | await mockSession(page, state, meHandler(state)) |
| 122 | 150 | await page.goto('/dashboard/account') |
| 123 | 151 | await expect(page.getByText('ada@example.com').first()).toBeVisible() |
| 152 | + await expect(page.locator('.dash-main').getByTestId('cta-more')).toHaveAttribute('href', /^mailto:contact@spboucher.ai/) | |
| 153 | + // password | |
| 154 | + const pf = page.getByTestId('password-form') | |
| 155 | + await pf.getByLabel('Current password').fill('wrong-one-here') | |
| 156 | + await pf.getByLabel('New password', { exact: true }).fill('a-brand-new-passphrase') | |
| 157 | + await pf.getByLabel('Confirm new password').fill('a-brand-new-passphrase') | |
| 124 | 158 | await page.getByTestId('change-password').click() |
| 125 | − await expect(page.getByText(/Reset link sent to/)).toBeVisible() | |
| 126 | − expect(state.posts).toContainEqual(['POST', '/v1/auth/forgot', { email: 'ada@example.com' }]) | |
| 127 | − await expect(page.locator('.dash-main').getByRole('link', { name: /Need more\? contact@spboucher.ai/ })).toHaveAttribute('href', /^mailto:contact@spboucher.ai/) | |
| 159 | + await expect(pf.getByText('Incorrect e-mail or password.')).toBeVisible() | |
| 160 | + await pf.getByLabel('Current password').fill('correct-horse-battery') | |
| 161 | + await page.getByTestId('change-password').click() | |
| 162 | + await expect(page.getByTestId('toast')).toContainText('Password changed') | |
| 163 | + expect(state.posts).toContainEqual(['POST', '/v1/me/password', { current_password: 'correct-horse-battery', new_password: 'a-brand-new-passphrase' }]) | |
| 164 | ||
| 165 | + const ef = page.getByTestId('email-form') | |
| 166 | + await ef.getByLabel('New e-mail').fill('ada.new@example.com') | |
| 167 | + await ef.getByLabel('Current password').fill('correct-horse-battery') | |
| 168 | + await page.getByTestId('change-email').click() | |
| 169 | + await expect(page.getByText(/A confirmation link was sent to/)).toBeVisible() | |
| 170 | + expect(state.posts).toContainEqual(['POST', '/v1/me/email', { new_email: 'ada.new@example.com', password: 'correct-horse-battery' }]) | |
| 171 | + // quota alerts opt-out | |
| 172 | + await page.getByTestId('quota-alerts').uncheck() | |
| 173 | + await expect.poll(() => state.posts.some(p => p[0] === 'PATCH' && p[1] === '/v1/me' && p[2].quota_alerts === false)).toBe(true) | |
| 174 | + // sign out everywhere (confirmed) | |
| 175 | + await page.getByTestId('revoke-all').click() | |
| 176 | + await page.getByTestId('confirm-revoke-all').click() | |
| 177 | + expect(await expect.poll(() => state.posts.some(p => p[1] === '/v1/me/sessions/revoke-all')).toBe(true)) | |
| 178 | + // delete: button disabled until the e-mail is typed | |
| 179 | + await page.getByTestId('delete-open').click() | |
| 180 | + await expect(page.getByTestId('delete-confirm')).toBeDisabled() | |
| 181 | + await page.getByTestId('delete-form').getByLabel(/Type your e-mail/).fill('ada@example.com') | |
| 182 | + await page.getByTestId('delete-form').getByLabel('Current password').fill('correct-horse-battery') | |
| 183 | + await page.getByTestId('delete-confirm').click() | |
| 184 | + await expect(page).toHaveURL(/\/$/) | |
| 185 | + expect(state.posts).toContainEqual(['DELETE', '/v1/me', { password: 'correct-horse-battery' }]) | |
| 186 | + expect(state.csrfErrors).toEqual([]) | |
| 187 | +}) | |
| 188 | + | |
| 189 | +test('sign out: the error is shown when the server refuses (cookie stays valid), success returns to the site', async ({ page }) => { | |
| 190 | + const state = fresh({ logoutFails: true }) | |
| 191 | + await mockSession(page, state, meHandler(state)) | |
| 192 | + await page.goto('/dashboard') | |
| 193 | + await page.getByTestId('signout').click() | |
| 194 | + await expect(page.getByTestId('toast')).toContainText(/refused by the CSRF guard/) | |
| 195 | + await expect(page.getByTestId('dashboard')).toContainText('ada@example.com') // still signed in | |
| 196 | + state.logoutFails = false | |
| 197 | + await page.getByTestId('signout').click() | |
| 198 | + await expect(page).toHaveURL(/\/signin\?next=/) | |
| 128 | 199 | }) |
| 129 | 200 | |
| 130 | −test('admin: forbidden for users, users table with inline edit + invite for admins', async ({ page }) => { | |
| 131 | − // plain user → 403 | |
| 132 | − await mockSession(page, { user: USER, keys: [], posts: [] }) | |
| 133 | − await page.goto('/admin') | |
| 134 | − await expect(page.getByText(/requires the admin role/)).toBeVisible() | |
| 135 | − // admin | |
| 136 | − const state = { user: ADMIN, patches: [] } | |
| 137 | − const users = [ | |
| 138 | − { id: 1, email: 'ada@example.com', name: 'Ada', role: 'user', tier: 'free', status: 'active', created_at: '2026-09-01T00:00:00Z', last_login_at: '2026-09-04T09:00:00Z' }, | |
| 139 | − { id: 3, email: 'bob@example.com', name: 'Bob', role: 'user', tier: 'free', status: 'invited', created_at: '2026-09-03T00:00:00Z' }, | |
| 140 | − ] | |
| 141 | − await page.unrouteAll({ behavior: 'ignoreErrors' }) | |
| 201 | +test('error boundary: a broken section does not blank the dashboard', async ({ page }) => { | |
| 202 | + const state = fresh() | |
| 142 | 203 | await mockSession(page, state, async (url, route, req) => { |
| 143 | − if (url.pathname === '/v1/admin/users' && req.method() === 'GET') return json({ data: users }) | |
| 144 | − if (url.pathname === '/v1/admin/users' && req.method() === 'POST') { | |
| 145 | − const b = req.postDataJSON() | |
| 146 | − users.push({ id: 4, email: b.email, name: b.name, role: 'user', tier: 'free', status: 'invited', created_at: new Date().toISOString() }) | |
| 147 | − return json({ data: { user: users[users.length - 1], invite_url: 'https://www.hfmarketdata.io/invite?token=INV-abc', key: 'hfmd_live_NEWUSERKEY0000000000000000' } }) | |
| 148 | − } | |
| 149 | − const m = url.pathname.match(/^\/v1\/admin\/users\/(\d+)$/) | |
| 150 | − if (m && req.method() === 'PATCH') { state.patches.push([Number(m[1]), req.postDataJSON()]); return json({ data: { ...users.find(u => u.id === Number(m[1])), ...req.postDataJSON() } }) } | |
| 151 | − if (url.pathname === '/v1/admin/usage') return json({ data: { totals: { requests: 12345, rows: 6789000, status_429: 3 }, top: [{ principal: 'key:1', email: 'ada@example.com', tier: 'free', requests: 9000, rows: 5000000, status_429: 3 }], series } }) | |
| 152 | − if (url.pathname === '/v1/admin/audit') return json({ data: [{ ts: '2026-09-04T10:00:00Z', actor: 'root@example.com', action: 'user.tier', target: 'ada@example.com', meta: { tier: 'high_usage' } }] }) | |
| 153 | − return null | |
| 204 | + if (url.pathname === '/v1/me/usage') return ok({ points: 'not-an-array', totals: null }) // malformed → render error in the page | |
| 205 | + return meHandler(state)(url, route, req) | |
| 154 | 206 | }) |
| 155 | − await page.goto('/admin') | |
| 156 | − const table = page.getByTestId('users-table') | |
| 157 | − await expect(table).toContainText('ada@example.com') | |
| 158 | − await page.getByLabel('Tier of ada@example.com').selectOption('high_usage') | |
| 159 | − await expect.poll(() => state.patches).toEqual([[1, { tier: 'high_usage' }]]) | |
| 160 | − await page.getByLabel('Search users').fill('bob') | |
| 161 | − await expect(table).not.toContainText('ada@example.com') | |
| 162 | − await expect(table).toContainText('bob@example.com') | |
| 163 | − await page.getByLabel('Search users').fill('') | |
| 164 | − // invite | |
| 165 | − await page.getByLabel('Name').fill('Carol') | |
| 166 | − await page.getByLabel('E-mail').fill('carol@example.com') | |
| 167 | − await page.getByTestId('invite-submit').click() | |
| 168 | − await expect(page.getByTestId('invite-link')).toHaveText('https://www.hfmarketdata.io/invite?token=INV-abc') | |
| 169 | − await expect(page.getByTestId('invite-result')).toContainText('Initial API key') | |
| 170 | − await expect(table).toContainText('carol@example.com') | |
| 171 | − // global usage + audit | |
| 172 | − await page.getByRole('link', { name: 'Global usage' }).click() | |
| 173 | − await expect(page.getByTestId('top-principals')).toContainText('key:1') | |
| 174 | − await expect(page.getByTestId('admin')).toContainText('12,345') | |
| 175 | − await page.getByRole('link', { name: 'Audit log' }).click() | |
| 176 | − await expect(page.getByTestId('audit-table')).toContainText('user.tier') | |
| 207 | + await page.goto('/dashboard/usage') | |
| 208 | + await expect(page.getByTestId('error-boundary').or(page.getByTestId('usage-table'))).toBeVisible() | |
| 209 | + await expect(page.getByTestId('dashboard')).toContainText('ada@example.com') // sidebar survived | |
| 177 | 210 | }) |
modified
hfmarketdata/web/e2e/mocks.js
+58 −8
@@ -1,12 +1,43 @@ | ||
| 1 | 1 | // Shared offline mocks for the E2E specs (page.route). Nothing here talks to the network. |
| 2 | +// The shapes below mirror the REAL backend contract (hfmarketdata/api/accounts/routes_*.py, ratelimit/usage.py): | |
| 3 | +// every v2 answer is an envelope `{ data, meta: { count, … } }`; errors are `{ error: { code, message, docs }, detail }`. | |
| 2 | 4 | export const RATE = { |
| 3 | 5 | 'X-RateLimit-Limit-Requests': '30', 'X-RateLimit-Remaining-Requests': '29', |
| 4 | 6 | 'X-RateLimit-Limit-Rows': '100000', 'X-RateLimit-Remaining-Rows': '99500', |
| 5 | 7 | 'X-RateLimit-Reset': String(Math.floor(Date.now() / 1000) + 1800), |
| 6 | 8 | } |
| 7 | 9 | |
| 8 | −export const USER = { id: 1, email: 'ada@example.com', name: 'Ada', role: 'user', tier: 'free', limits: { window: 'minute', requests: 120, rows: 1_000_000, max_rows: 50_000 }, created_at: '2026-09-01T10:00:00Z' } | |
| 9 | −export const ADMIN = { ...USER, id: 2, email: 'root@example.com', name: 'Root', role: 'admin' } | |
| 10 | +export const LIMITS_FREE = { tier: 'free', window_seconds: 60, requests: 120, rows: 1_000_000, max_rows_per_request: 50_000, upgrade: 'Need more? Ask for the high-usage tier at contact@spboucher.ai.' } | |
| 11 | + | |
| 12 | +/** `GET /v1/me` → data.user (flat profile). */ | |
| 13 | +export const USER = { id: 1, email: 'ada@example.com', name: 'Ada', role: 'user', tier: 'free', status: 'active', email_verified: true, created_at: '2026-09-01T10:00:00Z', last_login_at: '2026-09-04T09:00:00Z', quota_alerts: true, locked_until: null, keys_active: 1 } | |
| 14 | +export const ADMIN = { ...USER, id: 2, email: 'root@example.com', name: 'Root', role: 'admin', tier: 'high_usage' } | |
| 15 | + | |
| 16 | +/** Envelope of GET /v1/me for a signed-in user. */ | |
| 17 | +export const me = (user, auth = 'session') => ({ data: { user, limits: user.tier === 'high_usage' ? { ...LIMITS_FREE, tier: 'high_usage', requests: 600, rows: 10_000_000, max_rows_per_request: 200_000 } : LIMITS_FREE, auth }, meta: { count: 1 } }) | |
| 18 | + | |
| 19 | +export const KEY = 'hfmd_live_ab12cd34EFGH5678ijkl9012MNOP3456' | |
| 20 | +export const key = (over = {}) => ({ id: 1, name: 'laptop', prefix: 'hfmd_live_ab12cd34', status: 'active', tier_override: null, created_at: '2026-09-01T10:00:00Z', last_used_at: null, last_used_ip: null, revoked_at: null, expires_at: null, expired: false, note: null, scopes: ['data'], principal: 'key:1', ...over }) | |
| 21 | + | |
| 22 | +/** GET /v1/me/usage → data: { range, step_seconds, from, to, points: [{ t, requests, rows, status_429, bytes, rows_parquet }], totals, principals, key_id } */ | |
| 23 | +export function usageSeries(range = '7d', now = Date.now()) { | |
| 24 | + const step = range === '24h' ? 60 : range === '7d' ? 3600 : 86400 | |
| 25 | + const count = range === '24h' ? 24 * 60 : range === '7d' ? 7 * 24 : 30 | |
| 26 | + const end = Math.floor(now / 1000 / step) * step + step | |
| 27 | + const points = Array.from({ length: count }, (_, i) => { | |
| 28 | + const ts = end - (count - i) * step | |
| 29 | + const hot = i >= count - 24 | |
| 30 | + return { t: new Date(ts * 1000).toISOString().slice(0, 19) + 'Z', requests: hot ? 10 + (i % 24) : 0, rows: hot ? 1000 * ((i % 24) + 1) : 0, status_429: hot && i % 24 === 5 ? 2 : 0, bytes: hot ? 4096 : 0, rows_parquet: 0 } | |
| 31 | + }) | |
| 32 | + const totals = points.reduce((a, p) => ({ requests: a.requests + p.requests, rows: a.rows + p.rows, status_429: a.status_429 + p.status_429, bytes: a.bytes + p.bytes, rows_parquet: 0 }), { requests: 0, rows: 0, status_429: 0, bytes: 0, rows_parquet: 0 }) | |
| 33 | + return { range, step_seconds: step, from: points[0].t, to: new Date(end * 1000).toISOString().slice(0, 19) + 'Z', points, totals, principals: ['key:1'], key_id: null } | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** GET /v1/me/limits → data: { tier, keys: [{ key_id, name, prefix, …snapshot }] } */ | |
| 37 | +export const liveLimits = (keys, used = { requests: 3, rows: 60_000 }) => ({ | |
| 38 | + tier: LIMITS_FREE, | |
| 39 | + keys: keys.filter(k => k.status === 'active').map(k => ({ key_id: k.id, name: k.name, prefix: k.prefix, status: k.status, expires_at: k.expires_at, principal: `key:${k.id}`, kind: 'key', tier: 'free', window_seconds: 60, max_rows_per_request: 50_000, requests: { limit: 120, remaining: 120 - used.requests, reset: Math.floor(Date.now() / 1000) + 45 }, rows: { limit: 1_000_000, remaining: 1_000_000 - used.rows, reset: Math.floor(Date.now() / 1000) + 45 }, redis: true })), | |
| 40 | +}) | |
| 10 | 41 | |
| 11 | 42 | export function bars(n = 500, start = Date.UTC(2024, 5, 3, 13, 30)) { |
| 12 | 43 | const out = [] |
@@ -20,32 +51,51 @@ export function bars(n = 500, start = Date.UTC(2024, 5, 3, 13, 30)) { | ||
| 20 | 51 | } |
| 21 | 52 | |
| 22 | 53 | export const json = (body, status = 200, headers = {}) => ({ status, contentType: 'application/json', headers: { ...RATE, 'X-Row-Count': String(Array.isArray(body?.data) ? body.data.length : 0), ...headers }, body: JSON.stringify(body) }) |
| 54 | +/** Envelope helper: `ok(data)` → `{ data, meta: { count } }`. */ | |
| 55 | +export const ok = (data, status = 200, meta = {}) => json({ data, meta: { count: Array.isArray(data) ? data.length : 1, ...meta } }, status) | |
| 56 | +export const err = (code, message, status, extra = {}) => json({ error: { code, message, docs: `https://www.hfmarketdata.io/docs/errors#${code.toLowerCase()}`, ...extra }, detail: message }, status) | |
| 23 | 57 | |
| 24 | 58 | export async function blockExternal(page) { |
| 25 | 59 | await page.route(/fonts\.(googleapis|gstatic)\.com/, r => r.abort()) |
| 26 | 60 | } |
| 27 | 61 | |
| 28 | −/** Anonymous visitor: /v1/me → 401, everything else under /v1 answered by `handler(url, route)` or 404. */ | |
| 62 | +/** Mutating requests must carry the CSRF proofs the server expects (A3). Throws so the spec fails loudly. */ | |
| 63 | +export function assertCsrf(req) { | |
| 64 | + const m = req.method() | |
| 65 | + if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(m)) return | |
| 66 | + const h = req.headers() | |
| 67 | + if (!(h['content-type'] || '').includes('application/json')) throw new Error(`${m} ${req.url()} without JSON content-type`) | |
| 68 | + if (h['x-requested-with'] !== 'hfmd') throw new Error(`${m} ${req.url()} without X-Requested-With: hfmd`) | |
| 69 | + if (req.postData() == null) throw new Error(`${m} ${req.url()} without a body`) | |
| 70 | +} | |
| 71 | + | |
| 72 | +/** Anonymous visitor: /v1/me → 401, everything else under /v1 answered by `handler(url, route, req)` or 404. */ | |
| 29 | 73 | export async function mockAnon(page, handler) { |
| 30 | 74 | await blockExternal(page) |
| 31 | 75 | await page.route('**/v1/**', async route => { |
| 32 | 76 | const url = new URL(route.request().url()) |
| 33 | − if (url.pathname === '/v1/me') return route.fulfill(json({ error: { code: 'AUTH_REQUIRED', message: 'Sign in' } }, 401)) | |
| 34 | − if (handler) { const r = await handler(url, route); if (r) return route.fulfill(r) } | |
| 77 | + if (url.pathname === '/v1/me' && route.request().method() === 'GET') return route.fulfill(err('AUTH_REQUIRED', 'Sign in', 401)) | |
| 78 | + if (handler) { const r = await handler(url, route, route.request()); if (r) return route.fulfill(r) } | |
| 35 | 79 | return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ detail: 'Not Found' }) }) |
| 36 | 80 | }) |
| 37 | 81 | } |
| 38 | 82 | |
| 39 | −/** Signed-in session. `state` is mutable so specs can flip login or grow the keys list. */ | |
| 83 | +/** Signed-in session. `state.user` is mutable so specs can flip login; every mutation is CSRF-checked. */ | |
| 40 | 84 | export async function mockSession(page, state, handler) { |
| 41 | 85 | await blockExternal(page) |
| 86 | + state.csrfErrors = state.csrfErrors || [] | |
| 42 | 87 | await page.route('**/v1/**', async route => { |
| 43 | 88 | const url = new URL(route.request().url()) |
| 44 | 89 | const req = route.request() |
| 90 | + try { assertCsrf(req) } catch (e) { state.csrfErrors.push(e.message) } | |
| 45 | 91 | if (url.pathname === '/v1/me' && req.method() === 'GET') { |
| 46 | − return state.user ? route.fulfill(json({ data: state.user })) : route.fulfill(json({ error: { code: 'AUTH_REQUIRED', message: 'Sign in' } }, 401)) | |
| 92 | + return state.user ? route.fulfill(json(me(state.user))) : route.fulfill(err('AUTH_REQUIRED', 'Sign in', 401)) | |
| 93 | + } | |
| 94 | + if (url.pathname === '/v1/auth/logout') { | |
| 95 | + if (state.logoutFails) return route.fulfill(err('UNSUPPORTED_MEDIA_TYPE', 'Send JSON', 415)) | |
| 96 | + state.user = null | |
| 97 | + return route.fulfill(ok({ status: 'signed_out' })) | |
| 47 | 98 | } |
| 48 | − if (url.pathname === '/v1/auth/logout') { state.user = null; return route.fulfill(json({ ok: true })) } | |
| 49 | 99 | if (handler) { const r = await handler(url, route, req); if (r) return route.fulfill(r) } |
| 50 | 100 | return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ detail: 'Not Found' }) }) |
| 51 | 101 | }) |
| 52 | 102 | |