SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

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>
Simon-Pierre Boucher committed 18 days ago (Sep 6, 2026) parent d955c26

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 + // e-mail
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