mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
476 lines
20 KiB
TypeScript
476 lines
20 KiB
TypeScript
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } 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';
|
|
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
|
import { createTestOrm } from '../../../helpers/test-orm';
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* The signature is computed here from a fixed id and timestamp, exactly as the
|
|
* provider would. Timers are faked, because the endpoint now collects a burst
|
|
* before it runs: providers fire once per document, and twenty files dropped
|
|
* into a watched folder used to be twenty runs, nineteen of which the service
|
|
* answered `busy` and threw away.
|
|
*/
|
|
|
|
const SECRET = 'M7dQ2vLp5rTn8kYw1xZc4bJh';
|
|
|
|
/**
|
|
* A binding whose subscription TREK registered itself, so the provider was
|
|
* handed the secret and is expected to present it. A binding pasted in by
|
|
* hand has no subscription id; see the cases on that below.
|
|
*/
|
|
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: 'sub-7',
|
|
remote_cursor: null,
|
|
last_sync_at: null,
|
|
last_sync_state: 'never',
|
|
last_sync_error: null,
|
|
failure_count: 0,
|
|
next_attempt_at: null,
|
|
created_by: 1,
|
|
created_at: '2026-09-01 08:00:00',
|
|
updated_at: '2026-09-01 08:00:00',
|
|
...over,
|
|
});
|
|
|
|
const config = {
|
|
getLinkByToken: vi.fn((token: string) => (token === 'tok-live' ? link() : undefined)),
|
|
// Looked up again when the timer fires, so a binding switched off during the
|
|
// window does not get one last run out of a stale row.
|
|
getLink: vi.fn((id: number) => (id === 4 ? link() : undefined)),
|
|
webhookSecret: vi.fn(() => SECRET),
|
|
};
|
|
|
|
const sync = {
|
|
syncLink: vi.fn(async (_link: LinkRow) => ({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })),
|
|
// The Documents addon and the binding's provider, as one answer. What goes
|
|
// into it is the service's business and tested there.
|
|
isSwitchedOff: vi.fn((_link: LinkRow) => false),
|
|
// DSWH1 (R2 — moved off this controller onto DocSyncService.isSyncEnabled):
|
|
// the instance-wide kill switch, the same rule the job's own tick obeys.
|
|
isSyncEnabled: vi.fn(() => true),
|
|
};
|
|
|
|
// R9's withRequestContext wrap needs a real MikroORM EntityManager to fork
|
|
// (RequestContext.create calls em.fork(...)) — a bare `{ em: {} }` double
|
|
// cannot satisfy that, so this file draws the same shared test ORM
|
|
// `StorageHealthNotifierService`'s own unit test uses for the identical
|
|
// reason (its class docstring: "these hand-built doubles pass the shared
|
|
// test ORM instead"). Nothing in this file's own assertions touches SQLite
|
|
// (`config`/`sync` are still full mocks), so no `createTables`/migrations
|
|
// beyond what `createSnapshotTestDb`'s cached snapshot already carries.
|
|
const testDb = createSnapshotTestDb();
|
|
let controller: DocSyncWebhookController;
|
|
|
|
beforeAll(async () => {
|
|
const t = await createTestOrm(testDb);
|
|
controller = new DocSyncWebhookController(config as unknown as DocSyncConfigService, sync as unknown as DocSyncService, t.orm);
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
/** 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();
|
|
vi.useFakeTimers();
|
|
sync.isSwitchedOff.mockReturnValue(false);
|
|
sync.isSyncEnabled.mockReturnValue(true);
|
|
config.getLinkByToken.mockImplementation((token: string) => (token === 'tok-live' ? link() : undefined));
|
|
config.getLink.mockImplementation((id: number) => (id === 4 ? link() : undefined));
|
|
config.webhookSecret.mockReturnValue(SECRET);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await vi.runOnlyPendingTimersAsync();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
/**
|
|
* Let the debounce window pass, so a scheduled run actually happens.
|
|
*
|
|
* The timer callback is an async IIFE (recipe R1.5): advancing the clock only
|
|
* starts it running, it does not wait for the `await`s inside to settle. The
|
|
* async variant advances the timers and drains the microtasks each callback
|
|
* queues before returning, so a scheduled run has actually happened by the
|
|
* time this resolves.
|
|
*/
|
|
async function settle() {
|
|
await vi.advanceTimersByTimeAsync(6000);
|
|
}
|
|
|
|
describe('an unknown token', () => {
|
|
it('answers as if it were known, so nobody can enumerate which tokens exist', async () => {
|
|
expect(await controller.nudge('tok-guessed', makeReq())).toEqual({ received: true });
|
|
});
|
|
|
|
it('schedules no run', async () => {
|
|
await 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', async () => {
|
|
config.getLinkByToken.mockReturnValue(link({ sync_enabled: 0 }));
|
|
expect(await 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', async () => {
|
|
await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
|
|
await settle();
|
|
expect(sync.syncLink).toHaveBeenCalledTimes(1);
|
|
await settle();
|
|
expect(sync.syncLink.mock.calls[0][0]).toMatchObject({ id: 4 });
|
|
});
|
|
|
|
it('triggers nothing when it is wrong, and the answer looks identical', async () => {
|
|
const wrong = `${SECRET.slice(0, -1)}X`;
|
|
expect(await 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', async () => {
|
|
await 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', async () => {
|
|
config.webhookSecret.mockReturnValue('');
|
|
await controller.nudge('tok-live', makeReq());
|
|
await settle();
|
|
expect(sync.syncLink).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A URL somebody pasted into the store by hand.
|
|
*
|
|
* Papra's webhook settings are closed to API keys and Nextcloud's need admin
|
|
* rights, so the person copies the address off the binding card. The secret
|
|
* behind it is never shown to anybody, and Papra signs with a secret of its
|
|
* own, so no such call could ever carry the one TREK holds. Every one of them
|
|
* was dropped, and the binding ran on the timer while the card promised
|
|
* instant updates. The token in the URL is the whole credential there.
|
|
*/
|
|
describe('a binding pasted into the store by hand', () => {
|
|
const pasted = () => link({ webhook_subscription_id: null });
|
|
|
|
beforeEach(() => {
|
|
config.getLinkByToken.mockImplementation((token: string) => (token === 'tok-live' ? pasted() : undefined));
|
|
config.getLink.mockImplementation((id: number) => (id === 4 ? pasted() : undefined));
|
|
});
|
|
|
|
it('runs on the token alone, with no secret in the call', async () => {
|
|
await controller.nudge('tok-live', makeReq());
|
|
await settle();
|
|
expect(sync.syncLink).toHaveBeenCalledTimes(1);
|
|
expect(config.webhookSecret).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('runs on a Papra call signed with a secret TREK has never seen', async () => {
|
|
const payload = JSON.stringify({ event: 'document.created', documentId: 'doc_1' });
|
|
await controller.nudge('tok-live', papraReq(payload, sign(payload, 'papras-own-signing-secret')));
|
|
await settle();
|
|
expect(sync.syncLink).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('still needs the right token', async () => {
|
|
await controller.nudge('tok-guessed', makeReq());
|
|
await settle();
|
|
expect(sync.syncLink).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
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', async () => {
|
|
await controller.nudge('tok-live', papraReq(payload, sign(payload)));
|
|
await settle();
|
|
expect(sync.syncLink).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('triggers nothing when the payload was altered after it was signed', async () => {
|
|
const tampered = JSON.stringify({ event: 'document.created', documentId: 'doc_999' });
|
|
expect(await 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', async () => {
|
|
await 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', async () => {
|
|
const req = makeReq(
|
|
{ 'webhook-id': 'msg_other', 'webhook-timestamp': WEBHOOK_TS, 'webhook-signature': `v1,${sign(payload)}` },
|
|
{ rawBody: Buffer.from(payload, 'utf8') },
|
|
);
|
|
await controller.nudge('tok-live', req);
|
|
expect(sync.syncLink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('accepts a header carrying several signatures, as a key rotation sends', async () => {
|
|
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') },
|
|
);
|
|
await controller.nudge('tok-live', req);
|
|
await settle();
|
|
expect(sync.syncLink).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('verifies against the parsed body when a mount left no raw bytes behind', async () => {
|
|
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 },
|
|
);
|
|
await controller.nudge('tok-live', req);
|
|
await settle();
|
|
expect(sync.syncLink).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('is ignored when the timestamp header is missing, rather than verified without it', async () => {
|
|
const req = makeReq(
|
|
{ 'webhook-id': WEBHOOK_ID, 'webhook-signature': `v1,${sign(payload)}` },
|
|
{ rawBody: Buffer.from(payload, 'utf8') },
|
|
);
|
|
await 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', async () => {
|
|
await expect(controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': 'a' }))).resolves.not.toThrow();
|
|
await expect(controller.nudge('tok-live', papraReq('{}', 'short'))).resolves.not.toThrow();
|
|
expect(sync.syncLink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('is rejected the same way when it is longer than the stored secret', async () => {
|
|
await expect(controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': `${SECRET}extra` }))).resolves.not.toThrow();
|
|
expect(sync.syncLink).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A burst of calls is one run.
|
|
*
|
|
* Every provider here fires per document: a folder of twenty files is twenty
|
|
* calls within a second or two. Each used to start its own run. The service's
|
|
* in-flight guard then answered `busy` to nineteen of them, so nineteen
|
|
* announcements were thrown away and the one run that did start had begun
|
|
* before most of the changes landed.
|
|
*/
|
|
describe('a burst of nudges', () => {
|
|
it('runs once for twenty calls rather than twenty times', async () => {
|
|
for (let i = 0; i < 20; i += 1) {
|
|
await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
|
|
}
|
|
await settle();
|
|
expect(sync.syncLink).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('answers every one of them immediately, before any run happens', async () => {
|
|
const answers = [];
|
|
for (let i = 0; i < 5; i += 1) {
|
|
answers.push(await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })));
|
|
}
|
|
// Paperless gives the call five seconds before it counts it as failed.
|
|
expect(answers).toEqual(Array.from({ length: 5 }, () => ({ received: true })));
|
|
expect(sync.syncLink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('runs again for a burst that arrives after the window closed', async () => {
|
|
await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
|
|
await settle();
|
|
await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
|
|
await settle();
|
|
expect(sync.syncLink).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('keeps one window per binding, so a busy trip cannot starve a quiet one', async () => {
|
|
const other = link({ id: 9, webhook_token: 'tok-other' });
|
|
config.getLinkByToken.mockImplementation((t: string) =>
|
|
t === 'tok-live' ? link() : t === 'tok-other' ? other : undefined);
|
|
config.getLink.mockImplementation((id: number) => (id === 4 ? link() : id === 9 ? other : undefined));
|
|
|
|
await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
|
|
await controller.nudge('tok-other', makeReq({ 'x-trek-docsync-secret': SECRET }));
|
|
await settle();
|
|
|
|
expect(sync.syncLink).toHaveBeenCalledTimes(2);
|
|
expect(sync.syncLink.mock.calls.map((c) => c[0].id).sort()).toEqual([4, 9]);
|
|
});
|
|
|
|
it('drops the run when the binding is switched off inside the window', async () => {
|
|
await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
|
|
config.getLink.mockReturnValue(link({ sync_enabled: 0 }));
|
|
await settle();
|
|
expect(sync.syncLink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('drops the run when the binding is deleted inside the window', async () => {
|
|
await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
|
|
config.getLink.mockReturnValue(undefined);
|
|
await settle();
|
|
expect(sync.syncLink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('forgets its pending timers when the module goes down', async () => {
|
|
await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
|
|
controller.onModuleDestroy();
|
|
await settle();
|
|
expect(sync.syncLink).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The switches an admin expects to mean "off".
|
|
*
|
|
* The scheduler obeys the Documents addon, the binding's provider and the
|
|
* app_settings kill switch; the webhook obeyed none of them, so switching
|
|
* document sync off stopped the poll while every provider holding a webhook
|
|
* carried on driving full runs.
|
|
*/
|
|
describe('the admin switches', () => {
|
|
it('does nothing while the addon or the provider of the binding is switched off', async () => {
|
|
sync.isSwitchedOff.mockReturnValue(true);
|
|
expect(await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }))).toEqual({ received: true });
|
|
await settle();
|
|
expect(sync.isSwitchedOff.mock.calls[0][0]).toMatchObject({ id: 4, provider_id: 'papra' });
|
|
expect(sync.syncLink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does nothing while the kill switch is set', async () => {
|
|
sync.isSyncEnabled.mockReturnValue(false);
|
|
await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
|
|
await settle();
|
|
expect(sync.syncLink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('drops a scheduled run when the provider goes off inside the window', async () => {
|
|
await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
|
|
sync.isSwitchedOff.mockReturnValue(true);
|
|
await settle();
|
|
expect(sync.syncLink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('runs normally while both are on', async () => {
|
|
await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
|
|
await settle();
|
|
expect(sync.syncLink).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A nudge that lands on a run already in flight.
|
|
*
|
|
* `syncLink` answers `busy` and returns, so the nudge was thrown away, and the
|
|
* changes it was about may well have landed after the running pass read the
|
|
* folder, which means waiting out a whole poll interval for them. Asked again
|
|
* once, and only once, so two clients cannot keep each other going.
|
|
*/
|
|
describe('a nudge that arrives mid-run', () => {
|
|
it('asks again once when the run was busy', async () => {
|
|
sync.syncLink.mockResolvedValueOnce({ state: 'busy', pulled: 0, pushed: 0, conflicts: 0, missing: 0 });
|
|
await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
|
|
await settle();
|
|
await Promise.resolve();
|
|
await settle();
|
|
expect(sync.syncLink).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('gives up after that one retry rather than chasing itself', async () => {
|
|
sync.syncLink.mockResolvedValue({ state: 'busy', pulled: 0, pushed: 0, conflicts: 0, missing: 0 });
|
|
await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
|
|
for (let i = 0; i < 5; i += 1) { await settle(); await Promise.resolve(); }
|
|
expect(sync.syncLink).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('does not retry a run that worked', async () => {
|
|
// Set explicitly: clearAllMocks drops the calls but keeps the
|
|
// implementation the previous case installed.
|
|
sync.syncLink.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 });
|
|
await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
|
|
await settle();
|
|
await Promise.resolve();
|
|
await settle();
|
|
expect(sync.syncLink).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|