mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
b3a085df3f
The process-state ports were meant to let a store shared between processes replace the in-memory one, but they could not. The readers outside the container (the broadcast functions, the backup restore's permissions-cache flush, the pending-code sweep, hand-built consumers) went straight to the module-level in-memory instances, so an overridden provider was used by the gateway and the services while the broadcasts and the restore flush kept using the old store. And every port was synchronous, while a store in the database or on the network is async, so plugging one in would have changed every signature anyway. RateLimitStore, PendingCodeStore, OidcFlowStore and PermissionsCacheStore now return promises, and RateLimitService, OauthService, OidcService, PermissionsService and their callers await them. The in-memory stores still decide synchronously, so who gets a 429, a code or a cached level is unchanged. RoomRegistry stays synchronous: membership holds live sockets and broadcast() is a synchronous call every domain makes, so several processes need a fan-out in front of broadcast(), not a different place for the sets. The process-wide ports get a ProcessStoreSlot. PermissionsModule, OauthModule and RealtimeGatewayModule install the store the container resolved and release it on shutdown, and the free functions, the sweep and hand-built consumers read the slot, so overriding the provider changes every reader. Tests provide fake stores and check that the broadcasts, the restore flush and the sweep use them. Two callers needed more than an await. The transit MCP tools counted in a RateLimitService of their own that no provider could reach; they now take the one RateLimitModule provides (their mcp_transit_* buckets never overlap the REST ones). The link preview claimed its in-flight slot only after the budget check, which now yields, so simultaneous askers would each have been charged; it now claims the slot first, and an asker that joined someone out of budget asks again on its own budget. The unused putPendingCode/takePendingCode helpers are gone.
970 lines
42 KiB
TypeScript
970 lines
42 KiB
TypeScript
/**
|
|
* 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 { db as testDb } from '../../../src/db/database';
|
|
import fs from 'node:fs';
|
|
import nodePath from 'node:path';
|
|
|
|
|
|
vi.mock('../../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
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<typeof import('../../helpers/storage-fixture')>();
|
|
const shared = actual.makeStorageFixture('');
|
|
fixture.root = shared.root;
|
|
return { makeStorageFixture: () => shared };
|
|
});
|
|
|
|
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';
|
|
|
|
beforeEach(async () => {
|
|
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.
|
|
await invalidatePermissionsCache();
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
|
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. */
|
|
async function setPermission(action: string, level: string): Promise<void> {
|
|
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(`perm_${action}`, level);
|
|
await 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, so each case controls every column. */
|
|
function insertFile(tripId: number, overrides: Partial<FileRowOverrides> = {}) {
|
|
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);
|
|
await 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();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// upload_trip_file (#1566)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: upload_trip_file', () => {
|
|
const pdf = Buffer.from('%PDF-1.4 booking').toString('base64');
|
|
|
|
it('stores the bytes, records the row and attaches it to a booking', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const booking = createReservation(testDb, trip.id);
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
const data = parseToolResult(await h.client.callTool({
|
|
name: 'upload_trip_file',
|
|
arguments: { tripId: trip.id, filename: 'C:\\mail\\hotel.pdf', content: pdf, description: 'Hotel', reservation_id: booking.id },
|
|
})) as any;
|
|
expect(data.file).toMatchObject({ original_name: 'hotel.pdf', mime_type: 'application/pdf', file_size: 16, description: 'Hotel', reservation_id: booking.id, uploaded_by: user.id });
|
|
expect(data.file.filename).toMatch(/^[0-9a-f-]{36}\.pdf$/);
|
|
expect(fs.readFileSync(nodePath.join(fixture.root, data.file.filename), 'utf8')).toBe('%PDF-1.4 booking');
|
|
// And the read tool hands the same bytes back.
|
|
const read = parseToolResult(await h.client.callTool({ name: 'read_trip_file', arguments: { tripId: trip.id, fileId: data.file.id } })) as any;
|
|
expect(Buffer.from(read.file.content, 'base64').toString()).toBe('%PDF-1.4 booking');
|
|
});
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'file:created', expect.objectContaining({ _source: 'mcp' }));
|
|
});
|
|
|
|
it('refuses an extension the file manager would refuse', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
for (const filename of ['evil.html', 'logo.svg', 'tool.exe', 'noext']) {
|
|
const result = await h.client.callTool({ name: 'upload_trip_file', arguments: { tripId: trip.id, filename, content: pdf } });
|
|
expect(result.isError).toBe(true);
|
|
expect(JSON.stringify(result.content)).toContain('not allowed');
|
|
}
|
|
});
|
|
expect(testDb.prepare('SELECT COUNT(*) AS n FROM trip_files').get()).toEqual({ n: 0 });
|
|
});
|
|
|
|
it('follows the operator list, including the wildcard', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', 'pdf')").run();
|
|
await withHarness(user.id, async (h) => {
|
|
const refused = await h.client.callTool({ name: 'upload_trip_file', arguments: { tripId: trip.id, filename: 'notes.txt', content: pdf } });
|
|
expect(refused.isError).toBe(true);
|
|
testDb.prepare("UPDATE app_settings SET value = '*' WHERE key = 'allowed_file_types'").run();
|
|
const data = parseToolResult(await h.client.callTool({ name: 'upload_trip_file', arguments: { tripId: trip.id, filename: 'route.gpx', content: pdf } })) as any;
|
|
expect(data.file.mime_type).toBe('application/gpx+xml');
|
|
});
|
|
});
|
|
|
|
it('refuses content that is not base64, or decodes to nothing', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const bad = await h.client.callTool({ name: 'upload_trip_file', arguments: { tripId: trip.id, filename: 'a.pdf', content: 'not base64!' } });
|
|
expect(JSON.stringify(bad.content)).toContain('not valid base64');
|
|
const empty = await h.client.callTool({ name: 'upload_trip_file', arguments: { tripId: trip.id, filename: 'a.pdf', content: '==' } });
|
|
expect(JSON.stringify(empty.content)).toContain('empty');
|
|
});
|
|
});
|
|
|
|
it('refuses a file over the cap', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
// Just past the cap after decoding, still inside the schema's base64 length.
|
|
const big = Buffer.alloc(FILE_CONTENT_MAX + 1, 1).toString('base64');
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'upload_trip_file', arguments: { tripId: trip.id, filename: 'big.pdf', content: big } });
|
|
expect(result.isError).toBe(true);
|
|
expect(JSON.stringify(result.content)).toContain('too large');
|
|
});
|
|
});
|
|
|
|
it('refuses a place from another trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const foreignTrip = createTrip(testDb, user.id);
|
|
const foreignPlace = createPlace(testDb, foreignTrip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'upload_trip_file', arguments: { tripId: trip.id, filename: 'a.pdf', content: pdf, place_id: foreignPlace.id } });
|
|
expect(result.isError).toBe(true);
|
|
expect(JSON.stringify(result.content)).toContain('does not belong to this trip');
|
|
});
|
|
expect(testDb.prepare('SELECT COUNT(*) AS n FROM trip_files').get()).toEqual({ n: 0 });
|
|
});
|
|
|
|
it('refuses a member once file_upload 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);
|
|
await setPermission('file_upload', 'trip_owner');
|
|
await withHarness(member.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'upload_trip_file', arguments: { tripId: trip.id, filename: 'a.pdf', content: pdf } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('denies a non-member, and blocks a demo user', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const foreign = createTrip(testDb, other.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'upload_trip_file', arguments: { tripId: foreign.id, filename: 'a.pdf', content: pdf } });
|
|
expect(JSON.stringify(result.content)).toContain('Trip not found or access denied.');
|
|
});
|
|
process.env.DEMO_MODE = 'true';
|
|
const { user: demo } = createUser(testDb, { email: 'demo@trek.app' });
|
|
const own = createTrip(testDb, demo.id);
|
|
await withHarness(demo.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'upload_trip_file', arguments: { tripId: own.id, filename: 'a.pdf', content: pdf } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
expect(testDb.prepare('SELECT COUNT(*) AS n FROM trip_files').get()).toEqual({ n: 0 });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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);
|
|
await 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;
|
|
await 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 = ['upload_trip_file', 'update_trip_file', 'link_trip_file', 'unlink_trip_file'];
|
|
|
|
async function listToolNames(userId: number, scopes: string[] | null): Promise<string[]> {
|
|
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 seven 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);
|
|
});
|
|
});
|