/** * Dawarich e2e — drives /api/integrations/dawarich through the REAL AddonGuard * and the REAL JwtAuthGuard, over the real DI-native services (DatabaseModule + * DawarichModule) against a temp SQLite db carrying the full schema. * * Two things are replaced and nothing else: * * - `AddonsService.isAddonEnabled`, because the gate is what is under test and * an admin toggle is not worth seeding for it; * - `DawarichClient`, because a test must never open a socket to somebody's * location archive. Every method is a spy that fails loudly if a route this * file exercises turns out to reach upstream after all. * * Covered: the 404-before-401 ordering of the addon gate, the unauthenticated * 401, the shape of a connection nobody has configured, the two URL rejections * that need no DNS, the suggestions envelope's `connected: false`, and the * cross-user isolation — someone else's suggestion is invisible and untouchable * through your own session, and answers the same 404 a missing one does. The * bucket-list and Atlas tools of DawarichAtlasMcp run next to their routes on * the same container, so the two channels are held to the same answers. */ import { db } from '../../src/db/database'; import { BucketList } from '../../src/db/entities/BucketList.entity'; import { DawarichConnections } from '../../src/db/entities/DawarichConnections.entity'; import { DawarichVisitSuggestions } from '../../src/db/entities/DawarichVisitSuggestions.entity'; import { VisitedCountries } from '../../src/db/entities/VisitedCountries.entity'; import { trekMcpErrorMapper } from '../../src/mcp/nest-mcp-policy'; import type { McpContext, McpTextResult } from '../../src/nest-mcp'; import { AddonsService } from '../../src/nest/addons/addons.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { withRequestContext } from '../../src/nest/database/request-context'; import { DawarichAtlasMcp } from '../../src/nest/integrations/dawarich-atlas.mcp'; import { DawarichClient, DawarichError } from '../../src/nest/integrations/dawarich.client'; import { DawarichModule } from '../../src/nest/integrations/dawarich.module'; import { createUser } from '../helpers/factories'; import { makeBucketListItem } from '../helpers/factories/atlas'; import { countRows, deleteRows, findRow, insertRow } from '../helpers/factories/rows'; import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; import { sessionCookie } from './harness'; import { MikroORM } from '@mikro-orm/core'; import { Test } from '@nestjs/testing'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import request from 'supertest'; import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb } = await import('../helpers/db-mock'); const db = createSnapshotTestDb(); return { db, closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, isOwner: () => false, }; }); const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) })); let orm: TestOrm; /** * Every outbound call an instance would ever receive, stubbed to throw. A route * that unexpectedly reaches upstream fails this suite instead of quietly * hanging on a DNS lookup in CI. */ function makeClientStub() { const boom = (name: string) => vi.fn(() => { throw new Error(`DawarichClient.${name} must not be called from the e2e suite`); }); return { probe: boom('probe'), listVisits: boom('listVisits'), listTracks: boom('listTracks'), listPoints: boom('listPoints'), listVisitedCities: boom('listVisitedCities'), findVisitsNear: boom('findVisitsNear'), }; } /** A suggestion row with only the NOT NULL columns filled in. */ function seedSuggestion(userId: number, sourceVisitId: string, name: string): Promise { return insertRow(orm, DawarichVisitSuggestions, { user: userId, source_visit_id: sourceVisitId, name, lat: 48.8584, lng: 2.2945, started_at: '2026-05-01T10:00:00+02:00', ended_at: '2026-05-01T12:30:00+02:00', duration_minutes: 150, local_date: '2026-05-01', source_hash: 'hash-' + sourceVisitId, }); } describe('Dawarich e2e (real addon gate + real auth guard + real services + temp SQLite)', () => { let server: Server; let app: Awaited>; let ownerId: number; let strangerId: number; let strangerSuggestionId: number; async function build() { const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DawarichModule], }) .overrideProvider(AddonsService) .useValue({ isAddonEnabled }) .overrideProvider(DawarichClient) .useValue(makeClientStub()) .compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); // Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate // by metatype, exactly as they do under buildApp(). nest.useGlobalPipes(new ZodValidationPipe()); await nest.init(); return nest; } beforeAll(async () => { orm = await createTestOrm(db); ownerId = createUser(db as never, { username: 'dawarich-owner', email: 'dawarich-owner@test.example' }).user.id; strangerId = createUser(db as never, { username: 'dawarich-stranger', email: 'dawarich-stranger@test.example' }) .user.id; strangerSuggestionId = await seedSuggestion(strangerId, 'visit-stranger-1', 'Eiffel Tower'); app = await build(); server = app.getHttpServer(); }); beforeEach(async () => { isAddonEnabled.mockReturnValue(true); await deleteRows(orm, DawarichConnections); await deleteRows(orm, DawarichVisitSuggestions, { user: ownerId }); }); afterAll(async () => { await app.close(); await orm.close(); }); // ── Auth + addon gate ──────────────────────────────────────────────────── it('DAWARICH-E2E-001: 401 without a session cookie', async () => { const res = await request(server).get('/api/integrations/dawarich/settings'); expect(res.status).toBe(401); }); it('DAWARICH-E2E-002: a session on a disabled addon gets 404 with the Dawarich label, not a 403 that confirms the route', async () => { isAddonEnabled.mockReturnValue(false); const res = await request(server).get('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId)); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Dawarich addon is not enabled' }); }); it('DAWARICH-E2E-003: the addon gate beats the auth guard — no cookie and no addon is still 404, so an anonymous probe learns nothing', async () => { isAddonEnabled.mockReturnValue(false); const res = await request(server).get('/api/integrations/dawarich/suggestions'); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Dawarich addon is not enabled' }); }); it('DAWARICH-E2E-004: a disabled addon 404s the write routes too, and nothing is persisted', async () => { isAddonEnabled.mockReturnValue(false); const res = await request(server) .put('/api/integrations/dawarich/settings') .set('Cookie', sessionCookie(ownerId)) .send({ url: 'https://dawarich.example', apiKey: 'secret' }); expect(res.status).toBe(404); expect(await countRows(orm, DawarichConnections)).toBe(0); }); it('DAWARICH-E2E-005: the addon flag is re-read per request — switching it back on reopens the route without a restart', async () => { isAddonEnabled.mockReturnValue(false); expect( (await request(server).get('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId))).status, ).toBe(404); isAddonEnabled.mockReturnValue(true); expect( (await request(server).get('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId))).status, ).toBe(200); }); // ── Connection ─────────────────────────────────────────────────────────── it('DAWARICH-E2E-010: GET settings for a user who has never connected answers the empty connection, with the poll defaulted on', async () => { const res = await request(server).get('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId)); expect(res.status).toBe(200); expect(res.body).toEqual({ url: '', // Never the key, and not even a placeholder when there is nothing stored. apiKeyMasked: '', allowInsecureTls: false, // No row yet is a user who has not opened the card; the default there is // "poll once it is connected", not "off". syncEnabled: true, connected: false, lastSyncAt: null, lastSyncState: 'never', lastSyncError: null, capabilities: null, }); }); it('DAWARICH-E2E-011: PUT settings with an unparseable URL is a 400 and writes nothing', async () => { const res = await request(server) .put('/api/integrations/dawarich/settings') .set('Cookie', sessionCookie(ownerId)) .send({ url: 'not a url', apiKey: 'secret' }); expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'Invalid URL' }); // Crucially: the key is not stored on a rejected address either. expect(await countRows(orm, DawarichConnections)).toBe(0); }); it('DAWARICH-E2E-012: PUT settings with a non-HTTP scheme is a 400 — file:// and friends never reach the fetch layer', async () => { const res = await request(server) .put('/api/integrations/dawarich/settings') .set('Cookie', sessionCookie(ownerId)) .send({ url: 'file:///etc/passwd' }); expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'Only HTTP and HTTPS URLs are allowed' }); expect(await countRows(orm, DawarichConnections)).toBe(0); }); it('DAWARICH-E2E-013: PUT settings rejects a body the shared contract does not accept, before any of this runs', async () => { const res = await request(server) .put('/api/integrations/dawarich/settings') .set('Cookie', sessionCookie(ownerId)) .send({ apiKey: 'secret' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/^url: /); }); it('DAWARICH-E2E-014: clearing the address saves the row, keeps the connection unconnected and persists the poll toggle', async () => { const put = await request(server) .put('/api/integrations/dawarich/settings') .set('Cookie', sessionCookie(ownerId)) .send({ url: '', syncEnabled: false }); expect(put.status).toBe(200); expect(put.body).toEqual({ success: true }); const row = await findRow(orm, DawarichConnections, { user: ownerId }); expect(row).toMatchObject({ url: null, api_key: null, sync_enabled: 0 }); const res = await request(server).get('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId)); expect(res.body.connected).toBe(false); expect(res.body.syncEnabled).toBe(false); expect(res.body.apiKeyMasked).toBe(''); }); it('DAWARICH-E2E-015: DELETE settings answers 200 even when there was nothing to disconnect', async () => { const res = await request(server) .delete('/api/integrations/dawarich/settings') .set('Cookie', sessionCookie(ownerId)); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true }); }); it('DAWARICH-E2E-016: POST test with a blank key against a host other than the stored one answers not_connected and never dials out', async () => { // The stored key is the one thing this route must not carry to an address // the form just typed. The client stub throws on every call, so a probe // that did go out would surface as `unreachable` here rather than as the // refusal the form is meant to render. await insertRow(orm, DawarichConnections, { user: ownerId, url: 'https://dawarich.old.example', api_key: 'plain-legacy-key', }); const res = await request(server) .post('/api/integrations/dawarich/test') .set('Cookie', sessionCookie(ownerId)) .send({ url: 'https://dawarich.elsewhere.example' }); expect(res.status).toBe(200); expect(res.body).toMatchObject({ connected: false, error: 'not_connected' }); expect(res.body.errorDetail).toContain('https://dawarich.old.example'); // And the stored connection is untouched by a test, as it always was. expect(await findRow(orm, DawarichConnections, { user: ownerId })).toMatchObject({ url: 'https://dawarich.old.example', api_key: 'plain-legacy-key', }); }); // ── Suggestions ────────────────────────────────────────────────────────── it('DAWARICH-E2E-020: GET suggestions without a connection answers the envelope with connected:false — an empty list alone would read as "no stays"', async () => { const res = await request(server) .get('/api/integrations/dawarich/suggestions') .set('Cookie', sessionCookie(ownerId)); expect(res.status).toBe(200); expect(res.body).toEqual({ suggestions: [], connected: false, lastSyncAt: null, lastSyncState: 'never', lastSyncError: null, }); }); it('DAWARICH-E2E-021: an unknown state filter is a 400 rather than a silently unfiltered list', async () => { const res = await request(server) .get('/api/integrations/dawarich/suggestions?state=pending') .set('Cookie', sessionCookie(ownerId)); expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'state must be one of: new, accepted, dismissed' }); }); it('DAWARICH-E2E-022: a tripId that only starts with digits is a 400', async () => { const res = await request(server) .get('/api/integrations/dawarich/suggestions?tripId=12abc') .set('Cookie', sessionCookie(ownerId)); expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'Invalid tripId' }); }); // ── Cross-user isolation ───────────────────────────────────────────────── it("DAWARICH-E2E-030: another user's suggestion never appears in your own list", async () => { const mine = await seedSuggestion(ownerId, 'visit-mine-1', 'Gare du Nord'); const res = await request(server) .get('/api/integrations/dawarich/suggestions') .set('Cookie', sessionCookie(ownerId)); expect(res.status).toBe(200); const ids = res.body.suggestions.map((s: { id: number }) => s.id); expect(ids).toEqual([mine]); expect(ids).not.toContain(strangerSuggestionId); // And the row really is there for the other user, so this is isolation and // not an empty table. expect((await findRow(orm, DawarichVisitSuggestions, { id: strangerSuggestionId }))?.user_id).toBe(strangerId); }); it("DAWARICH-E2E-031: PUT state on another user's suggestion is a 404 — the same answer a missing id gets, so nothing is enumerable", async () => { const res = await request(server) .put(`/api/integrations/dawarich/suggestions/${strangerSuggestionId}/state`) .set('Cookie', sessionCookie(ownerId)) .send({ state: 'dismissed' }); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Suggestion not found' }); expect((await findRow(orm, DawarichVisitSuggestions, { id: strangerSuggestionId }))?.state).toBe('new'); const missing = await request(server) .put('/api/integrations/dawarich/suggestions/999999/state') .set('Cookie', sessionCookie(ownerId)) .send({ state: 'dismissed' }); expect(missing.status).toBe(404); expect(missing.body).toEqual(res.body); }); it("DAWARICH-E2E-032: POST accept on another user's suggestion is a 404 with the domain's own code", async () => { const res = await request(server) .post(`/api/integrations/dawarich/suggestions/${strangerSuggestionId}/accept`) .set('Cookie', sessionCookie(ownerId)) .send({ target: 'place', tripId: 1 }); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Suggestion not found', code: 'not_found' }); expect(await findRow(orm, DawarichVisitSuggestions, { id: strangerSuggestionId })).toMatchObject({ state: 'new', target: null, }); }); it('DAWARICH-E2E-033: your own suggestion is reachable through the same route, which is what makes the two 404s above meaningful', async () => { const mine = await seedSuggestion(ownerId, 'visit-mine-2', 'Musée d’Orsay'); const res = await request(server) .put(`/api/integrations/dawarich/suggestions/${mine}/state`) .set('Cookie', sessionCookie(ownerId)) .send({ state: 'dismissed' }); expect(res.status).toBe(200); expect(res.body.id).toBe(mine); expect(res.body.state).toBe('dismissed'); expect((await findRow(orm, DawarichVisitSuggestions, { id: mine }))?.state).toBe('dismissed'); }); // ── Bucket list and Atlas over MCP: the same services, the same answers ── const asUser = (userId: number) => ({ userId, scopes: null, isStaticToken: false }) as McpContext; const viaMcp = (fn: (mcp: DawarichAtlasMcp) => Promise) => withRequestContext(app.get(MikroORM), () => fn(app.get(DawarichAtlasMcp))); const answerOf = (result: McpTextResult) => JSON.parse(result.content[0]?.text ?? ''); it('DAWARICH-E2E-040: a wish ticked off over MCP is ticked off for REST, and only its owner can clear it', async () => { const wish = await makeBucketListItem(orm, ownerId, { name: 'Alhambra', lat: 37.176, lng: -3.588 }); const confirmed = await viaMcp((mcp) => mcp.confirmBucketVisits({ itemIds: [wish.id], visitedAt: '2024-04-02T10:00:00Z' }, asUser(ownerId)), ); expect(answerOf(confirmed)).toEqual({ updated: 1 }); const again = await request(server) .post('/api/integrations/dawarich/bucket-list/confirm') .set('Cookie', sessionCookie(ownerId)) .send({ itemIds: [wish.id] }); expect(again.status).toBe(200); expect(again.body).toEqual({ updated: 0 }); expect(await findRow(orm, BucketList, { id: wish.id })).toMatchObject({ visited_at: '2024-04-02T10:00:00Z', visited_source: 'dawarich', }); const foreign = await viaMcp((mcp) => mcp.clearBucketVisit({ itemId: wish.id }, asUser(strangerId))); expect(foreign).toEqual({ content: [{ type: 'text', text: 'Bucket-list entry not found' }], isError: true }); const foreignRest = await request(server) .delete(`/api/integrations/dawarich/bucket-list/${wish.id}/visit`) .set('Cookie', sessionCookie(strangerId)); expect(foreignRest.status).toBe(404); expect(foreignRest.body).toEqual({ error: 'Bucket-list entry not found' }); expect(answerOf(await viaMcp((mcp) => mcp.clearBucketVisit({ itemId: wish.id }, asUser(ownerId))))).toEqual({ success: true, }); expect(await findRow(orm, BucketList, { id: wish.id })).toMatchObject({ visited_at: null, visited_source: null }); }); it('DAWARICH-E2E-041: countries accepted over MCP land in the Atlas once, as REST then finds them', async () => { const marked = await viaMcp((mcp) => mcp.acceptAtlasCountries({ countryCodes: ['PT', 'es'] }, asUser(ownerId))); expect(answerOf(marked)).toEqual({ marked: 2 }); const again = await request(server) .post('/api/integrations/dawarich/atlas/accept') .set('Cookie', sessionCookie(ownerId)) .send({ countryCodes: ['PT', 'ES'] }); expect(again.status).toBe(200); expect(again.body).toEqual({ marked: 0 }); expect(await countRows(orm, VisitedCountries, { user: ownerId, source: 'dawarich' })).toBe(2); }); it('DAWARICH-E2E-042: without a connection both reads refuse alike, and a backwards window is the route 400 before anything is asked', async () => { const scan = await request(server) .post('/api/integrations/dawarich/bucket-list/scan') .set('Cookie', sessionCookie(ownerId)); expect(scan.status).toBe(400); expect(scan.body).toEqual({ error: 'Dawarich is not connected', code: 'not_connected' }); expect(await viaMcp((mcp) => mcp.scanBucketList({}, asUser(ownerId)))).toEqual({ content: [{ type: 'text', text: 'Dawarich is not connected' }], isError: true, }); const window = { from: '2026-01-01T00:00:00Z', to: '2026-03-01T00:00:00Z' }; const suggestions = await request(server) .get('/api/integrations/dawarich/atlas/suggestions') .query(window) .set('Cookie', sessionCookie(ownerId)); expect(suggestions.status).toBe(400); expect(suggestions.body).toEqual({ error: 'Dawarich is not connected', code: 'not_connected' }); expect(await viaMcp((mcp) => mcp.suggestAtlasCountries(window, asUser(ownerId)))).toEqual({ content: [{ type: 'text', text: 'Dawarich is not connected' }], isError: true, }); const backwards = { from: window.to, to: window.from }; const refused = await request(server) .get('/api/integrations/dawarich/atlas/suggestions') .query(backwards) .set('Cookie', sessionCookie(ownerId)); expect(refused.status).toBe(400); expect(refused.body).toEqual({ error: 'to must be after from' }); const thrown = await viaMcp((mcp) => mcp.suggestAtlasCountries(backwards, asUser(ownerId))).then( () => undefined, (err: unknown) => err, ); expect(thrown).toMatchObject({ status: 400, publicMessage: 'to must be after from' }); // The tool call answers with the route's sentence: the registry maps the refusal to an error result. expect(trekMcpErrorMapper(thrown)).toEqual({ content: [{ type: 'text', text: 'to must be after from' }], isError: true, }); }); it('DAWARICH-E2E-043: an instance that does not answer ends a scan after one lookup: the route 502 and the same sentence over MCP', async () => { await insertRow(orm, DawarichConnections, { user: ownerId, url: 'https://dawarich.example', api_key: 'plain-legacy-key', }); await Promise.all( ['Alhambra', 'Petra'].map((name) => makeBucketListItem(orm, ownerId, { name, lat: 37.176, lng: -3.588 })), ); const findVisitsNear = vi.mocked(app.get(DawarichClient).findVisitsNear); findVisitsNear.mockClear(); const down = () => new DawarichError('unreachable', 'Could not reach Dawarich', undefined, 'ETIMEDOUT'); findVisitsNear.mockRejectedValueOnce(down()).mockRejectedValueOnce(down()); const scan = await request(server) .post('/api/integrations/dawarich/bucket-list/scan') .set('Cookie', sessionCookie(ownerId)); expect(scan.status).toBe(502); expect(scan.body).toEqual({ error: 'Could not reach Dawarich', code: 'unreachable', detail: 'ETIMEDOUT' }); expect(await viaMcp((mcp) => mcp.scanBucketList({}, asUser(ownerId)))).toEqual({ content: [{ type: 'text', text: 'Could not reach Dawarich' }], isError: true, }); // One lookup per channel, not one per wish. expect(findVisitsNear).toHaveBeenCalledTimes(2); await deleteRows(orm, BucketList, { user: ownerId }); }); });