mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
2d7cb124b3
A trip can be bound to a folder, tag or space in a self-hosted document store, and documents then move in both directions: what someone uploads in TREK appears there, and what someone files there appears in TREK. Three decisions shape the rest. The connection belongs to the TRIP, not to a person. Every other integration here is per user, and photos are shared only through an opt-in flag; neither can deliver "everyone on the trip sees the same documents", because visibility would depend on whose credentials fetched a file, and a member without their own Paperless account would see nothing. So the trip owner binds the trip once, the server talks to the provider under that one identity, and TREK's own membership decides who sees what. The provider never learns that TREK has members. When the credential holder leaves the trip the binding goes to `orphaned` rather than quietly carrying on with an ex-member's token. TREK keeps the bytes. A synced document is an ordinary trip_files row, so download, trash, offline cache, PDF export and backup keep working untouched, and a provider outage does not blank out a trip that starts tomorrow. Deletion never crosses the boundary on its own. A document that disappears upstream is recorded as missing and waits for a human, and a run that sees more than 40 percent of its known documents vanish at once refuses the whole listing rather than acting on it: an unmounted share answers exactly like a mass delete. The core reconciles by enumerating both sides, because the weakest provider sets the model. A changed-since query cannot see a deletion, Papra does not move updatedAt when a tag changes, a Paperless bulk edit leaves `modified` alone, and Synology FileStation has no change feed at all. A webhook is therefore only ever "look now", never a source of truth. Echo is caught by comparing content, not timestamps: pushed_sha256 records what TREK itself wrote, so a provider reporting TREK's own upload is not mistaken for a stranger's edit. Provider differences are answered by capability data rather than by branching on an id: stable ids where they exist (Paperless document id, oc:fileid, driveId) and a normalised path where they do not, replace-in-place where the API offers it, and a content hash from the listing only where the provider really supplies one. Admins switch providers on under Admin, Addons, Documents, mirroring how photo providers hang off Journey, including the cascade and the 409 when the parent addon is off. The per-trip setup lives in the file manager, where the person has the context that makes the folder choice obvious.
232 lines
10 KiB
TypeScript
232 lines
10 KiB
TypeScript
/**
|
|
* The MCP half of document sync, built with `new` and plain mocks: no registry,
|
|
* no MCP server, no container.
|
|
*
|
|
* The tool bodies are three lines each, and what is worth pinning about them is
|
|
* not the plumbing but the decisions around it. The access check is the whole
|
|
* security story — there is no guard in front of an MCP tool, so a dropped
|
|
* `verifyTripAccess` is a stranger reading where somebody's documents live. The
|
|
* absence of a connect tool is a decision too: setting a connection up means
|
|
* handing over a credential that usually reaches a person's entire document
|
|
* archive, and a decision nobody asserts is one somebody re-adds by accident.
|
|
* And the refusal for an unconnected trip is the only thing standing between an
|
|
* assistant and answering "nothing to sync" to a question about someone's
|
|
* boarding pass.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { z, type ZodRawShape } from 'zod';
|
|
|
|
import { ADDON_IDS } from '../../../../src/addons';
|
|
import { DocSyncMcp } from '../../../../src/nest/doc-sync/doc-sync.mcp';
|
|
import type { DocSyncConfigService, LinkRow } from '../../../../src/nest/doc-sync/doc-sync-config.service';
|
|
import type { DocSyncService } from '../../../../src/nest/doc-sync/doc-sync.service';
|
|
import type { AddonsService } from '../../../../src/nest/addons/addons.service';
|
|
import type { FilesService } from '../../../../src/nest/files/files.service';
|
|
import { getEntry, type ClassRef } from '../../../../src/nest-mcp/metadata';
|
|
import type { McpContext, McpTextResult, ToolOptions } from '../../../../src/nest-mcp';
|
|
|
|
const ctx = { userId: 7, scopes: null, isStaticToken: false } as McpContext;
|
|
|
|
const link = (id: number, providerId = 'paperless'): LinkRow =>
|
|
({ id, provider_id: providerId, trip_id: 3 } as LinkRow);
|
|
|
|
const RUN = { state: 'ok', pulled: 2, pushed: 1, conflicts: 0, missing: 0 };
|
|
|
|
interface Setup {
|
|
/** `undefined` is what canAccessTrip answers for a trip the user cannot see. */
|
|
access: boolean;
|
|
links: LinkRow[];
|
|
status: Record<string, unknown>;
|
|
issues: Array<Record<string, unknown>>;
|
|
addonOn: boolean;
|
|
}
|
|
|
|
function makeMcp(over: Partial<Setup> = {}) {
|
|
const setup: Setup = { access: true, links: [], status: {}, issues: [], addonOn: true, ...over };
|
|
const files = { verifyTripAccess: vi.fn(() => (setup.access ? { id: 3, user_id: 7 } : undefined)) };
|
|
const config = { listLinks: vi.fn(() => setup.links) };
|
|
const sync = {
|
|
status: vi.fn(() => setup.status),
|
|
issues: vi.fn(() => setup.issues),
|
|
syncLink: vi.fn(async () => RUN),
|
|
};
|
|
const addons = { isAddonEnabled: vi.fn(() => setup.addonOn) };
|
|
const mcp = new DocSyncMcp(
|
|
config as unknown as DocSyncConfigService,
|
|
sync as unknown as DocSyncService,
|
|
files as unknown as FilesService,
|
|
addons as unknown as AddonsService,
|
|
);
|
|
return { mcp, files, config, sync, addons };
|
|
}
|
|
|
|
/** A success is one JSON text block and carries no error flag. */
|
|
function payload(result: McpTextResult): Record<string, unknown> {
|
|
expect(result.isError).toBeUndefined();
|
|
return JSON.parse(result.content[0].text) as Record<string, unknown>;
|
|
}
|
|
|
|
function refusal(result: McpTextResult): string {
|
|
expect(result.isError).toBe(true);
|
|
return result.content[0].text;
|
|
}
|
|
|
|
function toolOptions(method: string): ToolOptions {
|
|
const entry = getEntry(DocSyncMcp as unknown as ClassRef, method);
|
|
if (!entry || entry.kind !== 'tool') throw new Error(`${method} is not a registered MCP tool`);
|
|
return entry.options;
|
|
}
|
|
|
|
/**
|
|
* Read off the prototype rather than listed here, so a fourth tool fails the
|
|
* set assertion below instead of quietly slipping past a table this file keeps.
|
|
*/
|
|
function registeredMethods(): string[] {
|
|
return Object.getOwnPropertyNames(DocSyncMcp.prototype)
|
|
.filter((name) => getEntry(DocSyncMcp as unknown as ClassRef, name) !== undefined)
|
|
.sort();
|
|
}
|
|
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
describe('DocSyncMcp surface', () => {
|
|
it('offers three tools, none of which can create or change a connection', () => {
|
|
expect(registeredMethods()).toEqual(['getTripDocumentSync', 'listIssues', 'syncNow']);
|
|
expect(registeredMethods().map((m) => toolOptions(m).name).sort()).toEqual([
|
|
'get_trip_document_sync',
|
|
'list_trip_document_sync_issues',
|
|
'sync_trip_documents',
|
|
]);
|
|
});
|
|
|
|
it('asks for nothing but a trip id and a full-run flag', () => {
|
|
// The other half of the same decision: a credential surface could arrive
|
|
// bolted onto an existing schema rather than as a tool of its own.
|
|
const fields = registeredMethods().flatMap((m) => Object.keys(toolOptions(m).inputSchema as ZodRawShape));
|
|
expect([...new Set(fields)].sort()).toEqual(['full', 'tripId']);
|
|
});
|
|
|
|
it.each(registeredMethods())('%s disappears while the documents addon is off', (method) => {
|
|
const { mcp, addons } = makeMcp({ addonOn: false });
|
|
const { when } = toolOptions(method);
|
|
expect(typeof when).toBe('function');
|
|
expect(when?.(ctx, mcp)).toBe(false);
|
|
expect(addons.isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.DOCUMENTS);
|
|
|
|
addons.isAddonEnabled.mockReturnValue(true);
|
|
expect(when?.(ctx, mcp)).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
['getTripDocumentSync', 'read'],
|
|
['listIssues', 'read'],
|
|
['syncNow', 'write'],
|
|
])('%s claims files:%s, so a read-scoped token cannot move files', (method, mode) => {
|
|
expect(toolOptions(method).access).toEqual({ group: 'files', mode });
|
|
});
|
|
|
|
it('advertises the run as a write even though it creates nothing itself', () => {
|
|
// It moves bytes in both directions, and a host that trusts readOnlyHint
|
|
// would otherwise call it unattended.
|
|
expect(toolOptions('syncNow').annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false });
|
|
expect(toolOptions('getTripDocumentSync').annotations).toMatchObject({ readOnlyHint: true });
|
|
expect(toolOptions('listIssues').annotations).toMatchObject({ readOnlyHint: true });
|
|
});
|
|
|
|
it('refuses a trip id that is not a positive integer before a handler sees it', () => {
|
|
const schema = z.object(toolOptions('getTripDocumentSync').inputSchema as ZodRawShape);
|
|
expect(schema.safeParse({ tripId: 3 }).success).toBe(true);
|
|
expect(schema.safeParse({ tripId: 0 }).success).toBe(false);
|
|
expect(schema.safeParse({ tripId: -1 }).success).toBe(false);
|
|
expect(schema.safeParse({ tripId: 1.5 }).success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('DocSyncMcp access', () => {
|
|
it.each([
|
|
['get_trip_document_sync', (m: DocSyncMcp) => m.getTripDocumentSync({ tripId: 3 }, ctx)],
|
|
['list_trip_document_sync_issues', (m: DocSyncMcp) => m.listIssues({ tripId: 3 }, ctx)],
|
|
['sync_trip_documents', (m: DocSyncMcp) => m.syncNow({ tripId: 3 }, ctx)],
|
|
])('%s tells a user who cannot see the trip nothing beyond that', async (_name, call) => {
|
|
const { mcp, files } = makeMcp({ access: false, links: [link(1)] });
|
|
const result = await call(mcp);
|
|
expect(refusal(result as McpTextResult)).toBe('Trip not found or access denied.');
|
|
expect(files.verifyTripAccess).toHaveBeenCalledWith(3, ctx.userId);
|
|
});
|
|
|
|
it('does not reach the sync service at all for a trip the user cannot see', async () => {
|
|
const { mcp, sync, config } = makeMcp({ access: false, links: [link(1)] });
|
|
await mcp.syncNow({ tripId: 3 }, ctx);
|
|
mcp.listIssues({ tripId: 3 }, ctx);
|
|
mcp.getTripDocumentSync({ tripId: 3 }, ctx);
|
|
expect(sync.syncLink).not.toHaveBeenCalled();
|
|
expect(sync.status).not.toHaveBeenCalled();
|
|
expect(sync.issues).not.toHaveBeenCalled();
|
|
expect(config.listLinks).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('get_trip_document_sync', () => {
|
|
it('hands back the trip status as the service reports it', () => {
|
|
const status = { links: [{ id: 1, remoteLabel: 'Japan 2026' }], items: { synced: 4, conflict: 1 } };
|
|
const { mcp, sync } = makeMcp({ status });
|
|
expect(payload(mcp.getTripDocumentSync({ tripId: 3 }, ctx))).toEqual(status);
|
|
expect(sync.status).toHaveBeenCalledWith(3);
|
|
});
|
|
});
|
|
|
|
describe('list_trip_document_sync_issues', () => {
|
|
it('says the trip is not configured rather than answering with an empty list', () => {
|
|
// An empty list and no connection at all read identically to an assistant,
|
|
// and "everything is in step" is the wrong answer to give about documents
|
|
// that were never being synced.
|
|
const { mcp, sync } = makeMcp({ links: [] });
|
|
expect(payload(mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: false, issues: [] });
|
|
expect(sync.issues).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports an empty list for a configured trip with nothing to decide', () => {
|
|
const { mcp } = makeMcp({ links: [link(1)], issues: [] });
|
|
expect(payload(mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: true, issues: [] });
|
|
});
|
|
|
|
it('passes the open issues through for a configured trip', () => {
|
|
const issues = [{ id: 9, state: 'conflict', remote_name: 'boarding.pdf' }];
|
|
const { mcp, sync } = makeMcp({ links: [link(1)], issues });
|
|
expect(payload(mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: true, issues });
|
|
expect(sync.issues).toHaveBeenCalledWith(3);
|
|
});
|
|
});
|
|
|
|
describe('sync_trip_documents', () => {
|
|
it('refuses an unconnected trip with the step that would fix it', async () => {
|
|
const { mcp, sync } = makeMcp({ links: [] });
|
|
const text = refusal(await mcp.syncNow({ tripId: 3 }, ctx));
|
|
expect(text).toContain('not connected to a document store');
|
|
expect(text).toContain('file manager');
|
|
expect(sync.syncLink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('runs every binding of the trip and reports each one separately', async () => {
|
|
// A trip can be bound to more than one store, and collapsing the runs into
|
|
// one total would hide which of them failed.
|
|
const links = [link(1, 'paperless'), link(2, 'nextcloud')];
|
|
const { mcp, sync } = makeMcp({ links });
|
|
const body = payload(await mcp.syncNow({ tripId: 3 }, ctx));
|
|
expect(sync.syncLink).toHaveBeenCalledTimes(2);
|
|
expect(body.runs).toEqual([
|
|
{ linkId: 1, provider: 'paperless', ...RUN },
|
|
{ linkId: 2, provider: 'nextcloud', ...RUN },
|
|
]);
|
|
});
|
|
|
|
it('asks for the cheap incremental run unless the caller says otherwise', async () => {
|
|
const { mcp, sync } = makeMcp({ links: [link(1)] });
|
|
await mcp.syncNow({ tripId: 3 }, ctx);
|
|
expect(sync.syncLink).toHaveBeenCalledWith(link(1), { full: false });
|
|
|
|
await mcp.syncNow({ tripId: 3, full: true }, ctx);
|
|
expect(sync.syncLink).toHaveBeenLastCalledWith(link(1), { full: true });
|
|
});
|
|
});
|