Files
TREK/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts
T

984 lines
46 KiB
TypeScript

import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import { HttpException, Logger } from '@nestjs/common';
import type { Request } from 'express';
/**
* DocSyncController built with `new`, against a real in-memory SQLite.
*
* The guards are e2e's job (tests/e2e/doc-sync.e2e.test.ts drives the real
* AddonGuard, JwtAuthGuard and TripAccessGuard). What is left here is what the
* handler itself decides once a request is through the door, and all of it is
* the kind that would be expensive to notice late: who counts as the owner, a
* form field that must not lose a secret it never saw, and a webhook whose
* callback URL is derived from the request rather than from configuration.
*
* The config service and the provider registry are the real ones: the merge
* this file pins runs through actual encryption and actual provider-field rows,
* and a stub of either would only be a restatement of the controller. The
* reconciler is a spy: a run is a side effect here, not the subject.
*/
const { testDb, dbMock } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
return {
testDb: db,
dbMock: {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => undefined,
isOwner: () => false,
},
};
});
vi.mock('../../../../src/db/database', () => dbMock);
vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
/**
* The SSRF guard resolves DNS, and none of these hostnames exist. It is not
* what this file tests, and a suite that needs a resolver is a suite that fails
* on a train.
*/
const { checkSsrf } = vi.hoisted(() => ({
checkSsrf: vi.fn(async (url: string) => ({
allowed: !url.includes('blocked.invalid'),
isPrivate: false,
error: url.includes('blocked.invalid') ? 'blocked in test' : undefined,
})),
}));
vi.mock('../../../../src/utils/ssrfGuard', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../../src/utils/ssrfGuard')>()),
checkSsrf,
}));
import { createTables } from '../../../../src/db/schema';
import { runMigrations } from '../../../../src/db/migrations';
import { createTrip, createUser } from '../../../helpers/factories';
import { DocSyncController } from '../../../../src/nest/doc-sync/doc-sync.controller';
import { DocSyncConfigService } from '../../../../src/nest/doc-sync/doc-sync-config.service';
import { DocumentProviderRegistry } from '../../../../src/nest/doc-sync/document-provider.registry';
import type {
DocumentConnectionRef,
DocumentProvider,
DocumentScopeRef,
} from '../../../../src/nest/doc-sync/document-provider';
import type { LinkRow } from '../../../../src/nest/doc-sync/doc-sync-config.service';
import type { DocSyncService } from '../../../../src/nest/doc-sync/doc-sync.service';
import type { RealtimeService } from '../../../../src/nest/realtime/realtime.service';
import type {
DocsyncConnectionDto,
DocsyncConnectionTestDto,
DocsyncLinkDto,
} from '../../../../src/nest/doc-sync/doc-sync.dto';
import type { User } from '../../../../src/types';
import { createTestUnitOfWork, createTestTripsRepo } from '../../../helpers/test-uow';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import {
createTestDocumentConnectionsRepo,
createTestDocumentProviderFieldsRepo,
createTestDocumentProvidersRepo,
createTestDocumentSyncItemsRepo,
createTestTripDocumentLinksRepo,
} from '../../../helpers/doc-sync-repos';
const CAPS = {
push: 'webhook-self-registered' as const,
stableId: true,
remoteTrash: true,
replaceInPlace: true,
contentHashInListing: true,
maxUploadBytes: null,
acceptedMimeTypes: null,
canCreateScope: true,
};
function fakeProvider(id: string) {
return {
id,
capabilities: () => CAPS,
probe: vi.fn(async (_conn: DocumentConnectionRef) => ({ success: true as const, data: { account: `${id}-account`, capabilities: CAPS } })),
listScopes: vi.fn(async (_conn: DocumentConnectionRef, _query?: string) => ({ success: true as const, data: [] })),
createScope: vi.fn(async () => ({ success: false as const, error: { code: 'provider_error' as const } })),
resolveScope: vi.fn(async () => ({ success: false as const, error: { code: 'not_found' as const } })),
list: vi.fn(async () => ({ success: true as const, data: { documents: [], cursor: null, cursorUnchanged: false, truncated: false } })),
fetch: vi.fn(async () => ({ success: false as const, error: { code: 'not_found' as const } })),
push: vi.fn(async () => ({ success: false as const, error: { code: 'not_found' as const } })),
rename: vi.fn(async () => ({ success: true as const, data: { remoteVersion: 'v2' } })),
trash: vi.fn(async () => ({ success: true as const, data: undefined })),
registerWebhook: vi.fn(async (_conn: DocumentConnectionRef, _scope: DocumentScopeRef, _callbackUrl: string, _secret: string) => ({
success: true as const,
data: { subscriptionId: 'sub-7' },
})),
unregisterWebhook: vi.fn(async (_conn: DocumentConnectionRef, _subscriptionId: string) => ({ success: true as const, data: undefined })),
};
}
const paperless = fakeProvider('paperless');
const nextcloud = fakeProvider('nextcloud');
const sync = {
syncLink: vi.fn(async (_link: LinkRow, _opts?: { full?: boolean }) => ({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })),
retryShelvedItems: vi.fn((_linkId: number) => {}),
isSwitchedOff: vi.fn((_link: LinkRow) => false),
status: vi.fn(() => ({ links: [], items: {} })),
resolveConflict: vi.fn(async () => true),
// R2 (DSCTRL4/DSCTRL5, moved off the controller onto DocSyncService) — a
// real read against this file's own testDb, not a stub: "the document
// list" describe block below is testing the join/trip-scoping SHAPE this
// method now owns, and a canned return would only restate the assertion.
itemsForTrip: vi.fn((tripId: number, state?: string) =>
state
? testDb
.prepare(
`SELECT i.*, f.original_name AS file_name FROM document_sync_items i
LEFT JOIN trip_files f ON f.id = i.file_id
WHERE i.trip_id = ? AND i.state = ? ORDER BY i.id DESC LIMIT 500`,
)
.all(tripId, state)
: testDb
.prepare(
`SELECT i.*, f.original_name AS file_name FROM document_sync_items i
LEFT JOIN trip_files f ON f.id = i.file_id
WHERE i.trip_id = ? ORDER BY i.id DESC LIMIT 500`,
)
.all(tripId),
),
};
const registry = new DocumentProviderRegistry([paperless, nextcloud] as unknown as DocumentProvider[]);
const realtime = { broadcast: vi.fn() };
// Built in beforeAll: DocSyncConfigService now takes a UnitOfWork, and
// createTestUnitOfWork is async — module-scope construction cannot await it.
let config: DocSyncConfigService;
let controller: DocSyncController;
let t: TestOrm;
/** Only what publicOrigin reads: header lookup plus the connection's own scheme. */
function makeReq(headers: Record<string, string> = {}, protocol = 'http'): Request {
return {
protocol,
headers,
get: (name: string) => headers[name.toLowerCase()],
} as unknown as Request;
}
function connBody(over: Partial<DocsyncConnectionDto> = {}): DocsyncConnectionDto {
return {
providerId: 'paperless',
baseUrl: 'https://paperless.example.com',
credentials: {},
allowInsecureTls: false,
...over,
} as DocsyncConnectionDto;
}
function linkBody(connectionId: number, over: Partial<DocsyncLinkDto> = {}): DocsyncLinkDto {
return {
connectionId,
scopeKey: 'tag:1',
remoteRootId: '1',
remoteRootPath: '/TREK/japan',
remoteLabel: 'Japan 2026',
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
...over,
} as DocsyncLinkDto;
}
async function thrown(fn: () => unknown): Promise<HttpException> {
try {
await fn();
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
return err as HttpException;
}
throw new Error('expected the handler to throw');
}
let owner: User;
let member: User;
let admin: User;
let tripId: number;
let otherTripId: number;
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
// L1 (Plan 3h Task 7 review): createLink's fire-and-forget syncLink call
// is now wrapped in its own withRequestContext fork, which needs a real
// MikroORM instance — sync itself stays a spy, this ORM never resolves a
// repository through it.
t = await createTestOrm(testDb);
config = new DocSyncConfigService(
await createTestTripsRepo(testDb),
await createTestDocumentProvidersRepo(testDb),
await createTestDocumentProviderFieldsRepo(testDb),
await createTestDocumentConnectionsRepo(testDb),
await createTestTripDocumentLinksRepo(testDb),
await createTestDocumentSyncItemsRepo(testDb),
registry,
await createTestUnitOfWork(testDb),
);
controller = new DocSyncController(config, sync as unknown as DocSyncService, registry, realtime as unknown as RealtimeService, t.orm);
const o = createUser(testDb, { username: 'owner', email: 'owner@test.local' }).user;
const m = createUser(testDb, { username: 'member', email: 'member@test.local' }).user;
const a = createUser(testDb, { username: 'admin', email: 'admin@test.local', role: 'admin' }).user;
owner = { id: o.id, role: 'user' } as User;
member = { id: m.id, role: 'user' } as User;
admin = { id: a.id, role: 'admin' } as User;
tripId = createTrip(testDb, o.id, { title: 'Japan' }).id;
otherTripId = createTrip(testDb, m.id, { title: 'Norway' }).id;
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, m.id);
});
afterAll(async () => {
await t.close();
testDb.close();
});
beforeEach(() => {
testDb.exec('DELETE FROM document_sync_items; DELETE FROM trip_document_links; DELETE FROM document_connections; DELETE FROM trip_files');
// Providers ship switched off; these three are what the cases below need.
testDb.prepare("UPDATE document_providers SET enabled = 0").run();
testDb.prepare("UPDATE document_providers SET enabled = 1 WHERE id IN ('paperless', 'nextcloud', 'papra')").run();
vi.clearAllMocks();
});
async function storedPaperless(credentials: Record<string, string> = { api_token: 'stored-token' }) {
const res = await config.upsertConnection(tripId, Number(owner.id), connBody({ credentials }));
if ('error' in res) throw new Error(`fixture failed: ${res.error.code} ${res.error.detail ?? ''}`);
return res.data;
}
describe('who may change a trip binding', () => {
it('lets the trip owner through', async () => {
await expect(controller.resolve(String(tripId), '1', owner, { keep: 'trek' })).resolves.toEqual({ success: true });
});
it('lets an instance admin through on a trip they neither own nor joined', async () => {
await expect(controller.resolve(String(otherTripId), '1', admin, { keep: 'trek' })).resolves.toEqual({ success: true });
});
it('refuses a plain member with the message the client renders', async () => {
const err = await thrown(() => controller.resolve(String(tripId), '1', member, { keep: 'trek' }));
expect(err.getStatus()).toBe(403);
expect(err.message).toBe('Only the trip owner can change document sync');
});
it('answers 404 for a trip that does not exist, rather than the 403 that would confirm it', async () => {
const err = await thrown(() => controller.resolve('999999', '1', member, { keep: 'trek' }));
expect(err.getStatus()).toBe(404);
expect(err.message).toBe('Trip not found');
});
});
describe('providers', () => {
it('reports an enabled provider with no registered adapter as unavailable instead of hiding it', async () => {
const rows = (await controller.providers()) as Array<{ id: string; available: boolean }>;
const byId = Object.fromEntries(rows.map((r) => [r.id, r.available]));
// papra is switched on in the DB, but no adapter is registered in this suite.
expect(byId.papra).toBe(false);
expect(byId.paperless).toBe(true);
expect(byId.nextcloud).toBe(true);
});
it('hands the form flags to the client as booleans rather than the 0/1 the column stores', async () => {
const rows = (await controller.providers()) as Array<{ id: string; fields: Array<{ field_key: string; secret: boolean; required: boolean }> }>;
const fields = rows.find((r) => r.id === 'paperless')!.fields;
const token = fields.find((f) => f.field_key === 'api_token')!;
expect(token.secret).toBe(true);
expect(token.required).toBe(true);
expect(fields.find((f) => f.field_key === 'allow_insecure_tls')!.secret).toBe(false);
});
});
describe('storing a connection', () => {
it('hands the stored connection back with the secret masked instead of echoed', async () => {
const res = (await controller.upsertConnection(
String(tripId),
owner,
connBody({ credentials: { api_token: 'super-secret' } }),
)) as { id: number; secrets: Record<string, string> };
expect(res.secrets.api_token).not.toContain('super-secret');
const listed = (await controller.listConnections(String(tripId))) as Array<{ id: number; secrets: Record<string, string> }>;
expect(listed).toHaveLength(1);
expect(listed[0].id).toBe(res.id);
// The list is what the form renders from, so the secret must not survive
// the round trip either.
expect(listed[0].secrets.api_token).not.toContain('super-secret');
});
it('refuses a base URL the SSRF guard turns down', async () => {
const err = await thrown(() => controller.upsertConnection(String(tripId), owner, connBody({ baseUrl: 'https://blocked.invalid' })));
expect(err.getStatus()).toBe(400);
expect(await config.listConnections(tripId)).toHaveLength(0);
});
it('refuses to point the stored secret at another server when the form leaves it blank', async () => {
// The probe route had this rule; the save route let an instance admin, or
// whoever the trip was handed to, redirect the owner's token to a host of
// their own with an empty form.
const conn = await storedPaperless();
const err = await thrown(() =>
controller.upsertConnection(String(tripId), admin, connBody({ baseUrl: 'https://elsewhere.example', credentials: {} })),
);
expect(err.getStatus()).toBe(400);
expect((await config.getConnection(conn.id))!.base_url).toBe('https://paperless.example.com');
expect((await config.getConnection(conn.id))!.owner_user_id).toBe(Number(owner.id));
});
it('refuses a provider the instance admin has not switched on, and names it', async () => {
testDb.prepare("UPDATE document_providers SET enabled = 0 WHERE id = 'papra'").run();
const err = await thrown(() =>
controller.upsertConnection(String(tripId), owner, connBody({ providerId: 'papra', credentials: { api_key: 'k', organization_id: 'org_1' } })),
);
expect(err.getStatus()).toBe(400);
expect(err.message).toBe('Provider: "papra" is not enabled, contact server administrator');
});
});
describe('probing form values', () => {
it('keeps the stored secret when the form leaves the field blank', async () => {
await storedPaperless();
await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: '' } }) as DocsyncConnectionTestDto);
expect(paperless.probe.mock.calls[0][0]).toMatchObject({ secrets: { api_token: 'stored-token' } });
});
it('takes a freshly typed secret over the stored one', async () => {
await storedPaperless();
await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: 'typed-now' } }) as DocsyncConnectionTestDto);
expect(paperless.probe.mock.calls[0][0]).toMatchObject({ secrets: { api_token: 'typed-now' } });
});
it('keeps a stored non-secret setting the form did not resend', async () => {
const res = await config.upsertConnection(tripId, Number(owner.id), connBody({
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: { login_name: 'alice', app_password: 'app-pw', base_path: '/TREK' },
}));
expect('error' in res).toBe(false);
await controller.testConnection(String(tripId), owner, connBody({
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: { app_password: '' },
}) as DocsyncConnectionTestDto);
expect(nextcloud.probe.mock.calls[0][0]).toMatchObject({
settings: { login_name: 'alice', base_path: '/TREK' },
secrets: { app_password: 'app-pw' },
});
});
it('takes a freshly typed setting over the stored one', async () => {
const res = await config.upsertConnection(tripId, Number(owner.id), connBody({
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: { login_name: 'alice', app_password: 'app-pw' },
}));
expect('error' in res).toBe(false);
await controller.testConnection(String(tripId), owner, connBody({
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: { login_name: 'bob' },
}) as DocsyncConnectionTestDto);
expect(nextcloud.probe.mock.calls[0][0]).toMatchObject({ settings: { login_name: 'bob' } });
});
it('answers with a body instead of throwing when no adapter is registered', async () => {
await expect(
controller.testConnection(String(tripId), owner, connBody({ providerId: 'papra', credentials: { api_key: 'k' } }) as DocsyncConnectionTestDto),
).resolves.toEqual({ connected: false, error: 'unknown_provider' });
});
it('answers with a body instead of throwing when the URL is refused', async () => {
await expect(
controller.testConnection(String(tripId), owner, connBody({ baseUrl: 'https://blocked.invalid' }) as DocsyncConnectionTestDto),
).resolves.toEqual({ connected: false, error: 'ssrf_blocked' });
expect(paperless.probe).not.toHaveBeenCalled();
});
it('answers with a body instead of throwing when the provider rejects the credentials', async () => {
paperless.probe.mockResolvedValueOnce({ success: false, error: { code: 'unauthorized', detail: 'token expired' } } as never);
await expect(
controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: 'nope' } }) as DocsyncConnectionTestDto),
).resolves.toEqual({ connected: false, error: 'unauthorized', detail: 'token expired' });
});
it('writes a successful probe onto the stored connection, so the status panel has something to show', async () => {
const conn = await storedPaperless();
await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: '' } }) as DocsyncConnectionTestDto);
const row = (await config.getConnection(conn.id))!;
expect(row.last_probe_state).toBe('ok');
expect(JSON.parse(row.capabilities!)).toMatchObject({ push: 'webhook-self-registered' });
});
it('leaves no probe record behind when the form values belong to no stored connection yet', async () => {
await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: 'first-try' } }) as DocsyncConnectionTestDto);
expect(await config.listConnections(tripId)).toHaveLength(0);
expect(paperless.probe.mock.calls[0][0]).toMatchObject({ connectionId: 0, secrets: { api_token: 'first-try' } });
});
});
describe('a secret the adapter earned itself', () => {
const EARNED = 'a1b2c3:DEVICE-SECRET';
it('is in neither connection payload the form is rendered from', async () => {
testDb.prepare("UPDATE document_providers SET enabled = 1 WHERE id = 'synologydrive'").run();
const nas = connBody({
providerId: 'synologydrive',
baseUrl: 'https://nas.example.com:5001',
credentials: { username: 'anna', password: 'nas-pw' },
});
const created = (await controller.upsertConnection(String(tripId), owner, nas)) as { id: number };
await config.saveEarnedSecret(created.id, 'device_token', EARNED);
const listed = JSON.stringify(await controller.listConnections(String(tripId)));
const edited = JSON.stringify(
await controller.upsertConnection(String(tripId), owner, { ...nas, credentials: { username: 'anna', password: 'rotated' } }),
);
for (const payload of [listed, edited]) {
expect(payload).not.toContain('DEVICE-SECRET');
expect(payload).not.toContain('device_token');
}
expect(config.toRef((await config.getConnection(created.id))!).secrets.device_token).toBe(EARNED);
});
it('reaches a probe of the saved form, which gets no way to write', async () => {
const conn = await storedPaperless();
await config.saveEarnedSecret(conn.id, 'device_token', EARNED);
const res = await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: '' } }) as DocsyncConnectionTestDto);
const ref = paperless.probe.mock.calls[0][0];
// The row as well as its id: together they are where the saved connection keeps its device token.
expect(ref).toMatchObject({ connectionId: conn.id, createdAt: conn.created_at, secrets: { device_token: EARNED } });
expect(ref.createdAt).not.toBe('');
expect(ref.saveSecret).toBeUndefined();
expect(JSON.stringify(res)).not.toContain('DEVICE-SECRET');
});
it('gives a probe of an unsaved form no way to write either', async () => {
await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: 'first-try' } }) as DocsyncConnectionTestDto);
const ref = paperless.probe.mock.calls[0][0];
expect(ref.connectionId).toBe(0);
expect(ref.createdAt).toBe('');
expect(ref.saveSecret).toBeUndefined();
});
});
describe('reading the trip state', () => {
it('asks the reconciler with a number, because the path hands the handler a string', async () => {
await controller.status(String(tripId));
expect(sync.status).toHaveBeenCalledWith(tripId);
});
});
describe('removing a connection', () => {
it('unbinds without touching the documents that came through it', async () => {
const conn = await storedPaperless();
const fileId = Number(
testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)')
.run(tripId, 'stored.pdf', 'boarding.pdf').lastInsertRowid,
);
await expect(controller.deleteConnection(String(tripId), String(conn.id), owner)).resolves.toEqual({ success: true });
expect(await config.getConnection(conn.id)).toBeUndefined();
expect(testDb.prepare('SELECT id FROM trip_files WHERE id = ?').get(fileId)).toBeTruthy();
});
it('takes down every subscription TREK registered for its bindings, before the credential goes', async () => {
// The bindings cascade away with the connection, and the rows were the
// only record of the Paperless workflows and Nextcloud listeners TREK had
// set up: left standing, they keep posting to a token that answers
// nothing, for good. Unbinding one binding already did this.
const conn = await storedPaperless();
const first = (await controller.createLink(
String(tripId), owner, linkBody(conn.id, { scopeKey: 'tag:1' }), makeReq({ host: 'trek.example' }),
)) as { id: number };
paperless.registerWebhook.mockResolvedValueOnce({ success: true, data: { subscriptionId: 'sub-8' } } as never);
await controller.createLink(String(tripId), owner, linkBody(conn.id, { scopeKey: 'tag:2' }), makeReq({ host: 'trek.example' }));
// One that never got a subscription, which must not be unregistered as ''.
await controller.createLink(String(tripId), owner, linkBody(conn.id, { scopeKey: 'tag:3' }), makeReq());
paperless.unregisterWebhook.mockClear();
await controller.deleteConnection(String(tripId), String(conn.id), owner);
expect(paperless.unregisterWebhook.mock.calls.map((c) => c[1]).sort()).toEqual(['sub-7', 'sub-8']);
expect(paperless.unregisterWebhook.mock.calls[0][0]).toMatchObject({ connectionId: conn.id, secrets: { api_token: 'stored-token' } });
expect(await config.getConnection(conn.id)).toBeUndefined();
expect(await config.getLink(first.id)).toBeUndefined();
});
it('still unbinds when the provider refuses to take a subscription down', async () => {
const conn = await storedPaperless();
await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq({ host: 'trek.example' }));
paperless.unregisterWebhook.mockResolvedValueOnce({ success: false, error: { code: 'unreachable' } } as never);
await expect(controller.deleteConnection(String(tripId), String(conn.id), owner)).resolves.toEqual({ success: true });
expect(await config.getConnection(conn.id)).toBeUndefined();
});
it('refuses a connection that belongs to a different trip', async () => {
const conn = await storedPaperless();
const err = await thrown(() => controller.deleteConnection(String(otherTripId), String(conn.id), admin));
expect(err.getStatus()).toBe(404);
expect(await config.getConnection(conn.id)).toBeTruthy();
expect(realtime.broadcast).not.toHaveBeenCalled();
});
it('pings the trip once for every binding that went with it, and for no other', async () => {
const conn = await storedPaperless();
const cloud = await config.upsertConnection(tripId, Number(owner.id), connBody({
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: { login_name: 'alice', app_password: 'app-pw', base_path: '/TREK' },
}));
if ('error' in cloud) throw new Error(`fixture failed: ${cloud.error.code}`);
const bind = async (connectionId: number, scopeKey: string) => {
const res = await config.createLink(tripId, Number(owner.id), linkBody(connectionId, { scopeKey }));
if ('error' in res) throw new Error(`fixture failed: ${res.error.code}`);
return res.data.id;
};
const first = await bind(conn.id, 'tag:1');
const second = await bind(conn.id, 'tag:2');
await bind(cloud.data.id, 'folder:1');
await controller.deleteConnection(String(tripId), String(conn.id), owner);
expect(realtime.broadcast.mock.calls).toEqual([
[tripId, 'docsync:changed', { linkId: first, pulled: 0, pushed: 0 }],
[tripId, 'docsync:changed', { linkId: second, pulled: 0, pushed: 0 }],
]);
});
});
describe('the scope picker', () => {
it('passes the search text to the adapter and hands back what it offers', async () => {
const conn = await storedPaperless();
paperless.listScopes.mockResolvedValueOnce({
success: true,
data: [{ scopeKey: 'tag:1', label: 'Japan 2026', remoteRootId: '1', remoteRootPath: '/TREK/japan' }],
} as never);
const res = await controller.listScopes(String(tripId), String(conn.id), owner, 'japan');
expect(paperless.listScopes.mock.calls[0][1]).toBe('japan');
expect(res).toMatchObject({ scopes: [{ scopeKey: 'tag:1' }] });
});
it('reports a provider failure in the body, so the picker can say why it is empty', async () => {
const conn = await storedPaperless();
paperless.listScopes.mockResolvedValueOnce({ success: false, error: { code: 'unauthorized' } } as never);
await expect(controller.listScopes(String(tripId), String(conn.id), owner)).resolves.toEqual({ scopes: [], error: 'unauthorized' });
});
it('refuses a connection whose provider has no adapter on this instance', async () => {
const id = Number(
testDb.prepare('INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url) VALUES (?, ?, ?, ?)')
.run(tripId, 'papra', Number(owner.id), 'https://papra.example.com').lastInsertRowid,
);
const err = await thrown(() => controller.listScopes(String(tripId), String(id), owner));
expect(err.getStatus()).toBe(400);
expect(err.message).toBe('Provider not available');
});
it('refuses a connection that belongs to a different trip', async () => {
const conn = await storedPaperless();
const err = await thrown(() => controller.listScopes(String(otherTripId), String(conn.id), admin));
expect(err.getStatus()).toBe(404);
expect(paperless.listScopes).not.toHaveBeenCalled();
});
it('hands back the container the adapter created', async () => {
const conn = await storedPaperless();
paperless.createScope.mockResolvedValueOnce({
success: true,
data: { scopeKey: 'tag:9', label: 'Norway', remoteRootId: '9', remoteRootPath: '/TREK/norway' },
} as never);
await expect(controller.createScope(String(tripId), String(conn.id), owner, { name: 'Norway' }))
.resolves.toMatchObject({ scopeKey: 'tag:9', label: 'Norway' });
expect(paperless.createScope).toHaveBeenCalledWith(expect.objectContaining({ connectionId: conn.id }), 'Norway');
});
it('creates nothing through a connection that belongs to a different trip', async () => {
// The connection comes from the path and nowhere else, so the path is what
// has to match the trip the caller was admitted to.
const conn = await storedPaperless();
const err = await thrown(() => controller.createScope(String(otherTripId), String(conn.id), admin, { name: 'Norway' }));
expect(err.getStatus()).toBe(404);
expect(paperless.createScope).not.toHaveBeenCalled();
});
it('turns a refused creation into a 400 carrying the provider code', async () => {
const conn = await storedPaperless();
paperless.createScope.mockResolvedValueOnce({ success: false, error: { code: 'forbidden' } } as never);
const err = await thrown(() => controller.createScope(String(tripId), String(conn.id), owner, { name: 'Norway' }));
expect(err.getStatus()).toBe(400);
expect(err.message).toBe('forbidden');
});
});
describe('creating a binding', () => {
it('subscribes at the provider and remembers the subscription id', async () => {
const conn = await storedPaperless();
const link = (await controller.createLink(
String(tripId),
owner,
linkBody(conn.id),
makeReq({ 'x-forwarded-proto': 'https', 'x-forwarded-host': 'trek.example' }),
)) as { id: number };
const row = (await config.getLink(link.id))!;
expect(row.webhook_subscription_id).toBe('sub-7');
expect(paperless.registerWebhook.mock.calls[0][2]).toBe(`https://trek.example/api/docsync/webhook/${row.webhook_token}`);
// The secret handed to the provider is the plaintext one, not the stored blob.
expect(paperless.registerWebhook.mock.calls[0][3]).toBe(config.webhookSecret(row));
});
it('keeps the binding when the provider refuses the subscription, because polling still carries it', async () => {
const conn = await storedPaperless();
paperless.registerWebhook.mockResolvedValueOnce({ success: false, error: { code: 'forbidden' } } as never);
const link = (await controller.createLink(
String(tripId),
owner,
linkBody(conn.id),
makeReq({ 'x-forwarded-host': 'trek.example' }),
)) as { id: number; syncEnabled: boolean };
const row = (await config.getLink(link.id))!;
expect(row.webhook_subscription_id).toBeNull();
expect(row.sync_enabled).toBe(1);
});
it('says so in the log when the provider refuses the subscription, since nothing on screen will', async () => {
// The refusal used to go nowhere at all: not thrown, not logged, and the
// first run overwrites the link state a moment later. An admin whose
// Paperless kept declining the workflow had no way to find out why the
// binding only ever ran on the timer.
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
try {
const conn = await storedPaperless();
paperless.registerWebhook.mockResolvedValueOnce({ success: false, error: { code: 'forbidden', detail: 'API key may not manage workflows' } } as never);
const link = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq({ host: 'trek.example' }))) as { id: number };
const lines = warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes(`link ${link.id}`));
expect(lines).toHaveLength(1);
expect(lines[0]).toContain('forbidden');
expect(lines[0]).toContain('API key may not manage workflows');
} finally {
warn.mockRestore();
}
});
it('does not offer a webhook when no host reaches TREK', async () => {
const conn = await storedPaperless();
const link = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { webhookUrl: string | null };
expect(paperless.registerWebhook).not.toHaveBeenCalled();
expect(link.webhookUrl).toBeNull();
});
it('starts a first run at once instead of letting the user wait out a poll interval', async () => {
const conn = await storedPaperless();
await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq({ host: 'trek.example' }));
expect(sync.syncLink).toHaveBeenCalledTimes(1);
expect(sync.syncLink.mock.calls[0][1]).toEqual({ full: true });
});
it('pings the whole trip, so a member already on the Files tab gets the sync button', async () => {
const conn = await storedPaperless();
const link = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
// Three arguments: no socket is skipped, the owner's own button reads it too.
expect(realtime.broadcast).toHaveBeenCalledWith(tripId, 'docsync:changed', { linkId: link.id, pulled: 0, pushed: 0 });
});
it('refuses a binding for a connection that belongs to a different trip', async () => {
const conn = await storedPaperless();
const err = await thrown(() =>
controller.createLink(String(otherTripId), admin, linkBody(conn.id), makeReq({ host: 'trek.example' })),
);
expect(err.getStatus()).toBe(400);
expect(sync.syncLink).not.toHaveBeenCalled();
expect(realtime.broadcast).not.toHaveBeenCalled();
});
});
describe('changing a binding', () => {
it('writes only the fields the request carried', async () => {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
const res = (await controller.updateLink(String(tripId), String(created.id), owner, { syncEnabled: false }, makeReq())) as {
syncEnabled: boolean;
direction: string;
remoteLabel: string;
};
expect(res.syncEnabled).toBe(false);
expect(res.direction).toBe('both');
expect(res.remoteLabel).toBe('Japan 2026');
});
it('refuses a link that belongs to a different trip', async () => {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
const err = await thrown(() => controller.updateLink(String(otherTripId), String(created.id), admin, { syncEnabled: false }, makeReq()));
expect(err.getStatus()).toBe(404);
expect((await config.getLink(created.id))!.sync_enabled).toBe(1);
});
});
describe('a manual run', () => {
it('passes the full flag through to the reconciler', async () => {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
sync.syncLink.mockClear();
await controller.syncNow(String(tripId), String(created.id), { full: true });
expect(sync.syncLink.mock.calls[0][0]).toMatchObject({ id: created.id });
expect(sync.syncLink.mock.calls[0][1]).toEqual({ full: true });
});
it('refuses a link that belongs to a different trip', async () => {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
sync.syncLink.mockClear();
const err = await thrown(() => controller.syncNow(String(otherTripId), String(created.id), { full: false }));
expect(err.getStatus()).toBe(404);
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('refuses a binding an admin switched off with a code, and touches nothing', async () => {
// A code rather than a sentence so the client can say it in the reader's
// language; the MCP tool answers with the same one. Refused before the
// shelved rows are cleared, so the binding resumes exactly as it was.
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
sync.syncLink.mockClear();
sync.isSwitchedOff.mockReturnValueOnce(true);
const err = await thrown(() => controller.syncNow(String(tripId), String(created.id), { full: false }));
expect(err.getStatus()).toBe(409);
expect(err.getResponse()).toEqual({ error: 'provider_disabled' });
expect(sync.isSwitchedOff.mock.calls[0][0]).toMatchObject({ id: created.id });
expect(sync.retryShelvedItems).not.toHaveBeenCalled();
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('refuses a paused binding whose owner left, although the sweep never marked it', async () => {
const res = await config.upsertConnection(tripId, Number(member.id), connBody({ credentials: { api_token: 'member-token' } }));
if ('error' in res) throw new Error('fixture failed');
const created = (await controller.createLink(String(tripId), owner, linkBody(res.data.id, { syncEnabled: false }), makeReq())) as { id: number };
sync.syncLink.mockClear();
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(tripId, member.id);
try {
const err = await thrown(() => controller.syncNow(String(tripId), String(created.id), { full: false }));
expect(err.getStatus()).toBe(409);
expect(sync.retryShelvedItems).not.toHaveBeenCalled();
expect(sync.syncLink).not.toHaveBeenCalled();
} finally {
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member.id);
}
});
});
describe('the document list', () => {
async function seedItems() {
const conn = await storedPaperless();
const fileId = Number(
testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)')
.run(tripId, 'stored.pdf', 'boarding.pdf').lastInsertRowid,
);
const linkId = Number(
testDb.prepare(
`INSERT INTO trip_document_links (trip_id, connection_id, provider_id, remote_scope_key)
VALUES (?, ?, ?, ?)`,
).run(tripId, conn.id, 'paperless', 'tag:1').lastInsertRowid,
);
const insert = testDb.prepare('INSERT INTO document_sync_items (link_id, trip_id, file_id, trek_doc_uid, state) VALUES (?, ?, ?, ?, ?)');
insert.run(linkId, tripId, fileId, 'uid-1', 'synced');
insert.run(linkId, tripId, null, 'uid-2', 'conflict');
// Another trip's row, which must never appear in this trip's list.
insert.run(linkId, otherTripId, null, 'uid-3', 'conflict');
}
it('carries the local file name, so the list reads as documents rather than as ids', async () => {
await seedItems();
const rows = (await controller.items(String(tripId))) as Array<{ trek_doc_uid: string; file_name: string | null }>;
expect(rows).toHaveLength(2);
expect(rows.find((r) => r.trek_doc_uid === 'uid-1')!.file_name).toBe('boarding.pdf');
});
it('narrows to one state when asked, and never leaves the trip', async () => {
await seedItems();
const rows = (await controller.items(String(tripId), 'conflict')) as Array<{ trek_doc_uid: string }>;
expect(rows.map((r) => r.trek_doc_uid)).toEqual(['uid-2']);
});
});
describe('resolving a conflict', () => {
it('refuses an item that is not in conflict rather than pretending it did something', async () => {
sync.resolveConflict.mockResolvedValueOnce(false);
const err = await thrown(() => controller.resolve(String(tripId), '5', owner, { keep: 'provider' }));
expect(err.getStatus()).toBe(400);
expect(err.message).toBe('Item is not in conflict');
});
});
describe('removing a binding', () => {
it('unsubscribes at the provider and keeps both copies of the documents', async () => {
const conn = await storedPaperless();
const created = (await controller.createLink(
String(tripId),
owner,
linkBody(conn.id),
makeReq({ host: 'trek.example' }),
)) as { id: number };
const fileId = Number(
testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)')
.run(tripId, 'stored.pdf', 'boarding.pdf').lastInsertRowid,
);
testDb
.prepare('INSERT INTO document_sync_items (link_id, trip_id, file_id, trek_doc_uid, state) VALUES (?, ?, ?, ?, ?)')
.run(created.id, tripId, fileId, 'uid-1', 'synced');
const res = await controller.deleteLink(String(tripId), String(created.id), owner);
expect(res).toEqual({ success: true, documentsKept: true });
expect(paperless.unregisterWebhook.mock.calls[0][1]).toBe('sub-7');
expect(await config.getLink(created.id)).toBeUndefined();
expect(testDb.prepare('SELECT id FROM trip_files WHERE id = ?').get(fileId)).toBeTruthy();
});
it('pings the trip that the binding is gone', async () => {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
realtime.broadcast.mockClear();
await controller.deleteLink(String(tripId), String(created.id), owner);
expect(realtime.broadcast.mock.calls).toEqual([
[tripId, 'docsync:changed', { linkId: created.id, pulled: 0, pushed: 0 }],
]);
});
it('refuses a link that belongs to a different trip', async () => {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
realtime.broadcast.mockClear();
const err = await thrown(() => controller.deleteLink(String(otherTripId), String(created.id), admin));
expect(err.getStatus()).toBe(404);
expect(await config.getLink(created.id)).toBeTruthy();
expect(realtime.broadcast).not.toHaveBeenCalled();
});
});
describe('the callback origin a provider is given', () => {
async function linkedTrip() {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
return (await config.getLink(created.id))!;
}
it('builds the callback URL from the forwarded proto and host', async () => {
const row = await linkedTrip();
const [link] = (await controller.listLinks(String(tripId), makeReq({ 'x-forwarded-proto': 'https', 'x-forwarded-host': 'docs.example' }))) as Array<{ webhookUrl: string }>;
expect(link.webhookUrl).toBe(`https://docs.example/api/docsync/webhook/${row.webhook_token}`);
});
it('takes the first hop when a proxy chain appends its own protocol', async () => {
await linkedTrip();
const [link] = (await controller.listLinks(String(tripId), makeReq({ 'x-forwarded-proto': 'https, http', 'x-forwarded-host': 'docs.example' }))) as Array<{ webhookUrl: string }>;
expect(link.webhookUrl).toMatch(/^https:\/\/docs\.example\//);
});
it('falls back to the Host header and the connection scheme when nothing is forwarded', async () => {
await linkedTrip();
const [link] = (await controller.listLinks(String(tripId), makeReq({ host: 'trek.lan:3001' }, 'http'))) as Array<{ webhookUrl: string }>;
expect(link.webhookUrl).toMatch(/^http:\/\/trek\.lan:3001\//);
});
it('offers no callback URL when a proxy strips the host', async () => {
await linkedTrip();
const [link] = (await controller.listLinks(String(tripId), makeReq())) as Array<{ webhookUrl: string | null }>;
expect(link.webhookUrl).toBeNull();
});
it('offers no callback URL for a store the probe found unable to call back at all', async () => {
// Synology takes no webhook. Showing the address with "paste this into
// your provider" next to it promised something there was nowhere to do.
const row = await linkedTrip();
await config.recordProbe(row.connection_id, 'ok', null, { ...CAPS, push: 'none' });
const [link] = (await controller.listLinks(String(tripId), makeReq({ host: 'trek.example' }))) as Array<{ webhookUrl: string | null }>;
expect(link.webhookUrl).toBeNull();
});
it('keeps offering it for a store the probe found able to take one', async () => {
const row = await linkedTrip();
await config.recordProbe(row.connection_id, 'ok', null, { ...CAPS, push: 'webhook-manual' });
const [link] = (await controller.listLinks(String(tripId), makeReq({ host: 'trek.example' }))) as Array<{ webhookUrl: string | null }>;
expect(link.webhookUrl).toBe(`http://trek.example/api/docsync/webhook/${row.webhook_token}`);
});
});
/**
* A manual run is also a request to try the given-up rows once more.
*
* The scheduler must never do this: the attempt limit exists precisely so a
* document a provider refuses is not re-uploaded on every tick. A person
* pressing the button is the signal that something changed.
*/
describe('a manual run and the shelved rows', () => {
it('clears the attempt counters of the link it runs', async () => {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
sync.retryShelvedItems.mockClear();
await controller.syncNow(String(tripId), String(created.id), { full: false });
expect(sync.retryShelvedItems).toHaveBeenCalledWith(created.id);
});
it('does not clear them for a link the caller does not own', async () => {
sync.retryShelvedItems.mockClear();
await expect(controller.syncNow(String(tripId), '999999', { full: false })).rejects.toThrow();
expect(sync.retryShelvedItems).not.toHaveBeenCalled();
});
});
/**
* The trip the route was authorised against is now passed on to the service.
*
* `assertCanManage` proves the caller may manage the trip in the URL and says
* nothing about whether the item id in the path belongs to that trip: the id is
* a plain integer, and nothing tied the two together. The check itself lives in
* the service (where the row is), so what belongs here is that the trip reaches
* it at all.
*/
describe('resolving a conflict carries its trip', () => {
it('passes the trip from the URL to the service', async () => {
sync.resolveConflict.mockClear();
await controller.resolve(String(tripId), '42', owner, { keep: 'trek' } as never);
expect(sync.resolveConflict).toHaveBeenCalledWith(42, 'trek', tripId);
});
});