fix(docsync): refuse an orphaned binding over MCP as the REST route does

A binding whose credential owner left the trip is orphaned: the
scheduler, the webhook and the REST "Sync now" route all refuse it,
because a run would use the credential of somebody who is no longer on
the trip. The MCP sync_trip_documents tool only checked the admin
switch, so any member with MCP could still run such a binding on the
ex-member's account.

The tool now skips an orphaned binding before anything is un-shelved,
reports it with the orphaned code, and answers with an error when no
binding of the trip could run at all.
This commit is contained in:
Maurice
2026-09-19 21:01:45 +02:00
parent 80bb06c84f
commit 03d3f2f91e
2 changed files with 37 additions and 2 deletions
@@ -27,8 +27,8 @@ import type { McpContext, McpTextResult, ToolOptions } from '../../../../src/nes
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 link = (id: number, providerId = 'paperless', lastSyncState: string | null = null): LinkRow =>
({ id, provider_id: providerId, trip_id: 3, last_sync_state: lastSyncState } as LinkRow);
const RUN = { state: 'ok', pulled: 2, pushed: 1, conflicts: 0, missing: 0 };
@@ -256,6 +256,29 @@ describe('sync_trip_documents', () => {
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
it('refuses an orphaned binding as the REST route does, without touching it', async () => {
const { mcp, sync } = makeMcp({ links: [link(1, 'paperless', 'orphaned')] });
const text = refusal(await mcp.syncNow({ tripId: 3 }, ctx));
expect(text.startsWith('orphaned:')).toBe(true);
expect(text).toContain('connect the store again');
// The credential belongs to somebody who left the trip: no run, and the
// shelved rows stay shelved.
expect(sync.retryShelvedItems).not.toHaveBeenCalled();
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('runs the bindings that still have an owner and reports the orphaned one by its code', async () => {
const links = [link(1, 'paperless', 'orphaned'), link(2, 'nextcloud')];
const { mcp, sync } = makeMcp({ links });
const body = payload(await mcp.syncNow({ tripId: 3 }, ctx));
expect(body.runs).toEqual([
{ linkId: 1, provider: 'paperless', state: 'orphaned', errorCode: 'orphaned' },
{ linkId: 2, provider: 'nextcloud', ...RUN },
]);
expect(sync.retryShelvedItems.mock.calls).toEqual([[2]]);
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
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);