Files
TREK/server/tests/unit/nest/wanderer.client.test.ts
Maurice 018d2d1425 Tours/wanderer: cover the new service, repository and restore paths
- unit suites for WandererService (every refusal and fallback), the
  wanderer connection and tour source repositories, and restoring a
  recorded track (GPX and wanderer imports, empty or missing track, 404s)
- client cases for a body that breaks off and the trail count fallbacks
- an import whose realtime notification fails still stands (e2e)
- the search controller passes the validated body on; the schema already
  defaults q and page, so the fallbacks there could never run
2026-10-15 20:15:17 +02:00

290 lines
11 KiB
TypeScript

/**
* WandererClient: what it puts on the wire and what it makes of the answers.
*
* `safeFetch` and `readCappedJson` are mocked (the SSRF guard does a real DNS
* lookup, the capped reader wants a real stream); the reader stub still parses
* the bytes the reply carries, so a non-JSON answer stays a real parse failure.
*/
import {
WandererClient,
WandererError,
bboxFilter,
difficultyOf,
instanceOrigin,
isTrailId,
type WandererCreds,
} from '../../../src/nest/wanderer/wanderer.client';
import { ResponseTooLargeError, SsrfBlockedError } from '../../../src/utils/ssrfGuard';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { safeFetchMock, readCappedJsonMock } = vi.hoisted(() => ({
safeFetchMock: vi.fn(),
readCappedJsonMock: vi.fn(),
}));
vi.mock('../../../src/utils/ssrfGuard', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../src/utils/ssrfGuard')>()),
safeFetch: safeFetchMock,
}));
vi.mock('../../../src/utils/cappedFetch', () => ({ readCappedJson: readCappedJsonMock }));
const CREDS: WandererCreds = {
baseUrl: 'https://wanderer.example.org/api/v1/',
token: 'wanderer_key_abc',
allowInsecureTls: false,
};
interface Reply {
ok: boolean;
status: number;
text(): Promise<string>;
}
function reply(body: unknown, status = 200): Reply {
const raw = typeof body === 'string' ? body : JSON.stringify(body);
return { ok: status >= 200 && status < 300, status, text: async () => raw };
}
let calls: Array<{ url: string; init: RequestInit; options: { rejectUnauthorized?: boolean; maxBytes?: number } }> = [];
/** Answers by path, so the order of the calls a method makes does not matter. */
function answer(routes: Record<string, Reply | ((url: string) => Reply)>): void {
safeFetchMock.mockImplementation(
async (url: string, init: RequestInit, options: { rejectUnauthorized?: boolean }) => {
calls.push({ url, init, options });
const path = new URL(url).pathname;
const route = routes[path];
if (!route) throw new Error(`unexpected call to ${path}`);
return (typeof route === 'function' ? route(url) : route) as unknown as Response;
},
);
}
const OWNER = {
'/api/v1/api-token': reply({ items: [{ user: 'gixauk8ukjpqabm' }], totalItems: 1 }),
'/api/v1/user/gixauk8ukjpqabm': reply({
expand: {
activitypub_actors_via_user: [
{ id: 'nspsn7zu7octact', is_local: true },
{ id: 'remote1', is_local: false },
],
},
}),
};
const HIT = {
id: 'ukzj2hazx6dgzam',
name: 'Partnachklamm',
description: 'Gorge',
location: 'Garmisch',
distance: 19_700,
duration: 18_000,
elevation_gain: 929,
elevation_loss: 929,
difficulty: 1,
public: true,
author_name: 'maurice',
domain: 'wanderer.example.org',
polyline: '_p~iF~ps|U_ulLnnqC',
};
function searchBody(index: number): { q: string; options: { filter?: string[]; limit?: number; offset?: number } } {
return JSON.parse(String(calls.filter((call) => call.url.endsWith('/search/trails'))[index].init.body));
}
async function failure(run: () => Promise<unknown>): Promise<WandererError> {
try {
await run();
} catch (err) {
if (err instanceof WandererError) return err;
throw err;
}
throw new Error('expected a WandererError');
}
beforeEach(() => {
calls = [];
safeFetchMock.mockReset();
readCappedJsonMock.mockReset().mockImplementation(async (response: Reply) => JSON.parse(await response.text()));
});
describe('WandererClient', () => {
const client = new WandererClient();
it('RS-03: sends the token as a bearer header to the instance origin, never in the URL, with TLS checked', async () => {
answer({ ...OWNER, '/api/v1/search/trails': reply({ hits: [], estimatedTotalHits: 4 }) });
await expect(client.check(CREDS)).resolves.toEqual({ trailCount: 4 });
for (const call of calls) {
expect(call.url.startsWith('https://wanderer.example.org/api/v1/')).toBe(true);
expect(call.url).not.toContain('wanderer_key_abc');
expect((call.init.headers as Record<string, string>).Authorization).toBe('Bearer wanderer_key_abc');
expect(call.options.rejectUnauthorized).toBe(true);
expect(call.init.signal).toBeInstanceOf(AbortSignal);
}
// The count is of the owner's own trails only.
expect(searchBody(0).options.filter).toEqual(["author IN ['nspsn7zu7octact']"]);
});
it('RS-03: a token wanderer reads as anonymous is refused, however 200 the answer', async () => {
answer({ '/api/v1/api-token': reply({ items: [], totalItems: 0 }) });
expect((await failure(() => client.check(CREDS))).code).toBe('unauthorized');
});
it('RS-03: an owner without a local author, or with an odd id, is an answer TREK does not trust', async () => {
answer({
...OWNER,
'/api/v1/user/gixauk8ukjpqabm': reply({
expand: { activitypub_actors_via_user: [{ id: 'remote1', is_local: false }] },
}),
});
expect((await failure(() => client.ownActors(CREDS))).code).toBe('invalid_response');
answer({ '/api/v1/api-token': reply({ items: [{ user: 'Not-An-Id!' }] }) });
expect((await failure(() => client.ownActors(CREDS))).code).toBe('invalid_response');
});
it('RS-04/MF-02: searches the owner trails, a page at a time, in the box when one is given', async () => {
answer({ '/api/v1/search/trails': reply({ hits: [HIT], totalHits: 31 }) });
const box = { south: 47, west: 11, north: 48, east: 12 };
await expect(client.search(CREDS, { q: 'klamm', page: 2, bbox: box }, ['nspsn7zu7octact'])).resolves.toMatchObject({
total: 31,
hits: [HIT],
});
const body = searchBody(0);
expect(body.q).toBe('klamm');
expect(body.options).toMatchObject({ limit: 20, offset: 20 });
expect(body.options.filter).toEqual(["author IN ['nspsn7zu7octact']", bboxFilter(box)]);
});
it('MF-02: a box over the antimeridian takes either side of it', () => {
expect(bboxFilter({ south: -20, west: 170, north: -10, east: -170 })).toBe(
'max_lat >= -20 AND min_lat <= -10 AND (max_lon >= 170 OR min_lon <= -170)',
);
expect(bboxFilter({ south: 47, west: 11, north: 48, east: 12 })).toBe(
'max_lat >= 47 AND min_lat <= 48 AND max_lon >= 11 AND min_lon <= 12',
);
});
it('RS-05: finds one of the owner trails by id, and never puts an odd id into a filter', async () => {
answer({ '/api/v1/search/trails': reply({ hits: [HIT] }) });
await expect(client.searchById(CREDS, 'ukzj2hazx6dgzam', ['nspsn7zu7octact'])).resolves.toEqual(HIT);
expect(searchBody(0).options.filter).toEqual(["id = 'ukzj2hazx6dgzam'", "author IN ['nspsn7zu7octact']"]);
await expect(client.searchById(CREDS, "x' OR 1=1", ['nspsn7zu7octact'])).resolves.toBeNull();
expect(calls).toHaveLength(1);
});
it('RS-05: reads a trail record and its original GPX', async () => {
answer({
'/api/v1/trail/ukzj2hazx6dgzam': reply({
id: 'ukzj2hazx6dgzam',
name: 'Partnachklamm',
gpx: 'track.gpx',
author: 'nspsn7zu7octact',
}),
'/api/v1/files/trails/ukzj2hazx6dgzam/track.gpx': reply('<gpx></gpx>'),
});
const trail = await client.trail(CREDS, 'ukzj2hazx6dgzam');
expect(trail.author).toBe('nspsn7zu7octact');
await expect(client.gpx(CREDS, trail)).resolves.toBe('<gpx></gpx>');
expect(calls[1].options.maxBytes).toBe(10 * 1024 * 1024);
});
it('RS-05: a trail without a file, or an id that is no record id, is not found', async () => {
answer({});
expect((await failure(() => client.trail(CREDS, '../etc'))).code).toBe('not_found');
expect((await failure(() => client.gpx(CREDS, { id: 'abc', name: 'x', gpx: null }))).code).toBe('not_found');
expect(calls).toHaveLength(0);
});
it('RS-03: tells each way an instance can fail apart', async () => {
const statusOf = async (status: number, body = '') => {
answer({ '/api/v1/trail/abc': reply(body, status) });
return (await failure(() => client.trail(CREDS, 'abc'))).code;
};
expect(await statusOf(401)).toBe('unauthorized');
expect(await statusOf(500, 'Failed to verify API token: Invalid or revoked API token')).toBe('unauthorized');
expect(await statusOf(403)).toBe('forbidden');
expect(await statusOf(404)).toBe('not_found');
expect(await statusOf(502, 'Bad gateway')).toBe('server_error');
answer({ '/api/v1/trail/abc': reply('<html>login</html>') });
expect((await failure(() => client.trail(CREDS, 'abc'))).code).toBe('invalid_response');
answer({ '/api/v1/trail/abc': reply({ unexpected: true }) });
expect((await failure(() => client.trail(CREDS, 'abc'))).code).toBe('invalid_response');
});
it('RS-03: transport failures name the guard, the certificate, the size or the reach', async () => {
const transport = async (error: unknown) => {
safeFetchMock.mockRejectedValueOnce(error);
return (await failure(() => client.trail(CREDS, 'abc'))).code;
};
expect(await transport(new SsrfBlockedError('private'))).toBe('blocked_address');
expect(
await transport(Object.assign(new Error('fetch failed'), { cause: { code: 'DEPTH_ZERO_SELF_SIGNED_CERT' } })),
).toBe('tls');
expect(await transport(new ResponseTooLargeError(1))).toBe('too_large');
expect(await transport(new Error('ECONNREFUSED'))).toBe('unreachable');
});
it('RS-03: a body that breaks off while it is read is a reach failure, for JSON and for the GPX', async () => {
answer({ '/api/v1/trail/abc': reply({ id: 'abc', name: 'x' }) });
readCappedJsonMock.mockRejectedValueOnce(new Error('socket hang up'));
expect((await failure(() => client.trail(CREDS, 'abc'))).code).toBe('unreachable');
answer({
'/api/v1/files/trails/abc/track.gpx': {
ok: true,
status: 200,
text: async () => {
throw new Error('socket hang up');
},
},
});
expect((await failure(() => client.gpx(CREDS, { id: 'abc', name: 'x', gpx: 'track.gpx' }))).code).toBe(
'unreachable',
);
});
it('RS-03: the test counts the owner trails from either total, and still connects when the count fails', async () => {
const countWith = async (search: Reply) => {
answer({ ...OWNER, '/api/v1/search/trails': search });
return (await client.check(CREDS)).trailCount;
};
expect(await countWith(reply({ hits: [], estimatedTotalHits: 7 }))).toBe(7);
expect(await countWith(reply({ hits: [], totalHits: 5 }))).toBe(5);
expect(await countWith(reply({ hits: [] }))).toBeNull();
expect(await countWith(reply('Bad gateway', 502))).toBeNull();
});
it('RS-03: a self-signed certificate is accepted only when the user allowed it', async () => {
answer({ '/api/v1/trail/abc': reply({ id: 'abc', name: 'x' }) });
await client.trail({ ...CREDS, allowInsecureTls: true }, 'abc');
expect(calls[0].options.rejectUnauthorized).toBe(false);
});
});
describe('wanderer client helpers', () => {
it('reads the origin from whatever was pasted', () => {
expect(instanceOrigin('https://w.example.org/')).toBe('https://w.example.org');
expect(instanceOrigin(' https://w.example.org/api ')).toBe('https://w.example.org');
expect(instanceOrigin('https://w.example.org/sub/api/v1')).toBe('https://w.example.org/sub');
});
it('knows a record id and the three difficulties', () => {
expect(isTrailId('ukzj2hazx6dgzam')).toBe(true);
expect(isTrailId('UPPER')).toBe(false);
expect(isTrailId('')).toBe(false);
expect([0, 1, 2, 3, null].map((value) => difficultyOf(value))).toEqual([
'easy',
'moderate',
'difficult',
null,
null,
]);
});
});