mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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
This commit is contained in:
@@ -30,7 +30,7 @@ export class ToursWandererController {
|
||||
@HttpCode(200)
|
||||
@ResponseContract(wandererSearchResponseSchema)
|
||||
search(@Param('tripId') tripId: string, @CurrentUser() user: User, @Body() body: WandererSearchDto) {
|
||||
return this.wanderer.search(tripId, user.id, { q: body.q ?? '', page: body.page ?? 1, bbox: body.bbox });
|
||||
return this.wanderer.search(tripId, user.id, { q: body.q, page: body.page, bbox: body.bbox });
|
||||
}
|
||||
|
||||
@RequirePermission('place_edit')
|
||||
|
||||
@@ -381,6 +381,17 @@ describe('wanderer e2e', () => {
|
||||
expect((await preview()).body).toMatchObject({ state: 'unchanged', snapshot: { editedLocally: true } });
|
||||
});
|
||||
|
||||
it('RS-05: an import that is written stays written when the realtime notification fails', async () => {
|
||||
await connect();
|
||||
const shown = await preview();
|
||||
realtime.broadcastMock.mockImplementationOnce(() => {
|
||||
throw new Error('socket closed');
|
||||
});
|
||||
const res = await importTrail(shown.body.contentHash);
|
||||
expect(res.status).toBe(201);
|
||||
expect(await countRows(orm, Places)).toBe(1);
|
||||
});
|
||||
|
||||
it("someone else's trail is neither previewed nor imported", async () => {
|
||||
await connect();
|
||||
client.searchById.mockResolvedValue(null);
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* WandererConnectionsRepository and TourSourcesRepository: a user's wanderer
|
||||
* connection, and the provenance rows of tours imported through it.
|
||||
*/
|
||||
import { TourSources } from '../../../../src/db/entities/TourSources.entity';
|
||||
import { WandererConnections } from '../../../../src/db/entities/WandererConnections.entity';
|
||||
import type { TourSourcesRepository } from '../../../../src/db/repositories/TourSources.repository';
|
||||
import type { WandererConnectionsRepository } from '../../../../src/db/repositories/WandererConnections.repository';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { createPlace, createTrip, createUser } from '../../../helpers/factories';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createTour } from '../../../helpers/tours-repos';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let connections: WandererConnectionsRepository;
|
||||
let sources: TourSourcesRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
connections = t.repo(WandererConnections);
|
||||
sources = t.repo(TourSources);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
t.clear();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('WandererConnectionsRepository', () => {
|
||||
it('WANDREPO-001: no row reads as null', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(await connections.findRow(user.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('WANDREPO-002: saves, keeps the token when none is given, drops it on null, and forgets the check on a new host', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await connections.save(user.id, {
|
||||
url: 'https://a.example',
|
||||
allowInsecureTls: true,
|
||||
token: 'enc-1',
|
||||
hostChanged: false,
|
||||
});
|
||||
expect(await connections.findRow(user.id)).toMatchObject({
|
||||
url: 'https://a.example',
|
||||
token: 'enc-1',
|
||||
allow_insecure_tls: 1,
|
||||
});
|
||||
|
||||
await connections.recordCheck(user.id, false, 'unauthorized');
|
||||
await connections.save(user.id, { url: 'https://a.example/', allowInsecureTls: false, hostChanged: false });
|
||||
expect(await connections.findRow(user.id)).toMatchObject({
|
||||
token: 'enc-1',
|
||||
allow_insecure_tls: 0,
|
||||
last_check_ok: 0,
|
||||
last_check_error: 'unauthorized',
|
||||
});
|
||||
|
||||
await connections.save(user.id, {
|
||||
url: 'https://b.example',
|
||||
allowInsecureTls: false,
|
||||
token: null,
|
||||
hostChanged: true,
|
||||
});
|
||||
expect(await connections.findRow(user.id)).toMatchObject({
|
||||
url: 'https://b.example',
|
||||
token: null,
|
||||
last_checked_at: null,
|
||||
last_check_ok: null,
|
||||
last_check_error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('WANDREPO-003: records a passing check and removes only its own row', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const saveFor = (id: number) =>
|
||||
connections.save(id, { url: 'https://a.example', allowInsecureTls: false, token: 't', hostChanged: false });
|
||||
await saveFor(user.id);
|
||||
await saveFor(other.id);
|
||||
await connections.recordCheck(user.id, true, null);
|
||||
expect(await connections.findRow(user.id)).toMatchObject({ last_check_ok: 1, last_check_error: null });
|
||||
expect((await connections.findRow(user.id))?.last_checked_at).toEqual(expect.any(String));
|
||||
|
||||
await connections.remove(user.id);
|
||||
expect(await connections.findRow(user.id)).toBeNull();
|
||||
expect(await connections.findRow(other.id)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourSourcesRepository', () => {
|
||||
function importedTour(tripId: number, name: string): number {
|
||||
const place = createPlace(testDb, tripId, { name });
|
||||
createTour(testDb, place.id);
|
||||
return place.id;
|
||||
}
|
||||
|
||||
const source = (placeId: number, externalId: string, importedBy: number, contentHash = 'c1') => ({
|
||||
place_id: placeId,
|
||||
provider: 'wanderer',
|
||||
instance: 'https://a.example',
|
||||
external_id: externalId,
|
||||
external_url: `https://a.example/trail/view/@me/${externalId}`,
|
||||
external_updated: null,
|
||||
content_hash: contentHash,
|
||||
local_hash: 'l1',
|
||||
imported_by: importedBy,
|
||||
});
|
||||
|
||||
it('TOURSRC-001: asking about no trails asks nothing', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(await sources.listSnapshots(trip.id, 'wanderer', 'https://a.example', [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('TOURSRC-002: lists the snapshots of the asked trails in this trip and instance, newest first', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const other = createTrip(testDb, user.id);
|
||||
const first = importedTour(trip.id, 'Ridge');
|
||||
const second = importedTour(trip.id, 'Ridge (new)');
|
||||
const foreign = importedTour(other.id, 'Ridge elsewhere');
|
||||
const unasked = importedTour(trip.id, 'Gorge');
|
||||
await sources.insertSource(source(first, 'trail1', user.id, 'c1'));
|
||||
await sources.insertSource(source(second, 'trail1', user.id, 'c2'));
|
||||
await sources.insertSource(source(foreign, 'trail1', user.id));
|
||||
await sources.insertSource(source(unasked, 'trail2', user.id));
|
||||
|
||||
const rows = await sources.listSnapshots(trip.id, 'wanderer', 'https://a.example', ['trail1']);
|
||||
expect(rows.map((row) => [row.place_id, row.content_hash, row.name])).toEqual([
|
||||
[second, 'c2', 'Ridge (new)'],
|
||||
[first, 'c1', 'Ridge'],
|
||||
]);
|
||||
expect(await sources.listSnapshots(trip.id, 'wanderer', 'https://b.example', ['trail1'])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -530,3 +530,92 @@ describe('ToursService updates', () => {
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToursService restoring a recorded track (PR1-05B)', () => {
|
||||
const recorded = [
|
||||
[47.45, 11.1],
|
||||
[47.46, 11.11],
|
||||
[47.47, 11.12],
|
||||
];
|
||||
|
||||
/** A tour planned over in place: waypoints of its own, the import's line kept beside them. */
|
||||
async function plannedOverImport(
|
||||
fields: Partial<Pick<Tours, 'wanderer_ref' | 'difficulty' | 'recorded_route_geometry'>>,
|
||||
) {
|
||||
const created = await service.createTour(tripId, request);
|
||||
const placeId = created.tour.place_id;
|
||||
await updateRows(
|
||||
await sharedTestOrm(testDb),
|
||||
Tours,
|
||||
{ place: placeId },
|
||||
{
|
||||
recorded_route_geometry: JSON.stringify(recorded),
|
||||
...fields,
|
||||
},
|
||||
);
|
||||
(await sharedTestOrm(testDb)).clear();
|
||||
broadcast.mockReset();
|
||||
return String(placeId);
|
||||
}
|
||||
|
||||
it('PR1-05B-SVC-001: a GPX import gets its line back without heights or grade, and loses its waypoints', async () => {
|
||||
const placeId = await plannedOverImport({});
|
||||
broadcast.mockImplementation((_tripId: string, event: string) => {
|
||||
if (event === 'place:updated') throw new Error('socket closed');
|
||||
});
|
||||
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
const restored = await service.restoreRecordedTrack(tripId, placeId, 'socket');
|
||||
|
||||
// Its stored waypoints are gone; the detail names the two ends from the line, as for any import.
|
||||
expect(await count(TourWaypoints)).toBe(0);
|
||||
expect(restored.waypoints.map((point) => point.role)).toEqual(['start', 'end']);
|
||||
expect(restored.tour).toMatchObject({ max_hiking_difficulty: null, has_recorded_track: false });
|
||||
const tour = await findRow(await sharedTestOrm(testDb), Tours, { place: Number(placeId) });
|
||||
expect(tour).toMatchObject({ match_confidence: 0.3, recorded_route_geometry: null });
|
||||
expect(JSON.parse(String((await placeById(Number(placeId)))?.route_geometry))).toEqual(recorded);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('PR1-05B-SVC-002: a wanderer import gets the grade its source gave, on the SAC scale', async () => {
|
||||
const placeId = await plannedOverImport({
|
||||
wanderer_ref: 'https://w.example#trail1',
|
||||
difficulty: 'difficult',
|
||||
recorded_route_geometry: JSON.stringify(recorded.map(([lat, lng], i) => [lat, lng, 700 + i * 50])),
|
||||
});
|
||||
const restored = await service.restoreRecordedTrack(tripId, placeId);
|
||||
expect(restored.tour).toMatchObject({ max_hiking_difficulty: 3 });
|
||||
expect(await findRow(await sharedTestOrm(testDb), Tours, { place: Number(placeId) })).toMatchObject({
|
||||
match_confidence: 1,
|
||||
});
|
||||
expect(broadcast).toHaveBeenCalledWith(tripId, 'tours:changed', { placeIds: [Number(placeId)] }, undefined);
|
||||
});
|
||||
|
||||
it('PR1-05B-SVC-003: no kept track, or an empty one, is a 409 that changes nothing', async () => {
|
||||
const created = await service.createTour(tripId, request);
|
||||
const placeId = String(created.tour.place_id);
|
||||
const refusal = { status: 409, details: { code: 'no_recorded_track' } };
|
||||
await expect(service.restoreRecordedTrack(tripId, placeId)).rejects.toMatchObject(refusal);
|
||||
|
||||
await updateRows(
|
||||
await sharedTestOrm(testDb),
|
||||
Tours,
|
||||
{ place: created.tour.place_id },
|
||||
{ recorded_route_geometry: '[]' },
|
||||
);
|
||||
(await sharedTestOrm(testDb)).clear();
|
||||
await expect(service.restoreRecordedTrack(tripId, placeId)).rejects.toMatchObject(refusal);
|
||||
expect((await service.getTour(tripId, placeId)).waypoints).toEqual(request.waypoints);
|
||||
});
|
||||
|
||||
it('PR1-05B-SVC-004: a plain place, a tour of another trip or a malformed id is a 404', async () => {
|
||||
const plain = createPlace(testDb, Number(tripId), { name: 'Plain' });
|
||||
const foreign = await service.createTour(otherTripId, request);
|
||||
await expect(service.restoreRecordedTrack(tripId, String(plain.id))).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.restoreRecordedTrack(tripId, String(foreign.tour.place_id))).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.restoreRecordedTrack('abc', '1')).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.restoreRecordedTrack(tripId, 'abc')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -230,6 +230,36 @@ describe('WandererClient', () => {
|
||||
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');
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import { WandererConnections } from '../../../src/db/entities/WandererConnections.entity';
|
||||
import type { WandererConnectionsRepository } from '../../../src/db/repositories/WandererConnections.repository';
|
||||
import { AuditService } from '../../../src/nest/audit/audit.service';
|
||||
import { maybe_encrypt_api_key } from '../../../src/nest/common/crypto/apiKeyCrypto';
|
||||
import { DomainError } from '../../../src/nest/common/domain-error';
|
||||
import { WandererClient, WandererError, type WandererSearchHit } from '../../../src/nest/wanderer/wanderer.client';
|
||||
import { WandererModule } from '../../../src/nest/wanderer/wanderer.module';
|
||||
import { WandererService } from '../../../src/nest/wanderer/wanderer.service';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { getRepositoryToken } from '@mikro-orm/nestjs';
|
||||
import { WANDERER_TOKEN_MASK } from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { checkSsrf } = vi.hoisted(() => ({ checkSsrf: vi.fn() }));
|
||||
vi.mock('../../../src/utils/ssrfGuard', () => ({ checkSsrf, safeFetch: vi.fn() }));
|
||||
|
||||
const PUBLIC = { allowed: true, isPrivate: false };
|
||||
const PRIVATE = { allowed: false, isPrivate: true, resolvedIp: '192.168.1.5', error: 'private' };
|
||||
const BLOCKED = { allowed: false, isPrivate: false, error: 'not a public address' };
|
||||
|
||||
type Row = Awaited<ReturnType<WandererConnectionsRepository['findRow']>>;
|
||||
|
||||
function savedRow(over: Partial<NonNullable<Row>> = {}): NonNullable<Row> {
|
||||
return {
|
||||
user_id: 1,
|
||||
url: 'https://trails.example',
|
||||
token: maybe_encrypt_api_key('saved-token'),
|
||||
allow_insecure_tls: 0,
|
||||
last_checked_at: null,
|
||||
last_check_ok: null,
|
||||
last_check_error: null,
|
||||
...over,
|
||||
} as NonNullable<Row>;
|
||||
}
|
||||
|
||||
function hit(over: Partial<WandererSearchHit> = {}): WandererSearchHit {
|
||||
return { id: 'trail1', name: 'Ridge', ...over };
|
||||
}
|
||||
|
||||
const state: { row: Row } = { row: undefined };
|
||||
const connections = {
|
||||
findRow: vi.fn(async () => state.row),
|
||||
save: vi.fn(async () => undefined),
|
||||
remove: vi.fn(async () => undefined),
|
||||
recordCheck: vi.fn(async () => undefined),
|
||||
};
|
||||
const client = {
|
||||
check: vi.fn(),
|
||||
ownActors: vi.fn(),
|
||||
search: vi.fn(),
|
||||
searchById: vi.fn(),
|
||||
trail: vi.fn(),
|
||||
gpx: vi.fn(),
|
||||
};
|
||||
const audit = { writeAudit: vi.fn(async () => undefined) };
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [WandererModule],
|
||||
overrides: [
|
||||
{ provide: getRepositoryToken(WandererConnections), useValue: connections },
|
||||
{ provide: WandererClient, useValue: client },
|
||||
{ provide: AuditService, useValue: audit },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
/** The service over a saved row (or none), every collaborator answering the happy path. */
|
||||
function setup(row: Row = undefined) {
|
||||
state.row = row;
|
||||
for (const mock of [...Object.values(connections), ...Object.values(client), audit.writeAudit]) mock.mockClear();
|
||||
client.check.mockReset().mockResolvedValue({ trailCount: 4 });
|
||||
client.ownActors.mockReset().mockResolvedValue(['actor1']);
|
||||
client.search.mockReset().mockResolvedValue({ hits: [hit()], total: 1 });
|
||||
client.searchById.mockReset().mockResolvedValue(hit());
|
||||
client.trail.mockReset().mockResolvedValue({ id: 'trail1', name: 'Ridge', author: 'actor1' });
|
||||
client.gpx.mockReset().mockResolvedValue('<gpx/>');
|
||||
return { service: t.get(WandererService), connections, client, audit };
|
||||
}
|
||||
|
||||
async function refusal(promise: Promise<unknown>): Promise<DomainError> {
|
||||
const err = await promise.then(
|
||||
() => null,
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(err).toBeInstanceOf(DomainError);
|
||||
return err as DomainError;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
checkSsrf.mockReset();
|
||||
checkSsrf.mockResolvedValue(PUBLIC);
|
||||
});
|
||||
|
||||
describe('WandererService connection card', () => {
|
||||
it('WAND-SVC-001: no saved row reads as an empty, unconnected card', async () => {
|
||||
const { service } = setup();
|
||||
expect(await service.getConnection(1)).toEqual({
|
||||
url: '',
|
||||
tokenMasked: '',
|
||||
allowInsecureTls: false,
|
||||
connected: false,
|
||||
lastCheckedAt: null,
|
||||
lastCheckOk: null,
|
||||
lastCheckError: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('WAND-SVC-002: a saved row shows the mask, never the token, and its last check either way', async () => {
|
||||
const failed = setup(savedRow({ allow_insecure_tls: 1, last_check_ok: 0, last_check_error: 'unauthorized' }));
|
||||
expect(await failed.service.getConnection(1)).toMatchObject({
|
||||
url: 'https://trails.example',
|
||||
tokenMasked: WANDERER_TOKEN_MASK,
|
||||
allowInsecureTls: true,
|
||||
connected: true,
|
||||
lastCheckOk: false,
|
||||
lastCheckError: 'unauthorized',
|
||||
});
|
||||
const passed = setup(savedRow({ last_check_ok: 1 }));
|
||||
expect((await passed.service.getConnection(1)).lastCheckOk).toBe(true);
|
||||
const tokenless = setup(savedRow({ token: null }));
|
||||
expect(await tokenless.service.getConnection(1)).toMatchObject({ tokenMasked: '', connected: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('WandererService.save', () => {
|
||||
it('WAND-SVC-003: a public address that is not allowed is refused as invalid_url', async () => {
|
||||
checkSsrf.mockResolvedValue(BLOCKED);
|
||||
const { service, connections } = setup();
|
||||
const err = await refusal(
|
||||
service.save(1, { url: 'https://bad.example', token: 't', allowInsecureTls: false }, null),
|
||||
);
|
||||
expect(err.getStatus()).toBe(400);
|
||||
expect(connections.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WAND-SVC-004: a first save stores the typed token encrypted and audits a connection', async () => {
|
||||
const { service, connections, audit } = setup();
|
||||
expect(
|
||||
await service.save(1, { url: 'https://trails.example/api', token: 'new', allowInsecureTls: false }, '1.2.3.4'),
|
||||
).toEqual({
|
||||
success: true,
|
||||
});
|
||||
const saved = connections.save.mock.calls[0] as unknown as [
|
||||
number,
|
||||
{ url: string; token: string; hostChanged: boolean },
|
||||
];
|
||||
expect(saved[1].url).toBe('https://trails.example');
|
||||
expect(saved[1].token).not.toBe('new');
|
||||
expect(saved[1].hostChanged).toBe(false);
|
||||
expect(audit.writeAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'wanderer.connected',
|
||||
details: { url: 'https://trails.example', token_changed: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('WAND-SVC-005: a private address saves with a warning and records where it resolved', async () => {
|
||||
checkSsrf.mockResolvedValue(PRIVATE);
|
||||
const { service, audit } = setup(savedRow({ url: 'http://192.168.1.5:3000' }));
|
||||
expect(
|
||||
await service.save(
|
||||
1,
|
||||
{ url: 'http://192.168.1.5:3000', token: WANDERER_TOKEN_MASK, allowInsecureTls: false },
|
||||
null,
|
||||
),
|
||||
).toEqual({
|
||||
success: true,
|
||||
warning: 'private_ip',
|
||||
});
|
||||
expect(audit.writeAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'wanderer.updated',
|
||||
details: expect.objectContaining({ token_changed: false, resolved_ip: '192.168.1.5' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('WAND-SVC-006: the mask keeps the stored token on the same host and drops it on another', async () => {
|
||||
const same = setup(savedRow());
|
||||
await same.service.save(
|
||||
1,
|
||||
{ url: 'https://trails.example', token: WANDERER_TOKEN_MASK, allowInsecureTls: true },
|
||||
null,
|
||||
);
|
||||
expect(same.connections.save).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ token: undefined, hostChanged: false }),
|
||||
);
|
||||
|
||||
const moved = setup(savedRow());
|
||||
await moved.service.save(1, { url: 'https://elsewhere.example', allowInsecureTls: false }, null);
|
||||
expect(moved.connections.save).toHaveBeenCalledWith(1, expect.objectContaining({ token: null, hostChanged: true }));
|
||||
});
|
||||
|
||||
it('WAND-SVC-007: disconnecting removes the row and audits it', async () => {
|
||||
const { service, connections, audit } = setup(savedRow());
|
||||
await service.disconnect(1, '1.2.3.4');
|
||||
expect(connections.remove).toHaveBeenCalledWith(1);
|
||||
expect(audit.writeAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'wanderer.disconnected', ip: '1.2.3.4' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WandererService.test', () => {
|
||||
it('WAND-SVC-008: without an address or a token there is nothing to test', async () => {
|
||||
expect(await setup().service.test(1, {})).toEqual({ ok: false, code: 'not_connected' });
|
||||
// A typed address on another host never borrows the saved token.
|
||||
expect(await setup(savedRow()).service.test(1, { url: 'https://elsewhere.example' })).toEqual({
|
||||
ok: false,
|
||||
code: 'not_connected',
|
||||
});
|
||||
});
|
||||
|
||||
it('WAND-SVC-009: an address the guard refuses answers blocked_address or invalid_url', async () => {
|
||||
checkSsrf.mockResolvedValue(PRIVATE);
|
||||
expect(await setup().service.test(1, { url: 'http://192.168.1.5', token: 't' })).toEqual({
|
||||
ok: false,
|
||||
code: 'blocked_address',
|
||||
});
|
||||
checkSsrf.mockResolvedValue(BLOCKED);
|
||||
expect(await setup().service.test(1, { url: 'https://bad.example', token: 't' })).toEqual({
|
||||
ok: false,
|
||||
code: 'invalid_url',
|
||||
});
|
||||
});
|
||||
|
||||
it('WAND-SVC-010: testing the saved connection notes the outcome on it', async () => {
|
||||
const ok = setup(savedRow({ allow_insecure_tls: 1 }));
|
||||
expect(await ok.service.test(1, {})).toEqual({ ok: true, trailCount: 4 });
|
||||
expect(ok.client.check).toHaveBeenCalledWith({
|
||||
baseUrl: 'https://trails.example',
|
||||
token: 'saved-token',
|
||||
allowInsecureTls: true,
|
||||
});
|
||||
expect(ok.connections.recordCheck).toHaveBeenCalledWith(1, true, null);
|
||||
|
||||
const bad = setup(savedRow());
|
||||
bad.client.check.mockRejectedValueOnce(new WandererError('unauthorized', 'rejected'));
|
||||
expect(await bad.service.test(1, { url: 'https://trails.example' })).toEqual({ ok: false, code: 'unauthorized' });
|
||||
expect(bad.connections.recordCheck).toHaveBeenCalledWith(1, false, 'unauthorized');
|
||||
});
|
||||
|
||||
it('WAND-SVC-011: a typed token is tested without touching the saved state', async () => {
|
||||
const { service, client, connections } = setup(savedRow());
|
||||
client.check.mockRejectedValueOnce(new WandererError('unreachable', 'down'));
|
||||
expect(await service.test(1, { url: 'https://trails.example', token: 'typed', allowInsecureTls: false })).toEqual({
|
||||
ok: false,
|
||||
code: 'unreachable',
|
||||
});
|
||||
expect(client.check).toHaveBeenCalledWith(expect.objectContaining({ token: 'typed', allowInsecureTls: false }));
|
||||
expect(connections.recordCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WAND-SVC-012: an error that is not wanderer answering is not swallowed', async () => {
|
||||
const { service, client } = setup(savedRow());
|
||||
client.check.mockRejectedValueOnce(new TypeError('bug'));
|
||||
await expect(service.test(1, {})).rejects.toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WandererService reads for an import', () => {
|
||||
it.each([
|
||||
['no row', undefined],
|
||||
['no token', savedRow({ token: null })],
|
||||
['no address', savedRow({ url: '' })],
|
||||
])('WAND-SVC-013: a read with %s is a 409 not_connected', async (_case, row) => {
|
||||
expect((await refusal(setup(row).service.search(1, { q: '', page: 1 }))).getStatus()).toBe(409);
|
||||
});
|
||||
|
||||
it('WAND-SVC-013b: the saved connection names its instance', async () => {
|
||||
expect(await setup(savedRow()).service.instanceOf(1)).toBe('https://trails.example');
|
||||
});
|
||||
|
||||
it('WAND-SVC-014: search lists only the own trails, each as a list row', async () => {
|
||||
const { service, client } = setup(savedRow());
|
||||
client.search.mockResolvedValueOnce({
|
||||
hits: [
|
||||
hit({
|
||||
author_name: 'maurice',
|
||||
domain: 'trails.example',
|
||||
description: '',
|
||||
location: 'Alps',
|
||||
duration: 0,
|
||||
difficulty: 1,
|
||||
public: true,
|
||||
polyline: '_p~iF~ps|U_ulLnnqC',
|
||||
}),
|
||||
hit({ id: 'trail2', author_name: 'julia' }),
|
||||
hit({ id: 'trail3', difficulty: 9 }),
|
||||
],
|
||||
total: 3,
|
||||
});
|
||||
const found = await service.search(1, { q: 'ridge', page: 2, bbox: undefined });
|
||||
expect(client.search).toHaveBeenCalledWith(expect.anything(), { q: 'ridge', page: 2, bbox: undefined }, ['actor1']);
|
||||
expect(found.total).toBe(3);
|
||||
const [first, second, third] = found.trails;
|
||||
expect(first).toMatchObject({
|
||||
author: 'maurice@trails.example',
|
||||
description: null,
|
||||
location: 'Alps',
|
||||
durationS: null,
|
||||
difficulty: 'moderate',
|
||||
public: true,
|
||||
externalUrl: 'https://trails.example/trail/view/@maurice%40trails.example/trail1',
|
||||
});
|
||||
expect(first.line.length).toBeGreaterThan(1);
|
||||
expect(second).toMatchObject({ author: 'julia', line: [] });
|
||||
expect(third).toMatchObject({
|
||||
author: null,
|
||||
difficulty: null,
|
||||
public: false,
|
||||
externalUrl: 'https://trails.example/trail/view/@/trail3',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['not_found', 404, 0],
|
||||
['forbidden', 403, 0],
|
||||
['unauthorized', 502, 1],
|
||||
['unreachable', 502, 0],
|
||||
] as const)(
|
||||
'WAND-SVC-015: an upstream %s answers %i, noted on the connection %i times',
|
||||
async (code, status, notes) => {
|
||||
const { service, client, connections } = setup(savedRow());
|
||||
client.ownActors.mockRejectedValueOnce(new WandererError(code, code));
|
||||
expect((await refusal(service.search(1, { q: '', page: 1 }))).getStatus()).toBe(status);
|
||||
expect(connections.recordCheck).toHaveBeenCalledTimes(notes);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['someone-else', null])('WAND-SVC-016: download refuses a trail whose author is %s', async (author) => {
|
||||
const { service, client } = setup(savedRow());
|
||||
client.trail.mockResolvedValueOnce({ id: 'trail1', name: 'Ridge', author });
|
||||
expect((await refusal(service.download(1, 'trail1'))).getStatus()).toBe(403);
|
||||
expect(client.gpx).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WAND-SVC-017: download hands over the own trail with its GPX', async () => {
|
||||
const { service } = setup(savedRow());
|
||||
expect(await service.download(1, 'trail1')).toEqual({
|
||||
instance: 'https://trails.example',
|
||||
trail: { id: 'trail1', name: 'Ridge', author: 'actor1' },
|
||||
gpx: '<gpx/>',
|
||||
});
|
||||
});
|
||||
|
||||
it('WAND-SVC-018: findTrail answers the row, null when the search has none, and maps a failure', async () => {
|
||||
const found = setup(savedRow());
|
||||
expect(await found.service.findTrail(1, 'trail1')).toMatchObject({ id: 'trail1', name: 'Ridge' });
|
||||
const missing = setup(savedRow());
|
||||
missing.client.searchById.mockResolvedValueOnce(null as unknown as WandererSearchHit);
|
||||
expect(await missing.service.findTrail(1, 'trail1')).toBeNull();
|
||||
const failing = setup(savedRow());
|
||||
failing.client.searchById.mockRejectedValueOnce(new WandererError('not_found', 'gone'));
|
||||
expect((await refusal(failing.service.findTrail(1, 'trail1'))).getStatus()).toBe(404);
|
||||
});
|
||||
|
||||
it('WAND-SVC-019: a failure that is not wanderer answering is rethrown as it is', async () => {
|
||||
const { service, client } = setup(savedRow());
|
||||
client.trail.mockRejectedValueOnce(new TypeError('bug'));
|
||||
await expect(service.download(1, 'trail1')).rejects.toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user