diff --git a/server/src/nest/files/files.mcp.ts b/server/src/nest/files/files.mcp.ts new file mode 100644 index 000000000..27399cda3 --- /dev/null +++ b/server/src/nest/files/files.mcp.ts @@ -0,0 +1,218 @@ +import { z } from 'zod'; +import { + McpController, Tool, type McpContext, + TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, + demoDenied, errorResult, ok, +} from '../../nest-mcp'; +import { fileLinkRequestSchema, fileUpdateRequestSchema } from '@trek/shared'; +import type { FileLinkRequest, FileUpdateRequest } from '@trek/shared'; +import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; +import { AuthService } from '../auth/auth.service'; +import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; +import { FilesService, FileContentError, FILE_CONTENT_MAX } from './files.service'; + +const CONTENT_MAX_MB = Math.round(FILE_CONTENT_MAX / (1024 * 1024)); + +/** + * Bytes worth handing over as text. Everything else goes back base64: a model + * can pass those on to a converter, but it cannot read them, and decoding a PDF + * as UTF-8 would spend the whole payload on replacement characters. + */ +function isTextual(mimetype: string): boolean { + return mimetype.startsWith('text/') || mimetype === 'application/json'; +} + +/** The MCP wording for a refused content read; the plugin RPC has its own for the same reasons. */ +function contentRefusal(err: FileContentError): string { + if (err.reason === 'too-large') { + return `File is too large to read here (over ${CONTENT_MAX_MB} MB). Ask the user to open it in TREK instead.`; + } + if (err.reason === 'not-accessible') return 'File contents are not available.'; + return 'File not found.'; +} + +/** + * Trip-file MCP surface, mirroring /api/trips/:tripId/files. New rather than + * ported: the domain had no MCP tools at all, so an assistant could see a trip's + * bookings but not the confirmation PDF attached to one. + * + * Each tool reproduces its REST route's gates in the route's own order: trip + * access first (the 404 that keeps a stranger from learning a trip exists), then + * the file_edit right for the writes, then the row lookup scoped by :tripId. The + * two listing tools carry no file_* right because the routes they mirror carry + * none either; trip access is the whole gate there. + * + * There is no upload tool on purpose. MCP has no file transport, and an upload + * faked through base64 would be a second, unpoliced ingestion path beside the + * multipart route with its extension filter and per-type size caps. + */ +@McpController() +export class FilesMcp { + constructor( + private readonly files: FilesService, + private readonly auth: AuthService, + private readonly guards: McpToolGuardsService, + ) {} + + @Tool({ + name: 'list_trip_files', + description: 'List the documents on a trip: name, type, size, who uploaded it, what it is attached to, whether it is starred, and when it was moved to the trash. This is the way to find a file ID; to read what is inside one, follow up with read_trip_file. Set trash to true to list the trip\'s deleted files instead of its live ones.', + inputSchema: { + tripId: z.number().int().positive(), + trash: z.boolean().optional().default(false).describe('List the trash instead of the live files'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'files', mode: 'read' }, + }) + async listTripFiles({ tripId, trash }: { tripId: number; trash?: boolean }, ctx: McpContext) { + if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + return ok({ files: this.files.listFiles(tripId, trash === true) }); + } + + @Tool({ + name: 'read_trip_file', + description: `Read what is inside one uploaded document, e.g. a booking confirmation or a ticket. Text files come back as readable text, anything else base64-encoded, with "encoding" saying which. Files over ${CONTENT_MAX_MB} MB are refused outright, so point the user at the file in TREK rather than retrying. Reading contents is a separate permission from listing files, so this can be refused on a trip where list_trip_files works.`, + inputSchema: { + tripId: z.number().int().positive(), + fileId: z.number().int().positive().describe('File ID from list_trip_files'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + // Not files:read. Listing what a trip carries and reading the bytes of a + // passport scan are different privileges, and the plugin host draws the same + // line (db:read:files vs db:read:files:content). + access: { group: 'files', mode: 'content' }, + }) + async readTripFile({ tripId, fileId }: { tripId: number; fileId: number }, ctx: McpContext) { + if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + try { + const { name, mimetype, bytes } = await this.files.readContent(tripId, fileId); + const asText = isTextual(mimetype); + return ok({ + file: { + id: fileId, + name, + mimetype, + size: bytes.length, + encoding: asText ? 'utf8' : 'base64', + content: bytes.toString(asText ? 'utf8' : 'base64'), + }, + }); + } catch (err) { + if (err instanceof FileContentError) return errorResult(contentRefusal(err)); + throw err; + } + } + + @Tool({ + name: 'update_trip_file', + description: 'Set a file\'s description and attach it to a booking or a place. Fields left out keep their current value, null detaches. place_id and reservation_id are the file\'s primary attachment, the one the file manager shows next to it. To attach one document to several bookings or places at once, use link_trip_file instead.', + inputSchema: { + tripId: z.number().int().positive(), + fileId: z.number().int().positive(), + description: fileUpdateRequestSchema.shape.description.describe('Free-text description, or an empty string to clear it'), + place_id: fileUpdateRequestSchema.shape.place_id.describe('Place on the same trip to attach the file to, or null to detach it'), + reservation_id: fileUpdateRequestSchema.shape.reservation_id.describe('Booking on the same trip to attach the file to, or null to detach it'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'files', mode: 'write' }, + }) + async updateTripFile( + { tripId, fileId, ...fields }: { tripId: number; fileId: number } & FileUpdateRequest, + ctx: McpContext, + ) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!this.guards.hasTripPermission('file_edit', tripId, ctx.userId)) return permissionDenied(); + const current = this.files.getFileById(fileId, tripId); + if (!current) return errorResult('File not found.'); + // Same enforcement as the REST route's assertLinkTargets, through the same + // service method: a foreign reservation id would otherwise come back with its + // title attached the next time this file is listed. + const foreign = this.files.findForeignLinkTarget(tripId, { + reservation_id: fields.reservation_id, + place_id: fields.place_id, + }); + if (foreign) return errorResult(`Linked item does not belong to this trip (${foreign}).`); + // The rest spread carries only the keys the caller actually sent, which is what + // updateFile's presence sentinels need: naming the fields here would hand it an + // undefined description on a link-only call and wipe the description. + const file = this.files.updateFile(fileId, current, fields); + this.guards.safeBroadcast(tripId, 'file:updated', { file }); + return ok({ file }); + } + + @Tool({ + name: 'link_trip_file', + description: 'Attach a file to one more booking, place or day assignment on the same trip, keeping every attachment it already has. Prefer update_trip_file when the document belongs to a single booking or place; use this one for a document that covers several, such as one group ticket or one rental agreement. Returns every link the file now carries.', + inputSchema: { + tripId: z.number().int().positive(), + fileId: z.number().int().positive(), + reservation_id: fileLinkRequestSchema.shape.reservation_id.describe('Booking on the same trip'), + assignment_id: fileLinkRequestSchema.shape.assignment_id.describe('Day assignment (a place scheduled on a specific day) on the same trip'), + place_id: fileLinkRequestSchema.shape.place_id.describe('Place on the same trip'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'files', mode: 'write' }, + }) + async linkTripFile( + { tripId, fileId, ...targets }: { tripId: number; fileId: number } & FileLinkRequest, + ctx: McpContext, + ) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!this.guards.hasTripPermission('file_edit', tripId, ctx.userId)) return permissionDenied(); + if (!this.files.getFileById(fileId, tripId)) return errorResult('File not found.'); + // The REST body allows all three to be absent and stores a link row pointing at + // nothing. A tool caller that gets here with no target made a mistake, and saying + // so is more useful than a success that attached the file to nothing. + if (!targets.reservation_id && !targets.assignment_id && !targets.place_id) { + return errorResult('Pass at least one of reservation_id, assignment_id or place_id.'); + } + const foreign = this.files.findForeignLinkTarget(tripId, targets); + if (foreign) return errorResult(`Linked item does not belong to this trip (${foreign}).`); + const links = this.files.createFileLink(fileId, targets); + return ok({ success: true, links }); + } + + @Tool({ + name: 'unlink_trip_file', + description: 'Remove one attachment between a file and a booking, place or day assignment. The file itself stays on the trip and its other attachments are untouched. Take linkId from list_trip_file_links, it is not the booking or place ID.', + inputSchema: { + tripId: z.number().int().positive(), + fileId: z.number().int().positive(), + linkId: z.number().int().positive().describe('Link ID from list_trip_file_links'), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + access: { group: 'files', mode: 'write' }, + }) + async unlinkTripFile( + { tripId, fileId, linkId }: { tripId: number; fileId: number; linkId: number }, + ctx: McpContext, + ) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!this.guards.hasTripPermission('file_edit', tripId, ctx.userId)) return permissionDenied(); + // deleteFileLink scopes by (linkId, fileId) only, so the file has to be resolved + // against :tripId first, exactly as the REST route does it. Otherwise a member of + // any trip could drop a link row belonging to a foreign trip's file. + if (!this.files.getFileById(fileId, tripId)) return errorResult('File not found.'); + this.files.deleteFileLink(linkId, fileId); + return ok({ success: true }); + } + + @Tool({ + name: 'list_trip_file_links', + description: 'List everything one file is attached to: bookings (with their title), places and day assignments, each with the linkId that unlink_trip_file needs. list_trip_files already reports the linked booking and place IDs, so reach for this one when you need the link IDs themselves.', + inputSchema: { + tripId: z.number().int().positive(), + fileId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'files', mode: 'read' }, + }) + async listTripFileLinks({ tripId, fileId }: { tripId: number; fileId: number }, ctx: McpContext) { + if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!this.files.getFileById(fileId, tripId)) return errorResult('File not found.'); + return ok({ links: this.files.getFileLinks(fileId) }); + } +} diff --git a/server/src/nest/files/files.module.ts b/server/src/nest/files/files.module.ts index 0c2a7f7c5..6542f0230 100644 --- a/server/src/nest/files/files.module.ts +++ b/server/src/nest/files/files.module.ts @@ -3,6 +3,9 @@ import { FilesController } from './files.controller'; import { FilesDownloadController } from './files-download.controller'; import { FilesService } from './files.service'; import { FilesRpc } from './files.rpc'; +import { FilesMcp } from './files.mcp'; +import { AuthModule } from '../auth/auth.module'; +import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; import { PluginGuardsModule } from '../plugins/host/plugin-guards.module'; import { RealtimeModule } from '../realtime/realtime.module'; import { PermissionsModule } from '../permissions/permissions.module'; @@ -33,9 +36,12 @@ import { MAX_VIDEO_SIZE } from './files.constants'; }), }), StorageModule, - EphemeralTokenModule, PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule], + // AuthModule + McpSharedModule feed FilesMcp's demo and RBAC guards. Neither is + // @Global, and AuthModule reaches this domain only through the leaf + // AllowedFileTypesModule, so importing it here stays cycle-free. + EphemeralTokenModule, PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule, AuthModule, McpSharedModule], controllers: [FilesController, FilesDownloadController], - providers: [FilesService, FilesRpc], + providers: [FilesService, FilesRpc, FilesMcp], exports: [FilesService], }) export class FilesModule {} diff --git a/server/src/nest/files/files.rpc.ts b/server/src/nest/files/files.rpc.ts index 8a3a785e2..90b034595 100644 --- a/server/src/nest/files/files.rpc.ts +++ b/server/src/nest/files/files.rpc.ts @@ -11,26 +11,16 @@ import { DatabaseService } from '../database/database.service'; import { readEnv } from '../../app-config'; import { isDemoEmail } from '../common/demo'; import { BLOCKED_EXTENSIONS } from './files.constants'; -import { FilesService } from './files.service'; +// The read cap is the same number on both byte paths, so the upload side of +// this surface reads it off the service that owns the read side. +import { FilesService, FileContentError, FILE_CONTENT_MAX as CONTENT_MAX } from './files.service'; import { StorageService } from '../storage/storage.service'; -import { StorageNotFoundError, StorageInvalidKeyError, type ObjectStat } from '../storage/storage.types'; /** Files use three separate rights, one per operation, unlike every other domain. */ const UPLOAD_ACTION = 'file_upload'; const EDIT_ACTION = 'file_edit'; const DELETE_ACTION = 'file_delete'; -/** Decoded bytes a plugin may read or write in one call. */ -const CONTENT_MAX = 10 * 1024 * 1024; - -type FileRow = { - filename: string; - original_name: string; - mime_type: string | null; - file_size: number | null; - deleted_at: string | null; -}; - type CreateInput = { name: string; content_base64: string; @@ -77,53 +67,22 @@ export class FilesRpc { } /** - * Size-capped BEFORE the read, so a 500MB video cannot be pulled through the IPC - * pipe as ~667MB of base64. The bytes come from the storage layer, so this read - * works on remote backends too. The read itself runs off the event loop: 10MB of - * readFile plus base64 on the host thread would stall every other plugin RPC and - * every HTTP request for its duration. + * The cap, the storage read and the off-thread buffering live on FilesService, + * because the MCP read tool has to obey exactly the same rules and a second + * copy of them would drift. What stays here is the IPC wire shape and the two + * error types the plugin boundary knows how to encode: a refusal the caller + * caused (too large) is BAD_PARAMS, everything else RESOURCE_FORBIDDEN. */ private async readContent(tripId: number, fileId: number): Promise { - const file = this.files.getFileById(fileId, tripId) as FileRow | undefined; - if (!file || file.deleted_at) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`); - if ((file.file_size ?? 0) > CONTENT_MAX) { - throw new BadParams(`file too large to read (>${CONTENT_MAX} bytes); use the download UI`); - } - let stream: Readable; - let stat: ObjectStat; try { - ({ stream, stat } = await this.storage.getStream('files', pathMod.basename(file.filename))); + const { name, mimetype, bytes } = await this.files.readContent(tripId, fileId); + return { name, mimetype, size: bytes.length, content_base64: bytes.toString('base64') }; } catch (err) { - if (err instanceof StorageNotFoundError || err instanceof StorageInvalidKeyError) { - throw new ForbiddenResource('file path is not accessible'); + if (err instanceof FileContentError) { + throw err.reason === 'too-large' ? new BadParams(err.message) : new ForbiddenResource(err.message); } throw err; } - // Re-checked against the OBJECT, not the DB row: file_size can drift. - if (stat.size > CONTENT_MAX) { - stream.destroy(); - throw new BadParams(`file too large to read (>${CONTENT_MAX} bytes); use the download UI`); - } - const chunks: Buffer[] = []; - let total = 0; - for await (const chunk of stream) { - const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array); - total += part.length; - // A driver whose stat under-reports must not push an oversized payload - // through the IPC pipe: abort as soon as the running total crosses the cap. - if (total > CONTENT_MAX) { - stream.destroy(); - throw new BadParams('file too large to read'); - } - chunks.push(part); - } - const buf = Buffer.concat(chunks); - return { - name: file.original_name, - mimetype: file.mime_type ?? 'application/octet-stream', - size: buf.length, - content_base64: buf.toString('base64'), - }; } @PluginMethod('files.create', { permission: 'db:write:files' }) diff --git a/server/src/nest/files/files.service.ts b/server/src/nest/files/files.service.ts index ba0544697..77a17c5db 100644 --- a/server/src/nest/files/files.service.ts +++ b/server/src/nest/files/files.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import path from 'path'; +import type { Readable } from 'node:stream'; import type { Request } from 'express'; import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; import { RealtimeService } from '../realtime/realtime.service'; @@ -11,6 +12,7 @@ import type { User, TripFile } from '../../types'; import { DatabaseService, type TripAccess } from '../database/database.service'; import { DEFAULT_ALLOWED_EXTENSIONS } from './files.constants'; import { StorageService } from '../storage/storage.service'; +import { StorageNotFoundError, StorageInvalidKeyError, type ObjectStat } from '../storage/storage.types'; type Trip = TripAccess; type FilePermission = 'file_upload' | 'file_edit' | 'file_delete'; @@ -37,6 +39,21 @@ export interface FileLink { place_id: number | null; } +/** + * Decoded bytes one non-HTTP caller may pull out of a file in a single read. + * The browser download streams instead and is not bound by this. + */ +export const FILE_CONTENT_MAX = 10 * 1024 * 1024; + +/** Why a content read was refused. Each caller maps it onto its own error shape. */ +export type FileContentRefusal = 'not-found' | 'too-large' | 'not-accessible'; + +export class FileContentError extends Error { + constructor(readonly reason: FileContentRefusal, message: string) { + super(message); + } +} + /** * File domain service — owns the file SQL (moved 1:1 from the legacy * services/fileService.ts: identical statements, the `||` falsy-coercion @@ -146,6 +163,64 @@ export class FilesService { return this.db.get('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL', id, tripId); } + /** + * A file's bytes for a caller that is not the browser download. + * + * Shared by the plugin RPC (ctx.files.getContent) and the MCP read tool so the + * rules exist once. Three of them matter. The size is capped BEFORE the read, + * so a 500MB video is never pulled into memory just to be refused afterwards. + * The bytes come from the storage layer rather than from disk, so this keeps + * working on S3 or a mirrored pair. And the read runs off the event loop: + * 10MB of readFile on the host thread stalls every other request for its + * duration. + * + * Access is the caller's job. REST-side that is trip access plus the file + * row being on the trip, which the getFileById lookup below re-checks. + */ + async readContent( + tripId: string | number, + fileId: string | number, + ): Promise<{ name: string; mimetype: string; bytes: Buffer }> { + const file = this.getFileById(fileId, tripId); + if (!file || file.deleted_at) throw new FileContentError('not-found', `no file ${fileId} on trip ${tripId}`); + if ((file.file_size ?? 0) > FILE_CONTENT_MAX) { + throw new FileContentError('too-large', `file too large to read (>${FILE_CONTENT_MAX} bytes); use the download UI`); + } + let stream: Readable; + let stat: ObjectStat; + try { + ({ stream, stat } = await this.storage.getStream('files', path.basename(file.filename))); + } catch (err) { + if (err instanceof StorageNotFoundError || err instanceof StorageInvalidKeyError) { + throw new FileContentError('not-accessible', 'file path is not accessible'); + } + throw err; + } + // Re-checked against the OBJECT, not the DB row: file_size can drift. + if (stat.size > FILE_CONTENT_MAX) { + stream.destroy(); + throw new FileContentError('too-large', `file too large to read (>${FILE_CONTENT_MAX} bytes); use the download UI`); + } + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of stream) { + const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array); + total += part.length; + // A driver whose stat under-reports must not hand back an oversized + // payload: abort as soon as the running total crosses the cap. + if (total > FILE_CONTENT_MAX) { + stream.destroy(); + throw new FileContentError('too-large', 'file too large to read'); + } + chunks.push(part); + } + return { + name: file.original_name, + mimetype: file.mime_type ?? 'application/octet-stream', + bytes: Buffer.concat(chunks), + }; + } + listFiles(tripId: string | number, showTrash: boolean) { const where = showTrash ? 'f.trip_id = ? AND f.deleted_at IS NOT NULL' : 'f.trip_id = ? AND f.deleted_at IS NULL'; const files = this.db.all(`${FILE_SELECT} WHERE ${where} ORDER BY f.starred DESC, f.created_at DESC`, tripId); diff --git a/server/tests/unit/files/files.rpc.test.ts b/server/tests/unit/files/files.rpc.test.ts index 8d8ad1f08..e4b905f78 100644 --- a/server/tests/unit/files/files.rpc.test.ts +++ b/server/tests/unit/files/files.rpc.test.ts @@ -17,7 +17,7 @@ import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service'; import { FilesRpc } from '../../../src/nest/files/files.rpc'; import { FilesModule } from '../../../src/nest/files/files.module'; -import type { FilesService } from '../../../src/nest/files/files.service'; +import { FilesService } from '../../../src/nest/files/files.service'; import type { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import type { DatabaseService } from '../../../src/nest/database/database.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; @@ -60,6 +60,12 @@ function build(opts: { file?: Record | undefined; foreign?: str })), put: vi.fn(async () => undefined), } as unknown as StorageService & Record>; + // readContent moved onto FilesService (the MCP read tool obeys the same cap and + // the same storage rules), and the RPC only maps its refusals onto the two wire + // error types. Bind the real implementation onto this double so the cases below + // keep exercising that code rather than a stub of it. + (files as unknown as { readContent: FilesService['readContent'] }).readContent = + FilesService.prototype.readContent.bind({ getFileById: files.getFileById, storage } as unknown as FilesService); const rpc = new FilesRpc(files, realtime, db, guards, storage); const host = (...grants: string[]) => new PluginRpcHost('p', new Set(grants), makeDeps(), createTestPluginRegistry([rpc])); return { files, realtime, permissions, storage, host }; @@ -112,6 +118,15 @@ describe('FilesRpc reads', () => { expect(res.error.message).toBe('file path is not accessible'); }); + it('FILES-RPC-005e an unexpected storage failure keeps its own taxonomy', async () => { + const f = build(); + // Only the two storage errors become an accessibility refusal; a backend + // outage must not be reported to the plugin as a missing file. + (f.storage.getStream as ReturnType).mockRejectedValueOnce(new Error('backend down')); + const res = (await f.host('db:read:files:content').dispatch(req('files.getContent', { tripId: 1, fileId: 2 }), 42)) as RpcError; + expect(res.error.code).toBe('HOST_ERROR'); + }); + it('FILES-RPC-005b an object whose stat exceeds the cap is refused before buffering', async () => { const f = build(); const stream = Readable.from(Buffer.from('hi')); diff --git a/server/tests/unit/mcp/tools-files.test.ts b/server/tests/unit/mcp/tools-files.test.ts new file mode 100644 index 000000000..2fbcbb9e3 --- /dev/null +++ b/server/tests/unit/mcp/tools-files.test.ts @@ -0,0 +1,870 @@ +/** + * The trip-file MCP surface (FilesMcp): listing a trip's documents, reading one + * document's contents under its own scope, and the attach/describe writes. + * + * The gates are what this file is really about. Every tool has to answer exactly + * what its REST route answers: trip access before anything else, file_edit on + * the writes, and a file id that is only resolvable inside its own trip. And + * read_trip_file has to stay behind files:content while list_trip_files answers + * on files:read alone. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import fs from 'node:fs'; +import nodePath from 'node:path'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); + +const { fixture } = vi.hoisted(() => ({ fixture: {} as { root: string } })); +// Each createMcpHarness() builds a fresh registry, and the helper hands every one +// of those its own mkdtemp root, so bytes written by a test would land in a +// directory the next registry never looks at. Pin one shared fixture instead, +// which is what lets read_trip_file return a file this suite wrote. +vi.mock('../../helpers/storage-fixture', async (importOriginal) => { + const actual = await importOriginal(); + const shared = actual.makeStorageFixture(''); + fixture.root = shared.root; + return { makeStorageFixture: () => shared }; +}); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache'; +import { createUser, createTrip, createDay, createPlace, createDayAssignment, createReservation, addTripMember } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; +import { expectRegisteredProvider } from '../../helpers/module-providers'; +import { FilesModule } from '../../../src/nest/files/files.module'; +import { FilesMcp } from '../../../src/nest/files/files.mcp'; +import { FILE_CONTENT_MAX } from '../../../src/nest/files/files.service'; +import { StorageService } from '../../../src/nest/storage/storage.service'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + broadcastMock.mockClear(); + delete process.env.DEMO_MODE; + // resetTestDb truncates app_settings, but the permission cache is module-scoped + // and would keep serving whatever the previous case configured. + invalidatePermissionsCache(); +}); + +afterAll(() => { + testDb.close(); +}); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false }); + try { await fn(h); } finally { await h.cleanup(); } +} + +/** Lower a configurable action the way the admin permission panel does. */ +function setPermission(action: string, level: string) { + testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(`perm_${action}`, level); + invalidatePermissionsCache(); +} + +interface FileRowOverrides { + filename: string; + original_name: string; + file_size: number | null; + mime_type: string | null; + description: string | null; + uploaded_by: number | null; + place_id: number | null; + reservation_id: number | null; + starred: number; + deleted_at: string | null; +} + +/** A trip_files row, straight in: there is no upload path through MCP to make one. */ +function insertFile(tripId: number, overrides: Partial = {}) { + const info = testDb.prepare(` + INSERT INTO trip_files (trip_id, filename, original_name, file_size, mime_type, description, uploaded_by, place_id, reservation_id, starred, deleted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + tripId, + overrides.filename ?? 'stored-visa.pdf', + overrides.original_name ?? 'visa.pdf', + // `??` would swallow an explicit null here, and a row with no recorded size + // or type is exactly what the fallback cases need. + overrides.file_size === undefined ? 2 : overrides.file_size, + overrides.mime_type === undefined ? 'application/pdf' : overrides.mime_type, + overrides.description ?? null, + overrides.uploaded_by ?? null, + overrides.place_id ?? null, + overrides.reservation_id ?? null, + overrides.starred ?? 0, + overrides.deleted_at ?? null, + ); + return testDb.prepare('SELECT * FROM trip_files WHERE id = ?').get(info.lastInsertRowid) as { id: number; description: string | null; place_id: number | null; reservation_id: number | null }; +} + +/** Put real bytes where the storage layer will look for `filename`. */ +function storeBytes(filename: string, body: Buffer | string) { + fs.writeFileSync(nodePath.join(fixture.root, filename), body); +} + +function fileRow(id: number) { + return testDb.prepare('SELECT description, place_id, reservation_id FROM trip_files WHERE id = ?').get(id) as + { description: string | null; place_id: number | null; reservation_id: number | null }; +} + +function linkRows(fileId: number) { + return testDb.prepare('SELECT * FROM file_links WHERE file_id = ?').all(fileId) as + { id: number; reservation_id: number | null; assignment_id: number | null; place_id: number | null }[]; +} + +// --------------------------------------------------------------------------- +// list_trip_files +// --------------------------------------------------------------------------- + +describe('Tool: list_trip_files', () => { + it('reports name, size, uploader, links and starred state', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id); + const reservation = createReservation(testDb, trip.id, { title: 'Hotel Ritz' }); + const file = insertFile(trip.id, { + original_name: 'boarding-pass.pdf', file_size: 4242, uploaded_by: user.id, + place_id: place.id, reservation_id: reservation.id, starred: 1, description: 'AF1234', + }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_trip_files', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as any; + expect(data.files).toHaveLength(1); + expect(data.files[0]).toMatchObject({ + id: file.id, + original_name: 'boarding-pass.pdf', + file_size: 4242, + mime_type: 'application/pdf', + description: 'AF1234', + starred: 1, + deleted_at: null, + reservation_title: 'Hotel Ritz', + uploaded_by_name: user.username, + }); + }); + }); + + it('lists the trash only when asked for it', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + insertFile(trip.id, { original_name: 'live.pdf' }); + insertFile(trip.id, { original_name: 'trashed.pdf', deleted_at: '2027-01-01 10:00:00' }); + + await withHarness(user.id, async (h) => { + const live = parseToolResult(await h.client.callTool({ name: 'list_trip_files', arguments: { tripId: trip.id } })) as any; + expect(live.files.map((f: any) => f.original_name)).toEqual(['live.pdf']); + + const trashed = parseToolResult(await h.client.callTool({ name: 'list_trip_files', arguments: { tripId: trip.id, trash: true } })) as any; + expect(trashed.files.map((f: any) => f.original_name)).toEqual(['trashed.pdf']); + }); + }); + + it('reports the bookings and places a file is linked to', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id); + const reservation = createReservation(testDb, trip.id); + const file = insertFile(trip.id); + testDb.prepare('INSERT INTO file_links (file_id, reservation_id) VALUES (?, ?)').run(file.id, reservation.id); + testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(file.id, place.id); + + await withHarness(user.id, async (h) => { + const data = parseToolResult(await h.client.callTool({ name: 'list_trip_files', arguments: { tripId: trip.id } })) as any; + expect(data.files[0].linked_reservation_ids).toEqual([reservation.id]); + expect(data.files[0].linked_place_ids).toEqual([place.id]); + }); + }); + + it('denies a non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + insertFile(trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_trip_files', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// read_trip_file +// --------------------------------------------------------------------------- + +describe('Tool: read_trip_file', () => { + it('returns a text document as text', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = insertFile(trip.id, { filename: 'notes.txt', original_name: 'packing-notes.txt', mime_type: 'text/plain', file_size: 11 }); + storeBytes('notes.txt', 'Bring socks'); + + await withHarness(user.id, async (h) => { + const data = parseToolResult(await h.client.callTool({ + name: 'read_trip_file', arguments: { tripId: trip.id, fileId: file.id }, + })) as any; + expect(data.file).toMatchObject({ + id: file.id, name: 'packing-notes.txt', mimetype: 'text/plain', size: 11, encoding: 'utf8', content: 'Bring socks', + }); + }); + }); + + it('returns a binary document base64-encoded', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const bytes = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x00, 0xff]); + const file = insertFile(trip.id, { filename: 'stored.pdf', mime_type: 'application/pdf', file_size: bytes.length }); + storeBytes('stored.pdf', bytes); + + await withHarness(user.id, async (h) => { + const data = parseToolResult(await h.client.callTool({ + name: 'read_trip_file', arguments: { tripId: trip.id, fileId: file.id }, + })) as any; + expect(data.file.encoding).toBe('base64'); + expect(Buffer.from(data.file.content, 'base64').equals(bytes)).toBe(true); + }); + }); + + it('falls back to a binary mimetype when the row has none', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = insertFile(trip.id, { filename: 'unknown.bin', mime_type: null, file_size: null }); + storeBytes('unknown.bin', 'hi'); + + await withHarness(user.id, async (h) => { + const data = parseToolResult(await h.client.callTool({ + name: 'read_trip_file', arguments: { tripId: trip.id, fileId: file.id }, + })) as any; + expect(data.file.mimetype).toBe('application/octet-stream'); + expect(data.file.encoding).toBe('base64'); + }); + }); + + it('refuses a file that belongs to another trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const otherTrip = createTrip(testDb, user.id); + const foreign = insertFile(otherTrip.id, { filename: 'foreign.txt', mime_type: 'text/plain' }); + storeBytes('foreign.txt', 'secret'); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'read_trip_file', arguments: { tripId: trip.id, fileId: foreign.id } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('File not found.'); + }); + }); + + it('refuses a trashed file', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = insertFile(trip.id, { filename: 'trashed.txt', mime_type: 'text/plain', deleted_at: '2027-01-01 10:00:00' }); + storeBytes('trashed.txt', 'gone'); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'read_trip_file', arguments: { tripId: trip.id, fileId: file.id } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('File not found.'); + }); + }); + + it('refuses a file over the 10 MB cap without touching the bytes', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + // Recorded size only: the refusal happens before the storage layer is asked, + // which is the whole point of capping on the row. + const file = insertFile(trip.id, { filename: 'huge.mp4', mime_type: 'video/mp4', file_size: FILE_CONTENT_MAX + 1 }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'read_trip_file', arguments: { tripId: trip.id, fileId: file.id } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('too large to read here (over 10 MB)'); + }); + }); + + it('reports a row whose bytes are gone as unavailable, not as a crash', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = insertFile(trip.id, { filename: 'never-stored.pdf' }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'read_trip_file', arguments: { tripId: trip.id, fileId: file.id } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('File contents are not available.'); + }); + }); + + it('lets an unexpected storage failure surface instead of calling the file missing', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = insertFile(trip.id, { filename: 'flaky.txt', mime_type: 'text/plain' }); + storeBytes('flaky.txt', 'hi'); + // A backend outage is not one of the three refusals: reporting it as "not + // found" would send the caller off to fix a file that is perfectly fine. + const outage = vi.spyOn(StorageService.prototype, 'getStream').mockRejectedValueOnce(new Error('backend down')); + + try { + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'read_trip_file', arguments: { tripId: trip.id, fileId: file.id } }); + expect(result.isError).toBe(true); + const text = JSON.stringify(result.content); + expect(text).not.toContain('File not found.'); + expect(text).not.toContain('File contents are not available.'); + }); + } finally { + outage.mockRestore(); + } + }); + + it('denies a non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + const file = insertFile(trip.id, { filename: 'members-only.txt', mime_type: 'text/plain' }); + storeBytes('members-only.txt', 'secret'); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'read_trip_file', arguments: { tripId: trip.id, fileId: file.id } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('Trip not found or access denied.'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// update_trip_file +// --------------------------------------------------------------------------- + +describe('Tool: update_trip_file', () => { + it('sets the description and attaches the file to a place', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id); + const file = insertFile(trip.id); + + await withHarness(user.id, async (h) => { + const data = parseToolResult(await h.client.callTool({ + name: 'update_trip_file', + arguments: { tripId: trip.id, fileId: file.id, description: 'Museum ticket', place_id: place.id }, + })) as any; + expect(data.file.description).toBe('Museum ticket'); + expect(fileRow(file.id)).toMatchObject({ description: 'Museum ticket', place_id: place.id }); + }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'file:updated', expect.objectContaining({ _source: 'mcp' })); + }); + + it('leaves out fields the caller did not send', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const reservation = createReservation(testDb, trip.id); + const file = insertFile(trip.id, { description: 'Keep me' }); + + await withHarness(user.id, async (h) => { + await h.client.callTool({ + name: 'update_trip_file', + arguments: { tripId: trip.id, fileId: file.id, reservation_id: reservation.id }, + }); + expect(fileRow(file.id)).toMatchObject({ description: 'Keep me', reservation_id: reservation.id }); + }); + }); + + it('detaches a booking when reservation_id is null', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const reservation = createReservation(testDb, trip.id); + const file = insertFile(trip.id, { reservation_id: reservation.id }); + + await withHarness(user.id, async (h) => { + await h.client.callTool({ + name: 'update_trip_file', + arguments: { tripId: trip.id, fileId: file.id, reservation_id: null }, + }); + expect(fileRow(file.id).reservation_id).toBeNull(); + }); + }); + + it('refuses a place that belongs to another trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const otherTrip = createTrip(testDb, user.id); + const foreignPlace = createPlace(testDb, otherTrip.id); + const file = insertFile(trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_trip_file', + arguments: { tripId: trip.id, fileId: file.id, place_id: foreignPlace.id }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('does not belong to this trip (place_id)'); + expect(fileRow(file.id).place_id).toBeNull(); + }); + }); + + it('refuses a file id from another trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const otherTrip = createTrip(testDb, user.id); + const foreign = insertFile(otherTrip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_trip_file', + arguments: { tripId: trip.id, fileId: foreign.id, description: 'x' }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('File not found.'); + }); + }); + + it('refuses a member once file_edit is owner-only', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + const file = insertFile(trip.id); + setPermission('file_edit', 'trip_owner'); + + await withHarness(member.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_trip_file', + arguments: { tripId: trip.id, fileId: file.id, description: 'nope' }, + }); + expect(result.isError).toBe(true); + expect(fileRow(file.id).description).toBeNull(); + }); + }); + + it('denies a non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + const file = insertFile(trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_trip_file', + arguments: { tripId: trip.id, fileId: file.id, description: 'nope' }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('Trip not found or access denied.'); + }); + }); + + it('blocks a demo user before it looks at the trip', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@trek.app' }); + const trip = createTrip(testDb, user.id); + const file = insertFile(trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_trip_file', + arguments: { tripId: trip.id, fileId: file.id, description: 'nope' }, + }); + expect(result.isError).toBe(true); + expect(fileRow(file.id).description).toBeNull(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// link_trip_file +// --------------------------------------------------------------------------- + +describe('Tool: link_trip_file', () => { + it('attaches one file to a booking, a place and a day assignment', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const reservation = createReservation(testDb, trip.id); + const place = createPlace(testDb, trip.id); + const day = createDay(testDb, trip.id); + const assignment = createDayAssignment(testDb, day.id, place.id); + const file = insertFile(trip.id); + + await withHarness(user.id, async (h) => { + const first = parseToolResult(await h.client.callTool({ + name: 'link_trip_file', + arguments: { tripId: trip.id, fileId: file.id, reservation_id: reservation.id }, + })) as any; + expect(first.success).toBe(true); + expect(first.links).toHaveLength(1); + + await h.client.callTool({ + name: 'link_trip_file', + arguments: { tripId: trip.id, fileId: file.id, place_id: place.id, assignment_id: assignment.id }, + }); + }); + + const links = linkRows(file.id); + expect(links).toHaveLength(2); + expect(links[0].reservation_id).toBe(reservation.id); + expect(links[1]).toMatchObject({ place_id: place.id, assignment_id: assignment.id }); + }); + + it('refuses a call with no target instead of storing an empty link', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = insertFile(trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'link_trip_file', arguments: { tripId: trip.id, fileId: file.id } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('at least one of reservation_id'); + }); + expect(linkRows(file.id)).toHaveLength(0); + }); + + it('refuses a booking from another trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const otherTrip = createTrip(testDb, user.id); + const foreignReservation = createReservation(testDb, otherTrip.id); + const file = insertFile(trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'link_trip_file', + arguments: { tripId: trip.id, fileId: file.id, reservation_id: foreignReservation.id }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('does not belong to this trip (reservation_id)'); + }); + expect(linkRows(file.id)).toHaveLength(0); + }); + + it('refuses a day assignment from another trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const otherTrip = createTrip(testDb, user.id); + const foreignDay = createDay(testDb, otherTrip.id); + const foreignPlace = createPlace(testDb, otherTrip.id); + const foreignAssignment = createDayAssignment(testDb, foreignDay.id, foreignPlace.id); + const file = insertFile(trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'link_trip_file', + arguments: { tripId: trip.id, fileId: file.id, assignment_id: foreignAssignment.id }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('does not belong to this trip (assignment_id)'); + }); + }); + + it('refuses a file id from another trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const otherTrip = createTrip(testDb, user.id); + const foreign = insertFile(otherTrip.id); + const reservation = createReservation(testDb, trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'link_trip_file', + arguments: { tripId: trip.id, fileId: foreign.id, reservation_id: reservation.id }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('File not found.'); + }); + }); + + it('refuses a member once file_edit is owner-only', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + const reservation = createReservation(testDb, trip.id); + const file = insertFile(trip.id); + setPermission('file_edit', 'trip_owner'); + + await withHarness(member.id, async (h) => { + const result = await h.client.callTool({ + name: 'link_trip_file', + arguments: { tripId: trip.id, fileId: file.id, reservation_id: reservation.id }, + }); + expect(result.isError).toBe(true); + }); + expect(linkRows(file.id)).toHaveLength(0); + }); + + it('denies a non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + const reservation = createReservation(testDb, trip.id); + const file = insertFile(trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'link_trip_file', + arguments: { tripId: trip.id, fileId: file.id, reservation_id: reservation.id }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('Trip not found or access denied.'); + }); + }); + + it('blocks a demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@trek.app' }); + const trip = createTrip(testDb, user.id); + const reservation = createReservation(testDb, trip.id); + const file = insertFile(trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'link_trip_file', + arguments: { tripId: trip.id, fileId: file.id, reservation_id: reservation.id }, + }); + expect(result.isError).toBe(true); + }); + expect(linkRows(file.id)).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// unlink_trip_file +// --------------------------------------------------------------------------- + +describe('Tool: unlink_trip_file', () => { + it('removes one link and leaves the others alone', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const reservation = createReservation(testDb, trip.id); + const place = createPlace(testDb, trip.id); + const file = insertFile(trip.id); + testDb.prepare('INSERT INTO file_links (file_id, reservation_id) VALUES (?, ?)').run(file.id, reservation.id); + testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(file.id, place.id); + const doomed = linkRows(file.id)[0].id; + + await withHarness(user.id, async (h) => { + const data = parseToolResult(await h.client.callTool({ + name: 'unlink_trip_file', + arguments: { tripId: trip.id, fileId: file.id, linkId: doomed }, + })) as any; + expect(data.success).toBe(true); + }); + const left = linkRows(file.id); + expect(left).toHaveLength(1); + expect(left[0].place_id).toBe(place.id); + }); + + it('refuses a file id from another trip, so a foreign link survives', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const otherTrip = createTrip(testDb, user.id); + const foreignFile = insertFile(otherTrip.id); + const foreignReservation = createReservation(testDb, otherTrip.id); + testDb.prepare('INSERT INTO file_links (file_id, reservation_id) VALUES (?, ?)').run(foreignFile.id, foreignReservation.id); + const linkId = linkRows(foreignFile.id)[0].id; + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'unlink_trip_file', + arguments: { tripId: trip.id, fileId: foreignFile.id, linkId }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('File not found.'); + }); + expect(linkRows(foreignFile.id)).toHaveLength(1); + }); + + it('refuses a member once file_edit is owner-only', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + const reservation = createReservation(testDb, trip.id); + const file = insertFile(trip.id); + testDb.prepare('INSERT INTO file_links (file_id, reservation_id) VALUES (?, ?)').run(file.id, reservation.id); + const linkId = linkRows(file.id)[0].id; + setPermission('file_edit', 'trip_owner'); + + await withHarness(member.id, async (h) => { + const result = await h.client.callTool({ + name: 'unlink_trip_file', + arguments: { tripId: trip.id, fileId: file.id, linkId }, + }); + expect(result.isError).toBe(true); + }); + expect(linkRows(file.id)).toHaveLength(1); + }); + + it('denies a non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + const file = insertFile(trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'unlink_trip_file', + arguments: { tripId: trip.id, fileId: file.id, linkId: 1 }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('Trip not found or access denied.'); + }); + }); + + it('blocks a demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + const trip = createTrip(testDb, user.id); + const reservation = createReservation(testDb, trip.id); + const file = insertFile(trip.id); + testDb.prepare('INSERT INTO file_links (file_id, reservation_id) VALUES (?, ?)').run(file.id, reservation.id); + const linkId = linkRows(file.id)[0].id; + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'unlink_trip_file', + arguments: { tripId: trip.id, fileId: file.id, linkId }, + }); + expect(result.isError).toBe(true); + }); + expect(linkRows(file.id)).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// list_trip_file_links +// --------------------------------------------------------------------------- + +describe('Tool: list_trip_file_links', () => { + it('returns every link with its id and the booking title', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const reservation = createReservation(testDb, trip.id, { title: 'Nightjet 421' }); + const file = insertFile(trip.id); + testDb.prepare('INSERT INTO file_links (file_id, reservation_id) VALUES (?, ?)').run(file.id, reservation.id); + + await withHarness(user.id, async (h) => { + const data = parseToolResult(await h.client.callTool({ + name: 'list_trip_file_links', arguments: { tripId: trip.id, fileId: file.id }, + })) as any; + expect(data.links).toHaveLength(1); + expect(data.links[0]).toMatchObject({ file_id: file.id, reservation_id: reservation.id, reservation_title: 'Nightjet 421' }); + expect(typeof data.links[0].id).toBe('number'); + }); + }); + + it('refuses a file id from another trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const otherTrip = createTrip(testDb, user.id); + const foreign = insertFile(otherTrip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'list_trip_file_links', arguments: { tripId: trip.id, fileId: foreign.id }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('File not found.'); + }); + }); + + it('denies a non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + const file = insertFile(trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'list_trip_file_links', arguments: { tripId: trip.id, fileId: file.id }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Scope gating +// --------------------------------------------------------------------------- + +describe('File tools: scope gating', () => { + const READ_TOOLS = ['list_trip_files', 'list_trip_file_links']; + const WRITE_TOOLS = ['update_trip_file', 'link_trip_file', 'unlink_trip_file']; + + async function listToolNames(userId: number, scopes: string[] | null): Promise { + const h = await createMcpHarness({ userId, withResources: false, scopes }); + try { + return (await h.client.listTools()).tools.map((t) => t.name); + } finally { + await h.cleanup(); + } + } + + it('registers all six tools for a full-access session', async () => { + const { user } = createUser(testDb); + const names = await listToolNames(user.id, null); + for (const tool of [...READ_TOOLS, ...WRITE_TOOLS, 'read_trip_file']) expect(names).toContain(tool); + }); + + it('files:read lists the documents but does not open them', async () => { + const { user } = createUser(testDb); + const names = await listToolNames(user.id, ['files:read']); + for (const tool of READ_TOOLS) expect(names).toContain(tool); + for (const tool of [...WRITE_TOOLS, 'read_trip_file']) expect(names).not.toContain(tool); + }); + + it('files:content opens a document without granting the listing', async () => { + const { user } = createUser(testDb); + const names = await listToolNames(user.id, ['files:content']); + expect(names).toContain('read_trip_file'); + for (const tool of [...READ_TOOLS, ...WRITE_TOOLS]) expect(names).not.toContain(tool); + }); + + it('files:write carries the reads, and still not the contents', async () => { + const { user } = createUser(testDb); + const names = await listToolNames(user.id, ['files:write']); + for (const tool of [...READ_TOOLS, ...WRITE_TOOLS]) expect(names).toContain(tool); + expect(names).not.toContain('read_trip_file'); + }); + + it('a token without any files scope gets none of them', async () => { + const { user } = createUser(testDb); + const names = await listToolNames(user.id, ['trips:read']); + for (const tool of [...READ_TOOLS, ...WRITE_TOOLS, 'read_trip_file']) expect(names).not.toContain(tool); + }); +}); + +describe('FilesMcp wiring', () => { + it('is listed in its module providers', () => { + expectRegisteredProvider(FilesModule, FilesMcp); + }); +});