Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,8 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Run tests
run: npm test

- name: Build
run: npm run build
72 changes: 69 additions & 3 deletions src/api/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 });

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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',
);
});

Expand Down
33 changes: 33 additions & 0 deletions src/api/__tests__/watcherClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down Expand Up @@ -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 });
Expand Down
55 changes: 37 additions & 18 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -145,7 +146,7 @@ async function request<T>(
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<string, string> = { 'Content-Type': 'application/json' };
if (accountId && !spec.omitAccountId) headers['X-Account-ID'] = accountId;
Expand All @@ -154,31 +155,35 @@ async function request<T>(
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;
Expand All @@ -190,7 +195,7 @@ async function request<T>(
}

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 {
Expand Down Expand Up @@ -387,13 +392,27 @@ export function createUser(options: DmndClientOptions = {}): DmndClient {
req,
);
},
getSubaccountWorkers(id, token, req) {
const q = new URLSearchParams({ token }).toString();
return request<WorkersResponse>(
{ 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<string>();
let cursor: string | null = null;
for (;;) {
const params = new URLSearchParams({ token, limit: '1000' });
if (cursor) params.set('cursor', cursor);
const page = await request<WorkersResponse>(
{
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
Expand Down
7 changes: 7 additions & 0 deletions src/api/errorMessages.ts
Original file line number Diff line number Diff line change
@@ -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.",
});
1 change: 1 addition & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
4 changes: 2 additions & 2 deletions src/api/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type DmndApiErrorCode = 'unauthorized' | 'network' | 'server' | 'unknown';
export type DmndApiErrorCode = 'unauthorized' | 'network' | 'server' | 'other';

export class DmndApiError extends Error {
constructor(
Expand Down Expand Up @@ -305,7 +305,7 @@ export interface DmndClient {
getSubaccounts(req?: RequestOptions): Promise<Subaccount[]>;
/** Per-subaccount hashrate, share stats, fees, and today's BTC in one response. */
getSubaccountSummary(id: string, token: string, req?: RequestOptions): Promise<SubaccountSummary>;
/** Per-subaccount worker roster; active/offline counts derive from this. */
/** Per-subaccount live worker roster. */
getSubaccountWorkers(id: string, token: string, req?: RequestOptions): Promise<WorkersResponse>;
/** The subaccount's daily generated-BTC entries; a bare array, empty when none. */
getSubaccountGeneratedBtc(id: string, token: string, req?: RequestOptions): Promise<GeneratedBtcEntry[]>;
Expand Down
21 changes: 18 additions & 3 deletions src/api/watcherClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { API_BASE } from './client';
import { API_ERROR_MESSAGES } from './errorMessages';
import type { GeneratedBtcEntry, HashratePoint, HashrateSnapshot, SubaccountFees, WorkersResponse } from './types';

/**
Expand Down Expand Up @@ -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<WorkersResponse>('/api/workers/all', { limit: '1000' }, signal);
async getWorkers(signal) {
const workers: WorkersResponse['workers'] = [];
const seen = new Set<string>();
let cursor: string | null = null;

for (;;) {
const params: Record<string, string> = { limit: '1000' };
if (cursor) params.cursor = cursor;
const page = await get<WorkersResponse>('/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<HashrateSnapshot>('/api/user/hashrate', {}, signal);
Expand Down
22 changes: 22 additions & 0 deletions src/auth/__tests__/authError.test.ts
Original file line number Diff line number Diff line change
@@ -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.',
);
});
12 changes: 6 additions & 6 deletions src/auth/__tests__/resetErrors.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading