diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dea09251..0350297c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,5 +57,8 @@ jobs: - name: Install dependencies run: npm ci + - name: Run tests + run: npm test + - name: Build run: npm run build diff --git a/src/api/__tests__/client.test.ts b/src/api/__tests__/client.test.ts index 6535d917..02a39ab1 100644 --- a/src/api/__tests__/client.test.ts +++ b/src/api/__tests__/client.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createUser, setDmndAccountId } from '../client'; +import { API_ERROR_MESSAGES } from '../errorMessages'; import { DmndApiError } from '../types'; interface Call { @@ -63,11 +64,55 @@ test('a network failure retries up to the limit then throws a network error', as await assert.rejects( () => client.login('m@x.io', 'pw'), - (e: unknown) => e instanceof DmndApiError && e.code === 'network', + (e: unknown) => + e instanceof DmndApiError && + e.code === 'network' && + e.message === "We couldn't connect. Check your internet connection and try again.", ); assert.equal(calls.length, 3); }); +test('a dead session cookie reported as a 400 still surfaces as an expired session', async () => { + const body = JSON.stringify({ code: 'bad-request', message: 'Unauthorized. User ID cookie not found or invalid.' }); + const { fetchImpl, calls } = fakeFetch(() => new Response(body, { status: 400 })); + const client = createUser({ fetchImpl, backoffMs: 0 }); + + await assert.rejects( + () => client.checkAuth(), + (e: unknown) => + e instanceof DmndApiError && e.code === 'unauthorized' && e.message === API_ERROR_MESSAGES.unauthorized, + ); + assert.equal(calls.length, 1, 'a dead cookie is final, not retried'); +}); + +test('a rejected referral code reported as a 500 keeps its own message and does not retry', async () => { + const body = JSON.stringify({ code: 'internal-error', message: 'Invalid referral code' }); + const { fetchImpl, calls } = fakeFetch(() => new Response(body, { status: 500 })); + const client = createUser({ fetchImpl, backoffMs: 0 }); + + await assert.rejects( + () => client.signup({ email: 'm@x.io', password: 'pw', firstName: 'Ada', lastName: 'Lovelace', referralCode: 'NOPE' }), + (e: unknown) => + e instanceof DmndApiError && e.code === 'other' && e.message === 'Invalid referral code', + ); + assert.equal(calls.length, 1, 'the code cannot become valid on a retry'); +}); + +test('a 5xx retries and surfaces a user-friendly message without implementation details', async () => { + const { fetchImpl, calls } = fakeFetch(() => new Response('', { status: 500 })); + const client = createUser({ fetchImpl, backoffMs: 0, maxAttempts: 2 }); + + await assert.rejects( + () => client.login('m@x.io', 'pw'), + (e: unknown) => + e instanceof DmndApiError && + e.code === 'server' && + e.message === "We couldn't complete your request right now. Please try again in a moment." && + !/DMND|500|server error/i.test(e.message), + ); + assert.equal(calls.length, 2); +}); + test('resetPassword posts email, code, two_fa_token and new_password (snake_case)', async () => { const { fetchImpl, calls } = fakeFetch(() => new Response('', { status: 200 })); const client = createUser({ fetchImpl, backoffMs: 0 }); @@ -219,7 +264,7 @@ test('getSubaccountSummary GETs the per-subaccount summary with a token and the } }); -test('getSubaccountWorkers GETs the per-subaccount workers with a token', async () => { +test('getSubaccountWorkers GETs the per-subaccount live workers endpoint', async () => { const { fetchImpl, calls } = fakeFetch(() => jsonResponse({ workers: [], next_cursor: null })); const client = createUser({ fetchImpl, backoffMs: 0 }); @@ -228,6 +273,27 @@ test('getSubaccountWorkers GETs the per-subaccount workers with a token', async assert.equal(calls[0].init.method, 'GET'); assert.ok(calls[0].url.includes('/api/user/sub_account/-77/workers')); assert.ok(calls[0].url.includes('token=sub-tok')); + assert.equal(calls[0].init.credentials, 'include'); +}); + +test('getSubaccountWorkers follows pagination on the live per-subaccount endpoint', async () => { + const first = { name: 'first', hashrate: 1, total_shares: 0, rejected_shares: 0, is_connected: true }; + const second = { name: 'second', hashrate: 2, total_shares: 0, rejected_shares: 0, is_connected: true }; + const { fetchImpl, calls } = fakeFetch(({ url }) => + jsonResponse( + url.includes('cursor=page-2') + ? { workers: [second], next_cursor: null } + : { workers: [first], next_cursor: 'page-2' }, + ), + ); + const client = createUser({ fetchImpl, backoffMs: 0 }); + + const result = await client.getSubaccountWorkers('-77', 'sub-tok'); + + assert.deepEqual(result, { workers: [first, second], next_cursor: null }); + assert.equal(calls.length, 2); + assert.ok(calls.every((call) => call.url.includes('/api/user/sub_account/-77/workers'))); + assert.ok(calls[1].url.includes('cursor=page-2')); }); test('getGeneratedBtc GETs the generated_btc list with the X-Account-ID header', async () => { @@ -345,7 +411,7 @@ test('a 4xx with a server message surfaces it as an unknown error', async () => await assert.rejects( () => client.login('a@b.co', 'pw'), - (e: unknown) => e instanceof DmndApiError && e.code === 'unknown' && e.message === 'Add another word or two', + (e: unknown) => e instanceof DmndApiError && e.code === 'other' && e.message === 'Add another word or two', ); }); diff --git a/src/api/__tests__/watcherClient.test.ts b/src/api/__tests__/watcherClient.test.ts index d9e3b699..a06eea92 100644 --- a/src/api/__tests__/watcherClient.test.ts +++ b/src/api/__tests__/watcherClient.test.ts @@ -37,6 +37,26 @@ test('getWorkers passes the token in the query and sends NO cookie or account he assert.equal(headers['X-Account-ID'], undefined); }); +test('getWorkers follows pagination until the complete watcher roster is loaded', async () => { + const first = { name: 'first', hashrate: 1, total_shares: 0, rejected_shares: 0, is_connected: true }; + const second = { name: 'second', hashrate: 2, total_shares: 0, rejected_shares: 0, is_connected: true }; + const { fetchImpl, calls } = fakeFetch(({ url }) => + jsonResponse( + url.includes('cursor=page-2') + ? { workers: [second], next_cursor: null } + : { workers: [first], next_cursor: 'page-2' }, + ), + ); + const client = createWatcherClient('TOK', { fetchImpl }); + + const result = await client.getWorkers(); + + assert.deepEqual(result, { workers: [first, second], next_cursor: null }); + assert.equal(calls.length, 2); + assert.ok(calls[1].url.includes('cursor=page-2')); + assert.ok(calls[1].url.includes('token=TOK')); +}); + test('getHashrate passes the token and sends no credentials', async () => { const { fetchImpl, calls } = fakeFetch(() => jsonResponse({ pplns_hashrate: 1, fpps_hashrate: 2, total_hashrate: 3 }), @@ -100,6 +120,19 @@ test('a 401 (revoked or wrong token) surfaces as an unauthorized error', async ( await assert.rejects(() => client.getWorkers(), (e: unknown) => e instanceof Error); }); +test('a watcher server failure does not expose an HTTP status or implementation detail', async () => { + const { fetchImpl } = fakeFetch(() => new Response('', { status: 500 })); + const client = createWatcherClient('TOK', { fetchImpl }); + + await assert.rejects( + () => client.getWorkers(), + (e: unknown) => + e instanceof Error && + e.message === "We couldn't load this Watcher view. Please try again in a moment." && + !/500|request failed/i.test(e.message), + ); +}); + test('a non-array historical response collapses to an empty series', async () => { const { fetchImpl } = fakeFetch(() => jsonResponse({ not: 'an array' })); const client = createWatcherClient('TOK', { fetchImpl }); diff --git a/src/api/client.ts b/src/api/client.ts index a5e1ec38..e3050835 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -20,6 +20,7 @@ import { type Worker, type WorkersResponse, } from './types'; +import { API_ERROR_MESSAGES } from './errorMessages'; // The DMND dashboard API is called directly: it sets CORS for our origin and // allows credentials, so the browser sends the HttpOnly session cookie on every @@ -145,7 +146,7 @@ async function request( let lastError: unknown = null; for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) { - if (req.signal?.aborted) throw new DmndApiError('Request cancelled', 'network'); + if (req.signal?.aborted) throw new DmndApiError(API_ERROR_MESSAGES.cancelled, 'network'); const headers: Record = { 'Content-Type': 'application/json' }; if (accountId && !spec.omitAccountId) headers['X-Account-ID'] = accountId; @@ -154,31 +155,35 @@ async function request( const response = await opts.fetchImpl(`${API_BASE}${spec.path}`, { method: spec.method, headers, - // DMND auth is cookie-based; send the session cookie on every call. The - // proxy relays the login Set-Cookie back (de-Secured in dev). + // Authentication is cookie-based, so direct calls to the configured API + // origin must include the HttpOnly session cookie. credentials: 'include', body: spec.body === undefined ? undefined : JSON.stringify(spec.body), signal: combineSignals(spec.timeoutMs ?? opts.requestTimeoutMs, req.signal), }); if (response.status === 401 || response.status === 403) { - throw new DmndApiError((await readErrorMessage(response)) ?? 'Not authorized', 'unauthorized'); + throw new DmndApiError((await readErrorMessage(response)) ?? API_ERROR_MESSAGES.unauthorized, 'unauthorized'); + } + const serverMessage = response.ok ? undefined : await readErrorMessage(response); + if (response.status === 400 && serverMessage === 'Unauthorized. User ID cookie not found or invalid.') { + throw new DmndApiError(API_ERROR_MESSAGES.unauthorized, 'unauthorized'); } if (response.status >= 500) { - lastError = new DmndApiError(`DMND server error (${response.status})`, 'server'); + if (serverMessage === 'Invalid referral code') { + throw new DmndApiError("Invalid referral code", 'other'); + } + lastError = new DmndApiError(API_ERROR_MESSAGES.server, 'server'); } else if (!response.ok) { // 4xx with a server message (e.g. weak password) surfaces that message. - throw new DmndApiError( - (await readErrorMessage(response)) ?? 'Something went wrong. Please try again.', - 'unknown', - ); + throw new DmndApiError(serverMessage || 'Something went wrong. Please try again.', 'other'); } else { const text = await response.text(); return (text ? JSON.parse(text) : undefined) as T; } } catch (err) { // Auth and client errors are final; only transient failures retry. - if (err instanceof DmndApiError && (err.code === 'unauthorized' || err.code === 'unknown')) { + if (err instanceof DmndApiError && (err.code === 'unauthorized' || err.code === 'other')) { throw err; } lastError = err; @@ -190,7 +195,7 @@ async function request( } if (lastError instanceof DmndApiError) throw lastError; - throw new DmndApiError('Cannot reach DMND API server', 'network'); + throw new DmndApiError(API_ERROR_MESSAGES.network, 'network'); } export function createUser(options: DmndClientOptions = {}): DmndClient { @@ -387,13 +392,27 @@ export function createUser(options: DmndClientOptions = {}): DmndClient { req, ); }, - getSubaccountWorkers(id, token, req) { - const q = new URLSearchParams({ token }).toString(); - return request( - { method: 'GET', path: `/api/user/sub_account/${encodeURIComponent(id)}/workers?${q}` }, - opts, - req, - ); + async getSubaccountWorkers(id, token, req) { + const workers: Worker[] = []; + const seen = new Set(); + let cursor: string | null = null; + for (;;) { + const params = new URLSearchParams({ token, limit: '1000' }); + if (cursor) params.set('cursor', cursor); + const page = await request( + { + method: 'GET', + path: `/api/user/sub_account/${encodeURIComponent(id)}/workers?${params.toString()}`, + }, + opts, + req, + ); + workers.push(...page.workers); + if (!page.next_cursor || page.workers.length === 0 || seen.has(page.next_cursor)) break; + seen.add(page.next_cursor); + cursor = page.next_cursor; + } + return { workers, next_cursor: null }; }, async getSubaccountGeneratedBtc(id, token, req) { // Bare array like the main /api/generated_btc; the same non-array collapse guards diff --git a/src/api/errorMessages.ts b/src/api/errorMessages.ts new file mode 100644 index 00000000..fbf12829 --- /dev/null +++ b/src/api/errorMessages.ts @@ -0,0 +1,7 @@ +export const API_ERROR_MESSAGES = Object.freeze({ + cancelled: 'The request was cancelled. Please try again.', + network: "We couldn't connect. Check your internet connection and try again.", + server: "We couldn't complete your request right now. Please try again in a moment.", + unauthorized: 'Your session has expired. Please sign in again.', + watcher: "We couldn't load this Watcher view. Please try again in a moment.", +}); diff --git a/src/api/index.ts b/src/api/index.ts index 228ec90d..69fbb4e4 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -10,3 +10,4 @@ export type { } from './types'; export { createUser, getUser, setDmndClient, setDmndAccountId } from './client'; export type { DmndClientOptions } from './client'; +export { API_ERROR_MESSAGES } from './errorMessages'; diff --git a/src/api/types.ts b/src/api/types.ts index 2a1e798c..c52f647d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,4 +1,4 @@ -export type DmndApiErrorCode = 'unauthorized' | 'network' | 'server' | 'unknown'; +export type DmndApiErrorCode = 'unauthorized' | 'network' | 'server' | 'other'; export class DmndApiError extends Error { constructor( @@ -305,7 +305,7 @@ export interface DmndClient { getSubaccounts(req?: RequestOptions): Promise; /** Per-subaccount hashrate, share stats, fees, and today's BTC in one response. */ getSubaccountSummary(id: string, token: string, req?: RequestOptions): Promise; - /** Per-subaccount worker roster; active/offline counts derive from this. */ + /** Per-subaccount live worker roster. */ getSubaccountWorkers(id: string, token: string, req?: RequestOptions): Promise; /** The subaccount's daily generated-BTC entries; a bare array, empty when none. */ getSubaccountGeneratedBtc(id: string, token: string, req?: RequestOptions): Promise; diff --git a/src/api/watcherClient.ts b/src/api/watcherClient.ts index 20575889..2e675735 100644 --- a/src/api/watcherClient.ts +++ b/src/api/watcherClient.ts @@ -1,4 +1,5 @@ import { API_BASE } from './client'; +import { API_ERROR_MESSAGES } from './errorMessages'; import type { GeneratedBtcEntry, HashratePoint, HashrateSnapshot, SubaccountFees, WorkersResponse } from './types'; /** @@ -31,15 +32,29 @@ export function createWatcherClient(token: string, options: WatcherClientOptions throw new Error('This Watcher link is no longer valid.'); } if (!response.ok) { - throw new Error(`Watcher request failed (${response.status})`); + throw new Error(API_ERROR_MESSAGES.watcher); } const text = await response.text(); return (text ? JSON.parse(text) : undefined) as T; } return { - getWorkers(signal) { - return get('/api/workers/all', { limit: '1000' }, signal); + async getWorkers(signal) { + const workers: WorkersResponse['workers'] = []; + const seen = new Set(); + let cursor: string | null = null; + + for (;;) { + const params: Record = { limit: '1000' }; + if (cursor) params.cursor = cursor; + const page = await get('/api/workers/all', params, signal); + workers.push(...page.workers); + if (!page.next_cursor || page.workers.length === 0 || seen.has(page.next_cursor)) break; + seen.add(page.next_cursor); + cursor = page.next_cursor; + } + + return { workers, next_cursor: null }; }, getHashrate(signal) { return get('/api/user/hashrate', {}, signal); diff --git a/src/auth/__tests__/authError.test.ts b/src/auth/__tests__/authError.test.ts new file mode 100644 index 00000000..1691e474 --- /dev/null +++ b/src/auth/__tests__/authError.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { API_ERROR_MESSAGES, DmndApiError } from '@/api'; +import { authErrorMessage } from '@/components/auth/authError'; + +test('authErrorMessage presents network and server failures without internal terminology', () => { + const network = authErrorMessage(new DmndApiError('technical network detail', 'network')); + const server = authErrorMessage(new DmndApiError('DMND server error (500)', 'server')); + + assert.equal(network, API_ERROR_MESSAGES.network); + assert.equal(server, API_ERROR_MESSAGES.server); + assert.doesNotMatch(`${network} ${server}`, /DMND|500|server error/i); +}); + +test('authErrorMessage preserves actionable validation and screen-specific authorization copy', () => { + assert.equal(authErrorMessage(new DmndApiError('Add another word', 'other')), 'Add another word'); + assert.equal( + authErrorMessage(new DmndApiError('internal auth detail', 'unauthorized'), 'Incorrect email or password.'), + 'Incorrect email or password.', + ); +}); diff --git a/src/auth/__tests__/resetErrors.test.ts b/src/auth/__tests__/resetErrors.test.ts index d4e8cfba..9ca5ff54 100644 --- a/src/auth/__tests__/resetErrors.test.ts +++ b/src/auth/__tests__/resetErrors.test.ts @@ -1,23 +1,23 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { DmndApiError } from '@/api'; +import { API_ERROR_MESSAGES, DmndApiError } from '@/api'; import { isTwoFactorRequiredError } from '../resetErrors'; test('isTwoFactorRequiredError is true only for the exact backend 2FA message', () => { - assert.equal(isTwoFactorRequiredError(new DmndApiError('Invalid 2FA token', 'unknown')), true); + assert.equal(isTwoFactorRequiredError(new DmndApiError('Invalid 2FA token', 'other')), true); }); test('isTwoFactorRequiredError is false for near-misses and other failures', () => { // Strings the old regex matched are now correctly rejected (exact match only). - assert.equal(isTwoFactorRequiredError(new DmndApiError('invalid-token', 'unknown')), false); + assert.equal(isTwoFactorRequiredError(new DmndApiError('invalid-token', 'other')), false); assert.equal(isTwoFactorRequiredError(new DmndApiError('two-factor required', 'unauthorized')), false); - assert.equal(isTwoFactorRequiredError(new DmndApiError('invalid 2fa token', 'unknown')), false); // case differs + assert.equal(isTwoFactorRequiredError(new DmndApiError('invalid 2fa token', 'other')), false); // case differs assert.equal( - isTwoFactorRequiredError(new DmndApiError("This email doesn't have an account", 'unknown')), + isTwoFactorRequiredError(new DmndApiError("This email doesn't have an account", 'other')), false, ); - assert.equal(isTwoFactorRequiredError(new DmndApiError('Cannot reach DMND API server', 'network')), false); + assert.equal(isTwoFactorRequiredError(new DmndApiError(API_ERROR_MESSAGES.network, 'network')), false); assert.equal(isTwoFactorRequiredError(new Error('Invalid 2FA token')), false); // not a DmndApiError assert.equal(isTwoFactorRequiredError(null), false); }); diff --git a/src/components/auth/authError.ts b/src/components/auth/authError.ts index e7d7ae5e..501e8c17 100644 --- a/src/components/auth/authError.ts +++ b/src/components/auth/authError.ts @@ -1,4 +1,4 @@ -import { DmndApiError } from '@/api'; +import { API_ERROR_MESSAGES, DmndApiError } from '@/api'; /** * Turns a failed DMND call into a short, user-facing line. The unauthorized @@ -8,12 +8,10 @@ import { DmndApiError } from '@/api'; export function authErrorMessage(error: unknown, unauthorized = 'Not authorized.'): string { if (error instanceof DmndApiError) { if (error.code === 'unauthorized') return unauthorized; - if (error.code === 'network') { - return "Can't reach DMND right now. Check your connection and try again."; - } - if (error.code === 'server') return 'DMND is having trouble. Please try again in a moment.'; - // 'unknown' carries the server's own message (e.g. a password-strength hint). - if (error.code === 'unknown' && error.message) return error.message; + if (error.code === 'network') return API_ERROR_MESSAGES.network; + if (error.code === 'server') return API_ERROR_MESSAGES.server; + // 'other' carries the server's own message (e.g. a password-strength hint). + if (error.code === 'other' && error.message) return error.message; } return 'Something went wrong. Please try again.'; } diff --git a/src/pages/auth/BrokerSignUp.tsx b/src/pages/auth/BrokerSignUp.tsx index 412e8fc4..27455933 100644 --- a/src/pages/auth/BrokerSignUp.tsx +++ b/src/pages/auth/BrokerSignUp.tsx @@ -11,6 +11,7 @@ import { PasswordField } from '@/components/auth/PasswordField'; import { PasswordStrengthMeter } from '@/components/auth/PasswordStrengthMeter'; import { SignupStepper } from '@/components/auth/SignupStepper'; import { AuthSubmit } from '@/components/auth/AuthSubmit'; +import { authErrorMessage } from '@/components/auth/authError'; import { useToast } from '@/components/ui/toast'; import { Input } from '@/components/ui/input'; import { DmndApiError } from '@/api'; @@ -190,8 +191,8 @@ function PasswordStep({ toast({ type: 'error', message: 'An account with this email already exists.' }); return; } - if (message) setServerPwError(message); - else toast({ type: 'error', message: 'Unable to create account. Please try again.' }); + if (e instanceof DmndApiError && e.code === 'other' && message) setServerPwError(message); + else toast({ type: 'error', message: authErrorMessage(e, 'Unable to create account. Please try again.') }); return; } toast({ type: 'success', message: 'Account created successfully.' }); diff --git a/src/pages/auth/ResetPassword.tsx b/src/pages/auth/ResetPassword.tsx index 4f89ddf0..d4a6f939 100644 --- a/src/pages/auth/ResetPassword.tsx +++ b/src/pages/auth/ResetPassword.tsx @@ -119,9 +119,9 @@ function EmailStep({ onBack, onNext }: { onBack: () => void; onNext: (email: str toast({ type: 'success', message: 'Password reset link sent.' }); onNext(values.email); } catch (e) { - // An unknown email is a 4xx ('unknown'); show the designer's copy. Only + // An unknown email is a 4xx ('other'); show the designer's copy. Only // connectivity/server faults fall back to the generic message. - if (e instanceof DmndApiError && e.code === 'unknown') { + if (e instanceof DmndApiError && e.code === 'other') { setServerError("This email doesn't have an account"); toast({ type: 'error', message: "This email doesn't have an account" }); } else { diff --git a/src/pages/auth/SignUp.tsx b/src/pages/auth/SignUp.tsx index 907824c8..97903b88 100644 --- a/src/pages/auth/SignUp.tsx +++ b/src/pages/auth/SignUp.tsx @@ -11,6 +11,7 @@ import { PasswordField } from '@/components/auth/PasswordField'; import { PasswordStrengthMeter } from '@/components/auth/PasswordStrengthMeter'; import { SignupStepper } from '@/components/auth/SignupStepper'; import { AuthSubmit } from '@/components/auth/AuthSubmit'; +import { authErrorMessage } from '@/components/auth/authError'; import { useToast } from '@/components/ui/toast'; import { Input } from '@/components/ui/input'; import { DmndApiError } from '@/api'; @@ -195,8 +196,8 @@ function PasswordStep({ } // Server validation (e.g. a weak password) is surfaced through the meter; // anything unrecognised falls back to a generic toast. - if (message) setServerPwError(message); - else toast({ type: 'error', message: 'Unable to create account. Please try again.' }); + if (e instanceof DmndApiError && e.code === 'other' && message) setServerPwError(message); + else toast({ type: 'error', message: authErrorMessage(e, 'Unable to create account. Please try again.') }); return; } // Phase 2: account created. Show the success screen; the user signs in from