mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +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.
227 lines
8.5 KiB
TypeScript
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();
|
|
});
|
|
});
|