Files
TREK/server/tests/unit/nest/doc-sync/doc-sync-webhook.controller.test.ts
T
Maurice 2d7cb124b3 feat(documents): two-way sync with Paperless, Papra, Nextcloud, OpenCloud and Synology
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.
2026-09-18 19:23:07 +02:00

227 lines
8.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import crypto from 'crypto';
import type { Request } from 'express';
// Nothing here touches SQLite; the stub keeps the import chain from opening a
// database and running 238 migrations for a handler that never queries one.
vi.mock('../../../../src/db/database', () => ({
db: {},
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => undefined,
isOwner: () => false,
}));
import { DocSyncWebhookController } from '../../../../src/nest/doc-sync/doc-sync-webhook.controller';
import type { DocSyncConfigService, LinkRow } from '../../../../src/nest/doc-sync/doc-sync-config.service';
import type { DocSyncService } from '../../../../src/nest/doc-sync/doc-sync.service';
/**
* The webhook endpoint, with both services stubbed.
*
* It is the only route in TREK a stranger on the internet can reach with a
* guessed URL, so what matters is what it does NOT do: it never says whether a
* token exists, never says whether a secret matched, and never acts on the
* payload. Every case below therefore asserts two things — the answer, which is
* always the same, and whether a run was scheduled, which is the only place the
* decision is visible at all.
*
* No timers and no clock: the signature is computed here from a fixed id and
* timestamp, exactly as the provider would.
*/
const SECRET = 'M7dQ2vLp5rTn8kYw1xZc4bJh';
const link = (over: Partial<LinkRow> = {}): LinkRow => ({
id: 4,
trip_id: 1,
connection_id: 2,
provider_id: 'papra',
remote_scope_key: 'tag:1',
remote_root_id: '1',
remote_root_path: '/TREK/japan',
remote_label: 'Japan 2026',
direction: 'both',
delete_policy: 'unlink',
conflict_policy: 'manual',
sync_enabled: 1,
webhook_token: 'tok-live',
webhook_secret: 'enc:v1:whatever',
webhook_subscription_id: null,
remote_cursor: null,
last_sync_at: null,
last_sync_state: 'never',
last_sync_error: null,
failure_count: 0,
next_attempt_at: null,
...over,
});
const config = {
getLinkByToken: vi.fn((token: string) => (token === 'tok-live' ? link() : undefined)),
webhookSecret: vi.fn(() => SECRET),
};
const sync = {
syncLink: vi.fn(async (_link: LinkRow) => ({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })),
};
const controller = new DocSyncWebhookController(
config as unknown as DocSyncConfigService,
sync as unknown as DocSyncService,
);
/** Only what the handler reads: headers, the parsed body, and the raw bytes. */
function makeReq(headers: Record<string, string> = {}, opts: { body?: unknown; rawBody?: Buffer | undefined } = {}): Request {
return {
headers,
body: opts.body,
rawBody: opts.rawBody,
get: (name: string) => headers[name.toLowerCase()],
} as unknown as Request;
}
const WEBHOOK_ID = 'msg_2f8a';
const WEBHOOK_TS = '1758200000';
function sign(payload: string, secret = SECRET, id = WEBHOOK_ID, ts = WEBHOOK_TS): string {
return crypto.createHmac('sha256', Buffer.from(secret)).update(`${id}.${ts}.${payload}`).digest('base64');
}
function papraReq(payload: string, signature: string, sent = payload): Request {
return makeReq(
{ 'webhook-id': WEBHOOK_ID, 'webhook-timestamp': WEBHOOK_TS, 'webhook-signature': `v1,${signature}` },
{ rawBody: Buffer.from(sent, 'utf8') },
);
}
beforeEach(() => {
vi.clearAllMocks();
config.getLinkByToken.mockImplementation((token: string) => (token === 'tok-live' ? link() : undefined));
config.webhookSecret.mockReturnValue(SECRET);
});
describe('an unknown token', () => {
it('answers as if it were known, so nobody can enumerate which tokens exist', () => {
expect(controller.nudge('tok-guessed', makeReq())).toEqual({ received: true });
});
it('schedules no run', () => {
controller.nudge('tok-guessed', makeReq());
expect(sync.syncLink).not.toHaveBeenCalled();
});
});
describe('a binding whose sync is switched off', () => {
it('is left alone, and says nothing about it', () => {
config.getLinkByToken.mockReturnValue(link({ sync_enabled: 0 }));
expect(controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }))).toEqual({ received: true });
expect(sync.syncLink).not.toHaveBeenCalled();
});
});
describe('a shared-secret header', () => {
it('triggers the run when it matches', () => {
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
expect(sync.syncLink).toHaveBeenCalledTimes(1);
expect(sync.syncLink.mock.calls[0][0]).toMatchObject({ id: 4 });
});
it('triggers nothing when it is wrong, and the answer looks identical', () => {
const wrong = `${SECRET.slice(0, -1)}X`;
expect(controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': wrong }))).toEqual({ received: true });
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('triggers nothing when the header is missing altogether', () => {
controller.nudge('tok-live', makeReq());
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('is not demanded from a binding that carries no secret — the token alone authenticates there', () => {
config.webhookSecret.mockReturnValue('');
controller.nudge('tok-live', makeReq());
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
});
describe('a Papra standard-webhooks signature', () => {
const payload = JSON.stringify({ event: 'document.created', documentId: 'doc_1' });
it('triggers the run when it covers the bytes that arrived', () => {
controller.nudge('tok-live', papraReq(payload, sign(payload)));
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
it('triggers nothing when the payload was altered after it was signed', () => {
const tampered = JSON.stringify({ event: 'document.created', documentId: 'doc_999' });
expect(controller.nudge('tok-live', papraReq(payload, sign(payload), tampered))).toEqual({ received: true });
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('triggers nothing when the signature was made with another secret', () => {
controller.nudge('tok-live', papraReq(payload, sign(payload, 'someone-elses-secret-value')));
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('triggers nothing when the id or timestamp does not match what was signed', () => {
const req = makeReq(
{ 'webhook-id': 'msg_other', 'webhook-timestamp': WEBHOOK_TS, 'webhook-signature': `v1,${sign(payload)}` },
{ rawBody: Buffer.from(payload, 'utf8') },
);
controller.nudge('tok-live', req);
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('accepts a header carrying several signatures, as a key rotation sends', () => {
const req = makeReq(
{
'webhook-id': WEBHOOK_ID,
'webhook-timestamp': WEBHOOK_TS,
'webhook-signature': `v1,${sign(payload, 'the-previous-secret')} v1,${sign(payload)}`,
},
{ rawBody: Buffer.from(payload, 'utf8') },
);
controller.nudge('tok-live', req);
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
it('verifies against the parsed body when a mount left no raw bytes behind', () => {
const body = { event: 'document.created', documentId: 'doc_1' };
const req = makeReq(
{ 'webhook-id': WEBHOOK_ID, 'webhook-timestamp': WEBHOOK_TS, 'webhook-signature': `v1,${sign(JSON.stringify(body))}` },
{ body },
);
controller.nudge('tok-live', req);
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
it('is ignored when the timestamp header is missing, rather than verified without it', () => {
const req = makeReq(
{ 'webhook-id': WEBHOOK_ID, 'webhook-signature': `v1,${sign(payload)}` },
{ rawBody: Buffer.from(payload, 'utf8') },
);
controller.nudge('tok-live', req);
expect(sync.syncLink).not.toHaveBeenCalled();
});
});
describe('a credential of the wrong length', () => {
// crypto.timingSafeEqual throws on buffers of unequal length, so a one-byte
// header would turn into a 500 that tells a prober the secret is longer than
// what they sent.
it('is rejected rather than thrown over, whether it arrives as a header or as a signature', () => {
expect(() => controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': 'a' }))).not.toThrow();
expect(() =>
controller.nudge('tok-live', papraReq('{}', 'short')),
).not.toThrow();
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('is rejected the same way when it is longer than the stored secret', () => {
expect(() => controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': `${SECRET}extra` }))).not.toThrow();
expect(sync.syncLink).not.toHaveBeenCalled();
});
});