Files
TREK/server/tests/e2e/wanderer.e2e.test.ts
T
Maurice 782e613506 Tours: day GPX import, wanderer trails and planning over an import
- GPX files drop onto the map or a day; the import lands on that day
- wanderer connection per user in Integrations; the workspace offers the
  user's own trails only, behind an admin switch under the Tours addon
- wanderer difficulty is stored on the SAC scale (T1 to T3), backfilled
- a GPX or wanderer import can be planned as an editable tour, either in
  place (the recorded track is kept and can be restored) or as a new tour
  that remembers the import it came from
- rails, rows and detail dialog follow the plan sidebar's cards and badges
- admin addon shelves with more than one row fold, collapsed by default
- reverse-geocoded waypoint names are cached on the device
2026-10-15 17:44:15 +02:00

393 lines
16 KiB
TypeScript

/**
* wanderer e2e: the personal connection (/api/integrations/wanderer) and the
* trip-side search, preview and import (/api/trips/:tripId/tours/wanderer)
* through the real JwtAuthGuard, AddonGuard, WandererEnabledGuard and
* TripAccessGuard, over the real services and repositories on a migrated temp
* SQLite db handed to them.
*
* Replaced: `WandererClient`, so no test ever opens a socket to an instance
* (each method is a spy answering from the fixtures below), `checkSsrf`, whose
* DNS lookup is not what this file is about, the permission check (a spy) and
* the journey hooks. Broadcasts go to a FakeRealtimeService.
*/
import { Places } from '../../src/db/entities/Places.entity';
import { TourSources } from '../../src/db/entities/TourSources.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
import { WandererConnections } from '../../src/db/entities/WandererConnections.entity';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { ToursModule } from '../../src/nest/tours/tours.module';
import { WandererClient, WandererError, type WandererSearchHit } from '../../src/nest/wanderer/wanderer.client';
import { WandererModule } from '../../src/nest/wanderer/wanderer.module';
import { createSnapshotTestDb } from '../helpers/db-mock';
import { countRows, deleteRows, findRow, insertRows } from '../helpers/factories/rows';
import { setAddonEnabled, setAppSetting } from '../helpers/factories/settings';
import { addTripMember } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { FakeRealtimeService } from '../helpers/fake-realtime';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
}));
const { checkSsrf } = vi.hoisted(() => ({ checkSsrf: vi.fn() }));
vi.mock('../../src/utils/ssrfGuard', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/utils/ssrfGuard')>()),
checkSsrf,
}));
const { journeyHooks } = vi.hoisted(() => ({
journeyHooks: {
reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
onPlaceUpdated: vi.fn().mockResolvedValue(undefined),
onPlaceDeleted: vi.fn().mockResolvedValue(undefined),
},
}));
/** A migrated temp database handed to the code under test, rather than a mocked database module. */
const db = createSnapshotTestDb();
const OWNER = 1;
const VIEWER = 2;
const OUTSIDER = 3;
const TOKEN = 'wanderer_key_abcdef';
const HIT: WandererSearchHit = {
id: 'ukzj2hazx6dgzam',
name: 'Partnachklamm',
description: 'Through the gorge',
location: 'Garmisch',
distance: 2000,
duration: 3600,
elevation_gain: 150,
elevation_loss: 150,
difficulty: 1,
public: false,
author_name: 'maurice',
domain: 'wanderer.example.org',
polyline: '_p~iF~ps|U_ulLnnqC',
};
const GPX = `<?xml version="1.0"?>
<gpx version="1.1" creator="wanderer"><trk><name>Partnachklamm</name><trkseg>
<trkpt lat="47.45" lon="11.1"><ele>700</ele></trkpt>
<trkpt lat="47.46" lon="11.11"><ele>800</ele></trkpt>
<trkpt lat="47.47" lon="11.12"><ele>850</ele></trkpt>
</trkseg></trk></gpx>`;
const RECORD = {
id: HIT.id,
name: HIT.name,
description: HIT.description,
gpx: 'track.gpx',
updated: '2026-10-01',
public: false,
author: 'actor1',
};
const realtime = new FakeRealtimeService();
const client = {
check: vi.fn(),
ownActors: vi.fn(),
search: vi.fn(),
searchById: vi.fn(),
trail: vi.fn(),
gpx: vi.fn(),
};
let orm: TestOrm;
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
let checkPermission: MockInstance;
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [
await TestUnitOfWorkModule.forRoot(db),
await createTestMikroOrmModule(db),
RealtimeModule,
WandererModule,
ToursModule,
],
})
.overrideProvider(RealtimeService)
.useValue(realtime)
.overrideProvider(JourneyDomainService)
.useValue(journeyHooks)
.overrideProvider(WandererClient)
.useValue(client)
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalPipes(new ZodValidationPipe());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
const connect = (userId = OWNER) =>
request(server)
.put('/api/integrations/wanderer/settings')
.set(as(userId))
.send({ url: 'https://wanderer.example.org/', token: TOKEN });
const search = (body: Record<string, unknown> = {}, userId = OWNER, tripId = 5) =>
request(server).post(`/api/trips/${tripId}/tours/wanderer/search`).set(as(userId)).send(body);
const preview = (trailId = HIT.id, userId = OWNER) =>
request(server).get(`/api/trips/5/tours/wanderer/trails/${trailId}`).set(as(userId));
const importTrail = (contentHash: string, userId = OWNER) =>
request(server).post('/api/trips/5/tours/wanderer/import').set(as(userId)).send({ trailId: HIT.id, contentHash });
beforeAll(async () => {
orm = await createTestOrm(db);
await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 });
await makeUser(orm, { id: 2, username: 'viewer', email: 'viewer@example.test', role: 'user', password_version: 0 });
await makeUser(orm, {
id: 3,
username: 'outsider',
email: 'outsider@example.test',
role: 'user',
password_version: 0,
});
await insertRows(orm, Trips, [{ id: 5, user: 1, title: 'Alps' }]);
await addTripMember(orm, 5, 2);
app = await build();
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
server = app.getHttpServer();
});
beforeEach(async () => {
vi.clearAllMocks();
checkPermission.mockImplementation(
(action: string, _role: string, _owner: number, userId: number) => !(action === 'place_edit' && userId === VIEWER),
);
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false });
client.check.mockResolvedValue({ trailCount: 3 });
client.ownActors.mockResolvedValue(['actor1']);
client.search.mockResolvedValue({ hits: [HIT], total: 1 });
client.searchById.mockResolvedValue(HIT);
client.trail.mockResolvedValue(RECORD);
client.gpx.mockResolvedValue(GPX);
await setAddonEnabled(orm, 'tours', true);
await setAppSetting(orm, 'tours_wanderer_enabled', 'true');
await deleteRows(orm, TourSources);
await deleteRows(orm, Places);
await deleteRows(orm, WandererConnections);
});
afterAll(async () => {
await app.close();
await orm.close();
});
describe('wanderer e2e', () => {
describe('the connection', () => {
it('RS-03: saves a connection, keeps the token encrypted and never hands it back', async () => {
expect((await connect()).body).toEqual({ success: true });
const row = await findRow(orm, WandererConnections, { user: OWNER });
expect(row?.token).toBeTruthy();
expect(row?.token).not.toContain(TOKEN);
const res = await request(server).get('/api/integrations/wanderer/settings').set(as(OWNER));
expect(res.body).toMatchObject({ url: 'https://wanderer.example.org', tokenMasked: '••••••••', connected: true });
expect(JSON.stringify(res.body)).not.toContain(TOKEN);
});
it('RS-03: a private address is saved with a warning, an invalid one refused', async () => {
checkSsrf.mockResolvedValueOnce({ allowed: false, isPrivate: true, resolvedIp: '10.0.0.5' });
expect((await connect()).body).toEqual({ success: true, warning: 'private_ip' });
checkSsrf.mockResolvedValueOnce({ allowed: false, isPrivate: false, error: 'Invalid URL' });
const refused = await connect();
expect(refused.status).toBe(400);
expect(refused.body).toMatchObject({ code: 'invalid_url' });
});
it('RS-03: a new host without a new token drops the stored one', async () => {
await connect();
await request(server)
.put('/api/integrations/wanderer/settings')
.set(as(OWNER))
.send({ url: 'https://other.example.org' });
expect((await findRow(orm, WandererConnections, { user: OWNER }))?.token).toBeNull();
});
it('RS-03: the test answers with the reason in the body and notes it on the saved connection', async () => {
await connect();
expect((await request(server).post('/api/integrations/wanderer/test').set(as(OWNER)).send({})).body).toEqual({
ok: true,
trailCount: 3,
});
client.check.mockRejectedValueOnce(new WandererError('unauthorized', 'no'));
expect((await request(server).post('/api/integrations/wanderer/test').set(as(OWNER)).send({})).body).toEqual({
ok: false,
code: 'unauthorized',
});
expect((await request(server).get('/api/integrations/wanderer/settings').set(as(OWNER))).body).toMatchObject({
lastCheckOk: false,
lastCheckError: 'unauthorized',
});
});
it('RS-03: disconnecting forgets only the caller connection', async () => {
await connect(OWNER);
await connect(VIEWER);
expect((await request(server).delete('/api/integrations/wanderer/settings').set(as(OWNER))).status).toBe(200);
expect(await countRows(orm, WandererConnections, { user: OWNER })).toBe(0);
expect(await countRows(orm, WandererConnections, { user: VIEWER })).toBe(1);
});
it('404 for every wanderer route while the admin has not allowed it under Tours, or Tours is off', async () => {
await connect();
await setAppSetting(orm, 'tours_wanderer_enabled', 'false');
const settings = await request(server).get('/api/integrations/wanderer/settings').set(as(OWNER));
expect(settings.status).toBe(404);
expect(settings.body).toMatchObject({ code: 'disabled' });
expect((await search()).status).toBe(404);
await setAppSetting(orm, 'tours_wanderer_enabled', 'true');
await setAddonEnabled(orm, 'tours', false);
expect((await search()).status).toBe(404);
expect(client.search).not.toHaveBeenCalled();
});
});
describe('search, preview and import', () => {
it('RS-04: 409 not_connected before a connection exists', async () => {
const res = await search();
expect(res.status).toBe(409);
expect(res.body).toMatchObject({ code: 'not_connected' });
});
it('RS-04/MF-02: searches the caller own trails, in the map box when asked, and marks what is in the trip', async () => {
await connect();
const box = { south: 47, west: 11, north: 48, east: 12 };
const res = await search({ q: 'klamm', bbox: box });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
total: 1,
trails: [{ id: HIT.id, name: 'Partnachklamm', difficulty: 'moderate', importedAs: [] }],
});
expect(client.search).toHaveBeenCalledWith(
expect.objectContaining({ token: TOKEN }),
{ q: 'klamm', page: 1, bbox: box },
['actor1'],
);
});
it('RS-04: only who may add places to the trip looks; an outsider learns nothing of it', async () => {
await connect(VIEWER);
expect((await search({}, VIEWER)).status).toBe(403);
expect((await search({}, OUTSIDER)).status).toBe(404);
});
it('RS-05: previews, then imports what was previewed as a snapshot with its provenance', async () => {
await connect();
const shown = await preview();
expect(shown.status).toBe(200);
expect(shown.body).toMatchObject({ state: 'new', hasElevation: true, points: 3 });
const res = await importTrail(shown.body.contentHash);
expect(res.status).toBe(201);
expect(res.body.tour).toMatchObject({
name: 'Partnachklamm',
has_waypoints: false,
difficulty: 'moderate',
max_hiking_difficulty: 2,
});
const placeId = res.body.tour.place_id as number;
expect(await findRow(orm, TourSources, { place: placeId })).toMatchObject({
provider: 'wanderer',
external_id: HIT.id,
});
// Imported once, the same content is not imported again.
const again = await importTrail(shown.body.contentHash);
expect(again.status).toBe(409);
expect(again.body).toMatchObject({ code: 'already_imported' });
expect((await search()).body.trails[0].importedAs).toEqual([placeId]);
});
it('RS-05: a fingerprint that no longer matches the trail imports nothing', async () => {
await connect();
const res = await importTrail('0'.repeat(64));
expect(res.status).toBe(409);
expect(res.body).toMatchObject({ code: 'preview_stale' });
expect(await countRows(orm, Places)).toBe(0);
});
it('RS-06: a changed trail previews as changed and comes in as a new snapshot beside the old one', async () => {
await connect();
const first = await importTrail((await preview()).body.contentHash);
client.trail.mockResolvedValue({ ...RECORD, name: 'Partnachklamm (new route)' });
const changed = await preview();
expect(changed.body).toMatchObject({ state: 'changed', snapshot: { placeId: first.body.tour.place_id } });
expect(changed.body.changes).toContain('name');
const second = await importTrail(changed.body.contentHash);
expect(second.status).toBe(201);
expect(second.body.tour.place_id).not.toBe(first.body.tour.place_id);
expect(await countRows(orm, Places)).toBe(2);
});
it('RS-05: a trail without a track or route is refused before anything is written', async () => {
await connect();
client.gpx.mockResolvedValue('<?xml version="1.0"?><gpx version="1.1"><wpt lat="47" lon="11"/></gpx>');
const res = await preview();
expect(res.status).toBe(422);
expect(res.body).toMatchObject({ code: 'no_track' });
expect(await countRows(orm, Places)).toBe(0);
});
it('RS-05: a trail without heights comes in marked to use with caution', async () => {
await connect();
client.gpx.mockResolvedValue(GPX.replace(/<ele>\d+<\/ele>/g, ''));
client.trail.mockResolvedValue({ ...RECORD, description: ' ' });
const shown = await preview();
expect(shown.body).toMatchObject({ hasElevation: false, elevationGainM: null });
const res = await importTrail(shown.body.contentHash);
expect(res.body.tour).toMatchObject({ caution: true, description: null });
});
it('RS-06: says whether a snapshot was edited here since its import, and when nothing changed there', async () => {
await connect();
const imported = await importTrail((await preview()).body.contentHash);
expect((await preview()).body).toMatchObject({
state: 'unchanged',
changes: [],
snapshot: { editedLocally: false },
});
await request(server)
.put(`/api/trips/5/places/${imported.body.tour.place_id}`)
.set(as(OWNER))
.send({ name: 'Renamed here' });
expect((await preview()).body).toMatchObject({ state: 'unchanged', snapshot: { editedLocally: true } });
});
it("someone else's trail is neither previewed nor imported", async () => {
await connect();
client.searchById.mockResolvedValue(null);
expect((await preview()).status).toBe(404);
expect((await importTrail('0'.repeat(64))).status).toBe(404);
expect(client.gpx).not.toHaveBeenCalled();
});
});
});