feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed: - webhooks with delivery table, retries and stable delivery id - calendar handlers; a finished task removes its Outlook event - tasks, automations, inspections, varroa, treatments, feedings, harvests, colonies, wintering, stock and bottling record domain events in the same transaction; legacy post-commit calls removed - row locks through one helper (lockRowForUpdate, lockRowsForShare, lockRowsForUpdate) - job framework with JobRun, cli exit codes, cleanup, task templates, daily evaluation, hourly app-backup and the rekey job - system API and page "Verwaltung > System" with retry - @nestjs/schedule removed, guard tests against in-process schedulers Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed: - inspection photo offline without metadata - bulk capture for treatments, feedings and varroa (backend + frontend) - offline stand mode with service worker, offline pages and packs - offline session, cold start and logout on shared devices Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md. Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema drift; frontend lint, typecheck, 913 tests, build. Not rolled out. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
@@ -0,0 +1,21 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export const WEBHOOK_DELIVERIES_DEFAULT_LIMIT = 50;
|
||||
export const WEBHOOK_DELIVERIES_MAX_LIMIT = 100;
|
||||
|
||||
export class ListWebhookDeliveriesDto {
|
||||
@ApiPropertyOptional({
|
||||
minimum: 1,
|
||||
maximum: WEBHOOK_DELIVERIES_MAX_LIMIT,
|
||||
default: WEBHOOK_DELIVERIES_DEFAULT_LIMIT,
|
||||
description: 'Anzahl der neuesten Zustellungen',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(WEBHOOK_DELIVERIES_MAX_LIMIT)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import * as net from 'node:net';
|
||||
import {
|
||||
postWebhook,
|
||||
WEBHOOK_DEADLINE_MS,
|
||||
WEBHOOK_TIMEOUT_MS,
|
||||
} from './webhook-delivery';
|
||||
|
||||
// Real deliveries refuse loopback; these tests need a local receiver. The
|
||||
// guard itself is covered by webhook-url-guard.spec.ts and webhooks.e2e.
|
||||
jest.mock('./webhook-url-guard', () => ({
|
||||
...jest.requireActual<object>('./webhook-url-guard'),
|
||||
isBlockedIp: () => false,
|
||||
}));
|
||||
|
||||
const DEADLINE_MESSAGE = 'Webhook receiver did not finish answering in time';
|
||||
/** Well below the 5 s idle timeout: only the deadline can end the exchange. */
|
||||
const TRICKLE_EVERY_MS = 50;
|
||||
const TEST_DEADLINE_MS = 300;
|
||||
|
||||
interface Receiver {
|
||||
url: string;
|
||||
/** Resolves once the receiver's side of the connection is closed. */
|
||||
closed: Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** A raw TCP receiver: `answer` writes whatever bytes it wants. */
|
||||
async function startReceiver(
|
||||
answer: (socket: net.Socket) => void,
|
||||
): Promise<Receiver> {
|
||||
let markClosed!: () => void;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
markClosed = resolve;
|
||||
});
|
||||
const sockets = new Set<net.Socket>();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on('error', () => undefined);
|
||||
socket.on('close', () => {
|
||||
sockets.delete(socket);
|
||||
markClosed();
|
||||
});
|
||||
socket.once('data', () => answer(socket));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address() as net.AddressInfo;
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}/hook`,
|
||||
closed,
|
||||
stop: async () => {
|
||||
sockets.forEach((socket) => socket.destroy());
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Writes `chunk` every TRICKLE_EVERY_MS until the connection closes. */
|
||||
function trickle(socket: net.Socket, chunk: string): void {
|
||||
const timer = setInterval(() => socket.write(chunk), TRICKLE_EVERY_MS);
|
||||
socket.on('close', () => clearInterval(timer));
|
||||
}
|
||||
|
||||
describe('postWebhook', () => {
|
||||
let receiver: Receiver | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
await receiver?.stop();
|
||||
receiver = undefined;
|
||||
});
|
||||
|
||||
it('pins the idle timeout and the deadline of a whole exchange', () => {
|
||||
expect(WEBHOOK_TIMEOUT_MS).toBe(5_000);
|
||||
expect(WEBHOOK_DEADLINE_MS).toBe(10_000);
|
||||
});
|
||||
|
||||
it('aborts the request and closes the socket when the headers are not complete by the deadline', async () => {
|
||||
receiver = await startReceiver((socket) => {
|
||||
socket.write('HTTP/1.1 200 OK\r\n');
|
||||
trickle(socket, 'X-Slow: 1\r\n');
|
||||
});
|
||||
const startedAt = performance.now();
|
||||
|
||||
await expect(
|
||||
postWebhook(receiver.url, {}, '{}', TEST_DEADLINE_MS),
|
||||
).rejects.toThrow(DEADLINE_MESSAGE);
|
||||
await receiver.closed;
|
||||
|
||||
expect(performance.now() - startedAt).toBeLessThan(2_000);
|
||||
}, 3_000);
|
||||
|
||||
it('cuts an answer body that is still trickling at the deadline', async () => {
|
||||
receiver = await startReceiver((socket) => {
|
||||
socket.write('HTTP/1.1 200 OK\r\nContent-Length: 100000\r\n\r\n');
|
||||
trickle(socket, 'x');
|
||||
});
|
||||
const startedAt = performance.now();
|
||||
|
||||
// The status counts as soon as it arrives; the body is never read.
|
||||
await expect(
|
||||
postWebhook(receiver.url, {}, '{}', TEST_DEADLINE_MS),
|
||||
).resolves.toEqual({ status: 200 });
|
||||
await receiver.closed;
|
||||
|
||||
expect(performance.now() - startedAt).toBeLessThan(2_000);
|
||||
}, 3_000);
|
||||
|
||||
it('answers a prompt receiver and clears its deadline, 10 s by default', async () => {
|
||||
const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
|
||||
const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
|
||||
receiver = await startReceiver((socket) => {
|
||||
socket.end('HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n');
|
||||
});
|
||||
|
||||
await expect(postWebhook(receiver.url, {}, '{}')).resolves.toEqual({
|
||||
status: 204,
|
||||
});
|
||||
await receiver.closed;
|
||||
|
||||
const deadlineCall = setTimeoutSpy.mock.calls.findIndex(
|
||||
([, ms]) => ms === WEBHOOK_DEADLINE_MS,
|
||||
);
|
||||
expect(deadlineCall).toBeGreaterThanOrEqual(0);
|
||||
const deadlineTimer = setTimeoutSpy.mock.results[deadlineCall]
|
||||
.value as NodeJS.Timeout;
|
||||
// Cleared once the request closes - a finished exchange is never cut.
|
||||
const deadlineCleared = () =>
|
||||
clearTimeoutSpy.mock.calls.some(([timer]) => timer === deadlineTimer);
|
||||
for (let i = 0; i < 50 && !deadlineCleared(); i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
expect(deadlineCleared()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -5,21 +5,30 @@ import * as net from 'node:net';
|
||||
|
||||
/** A receiver gets this long to answer; a hanging one must not pile up. */
|
||||
export const WEBHOOK_TIMEOUT_MS = 5_000;
|
||||
/**
|
||||
* Upper bound for a whole exchange, answer included. WEBHOOK_TIMEOUT_MS is
|
||||
* an idle timeout: a receiver that trickles its answer byte by byte would
|
||||
* otherwise keep the connection - and a worker slot - open indefinitely.
|
||||
*/
|
||||
export const WEBHOOK_DEADLINE_MS = 10_000;
|
||||
|
||||
export interface WebhookResponse {
|
||||
status: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* POSTs a webhook without following redirects, with a hard timeout, and
|
||||
* through guardedLookup, so the connection can only ever go to a public
|
||||
* address - whatever the host name resolves to by then. The response body
|
||||
* is discarded unread.
|
||||
* POSTs a webhook without following redirects, with an idle timeout and a
|
||||
* deadline, and through guardedLookup, so the connection can only ever go
|
||||
* to a public address - whatever the host name resolves to by then. The
|
||||
* status counts as soon as it arrives; the response body is discarded
|
||||
* unread. At the deadline the request is destroyed, and with it the socket
|
||||
* and an answer still in transfer - before the status, the promise rejects.
|
||||
*/
|
||||
export function postWebhook(
|
||||
rawUrl: string,
|
||||
headers: Record<string, string>,
|
||||
body: string,
|
||||
deadlineMs = WEBHOOK_DEADLINE_MS,
|
||||
): Promise<WebhookResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(rawUrl);
|
||||
@@ -45,6 +54,15 @@ export function postWebhook(
|
||||
resolve({ status: response.statusCode ?? 0 });
|
||||
},
|
||||
);
|
||||
const deadline = setTimeout(
|
||||
() =>
|
||||
request.destroy(
|
||||
new Error('Webhook receiver did not finish answering in time'),
|
||||
),
|
||||
deadlineMs,
|
||||
);
|
||||
// 'close' ends every exchange - answered, failed or destroyed.
|
||||
request.on('close', () => clearTimeout(deadline));
|
||||
request.on('timeout', () =>
|
||||
request.destroy(new Error('Webhook receiver did not answer in time')),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { MAX_ATTEMPTS } from '../common/retry-policy';
|
||||
import { DRAIN_MAX_ROUNDS } from '../domain-events/domain-event-dispatcher';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { postWebhook, WebhookResponse } from './webhook-delivery';
|
||||
import {
|
||||
WEBHOOK_DELIVERY_BATCH_SIZE,
|
||||
WEBHOOK_DELIVERY_CONCURRENCY,
|
||||
WebhookDeliveryWorker,
|
||||
} from './webhook-delivery.worker';
|
||||
import { sealWebhookSecret } from './webhook-secret';
|
||||
|
||||
jest.mock('./webhook-delivery', () => ({
|
||||
...jest.requireActual<object>('./webhook-delivery'),
|
||||
postWebhook: jest.fn(),
|
||||
}));
|
||||
|
||||
const postWebhookMock = postWebhook as jest.MockedFunction<typeof postWebhook>;
|
||||
|
||||
const SECRET = 'topsecret-signing-key';
|
||||
const CLAIMED_AT = new Date('2026-10-13T08:00:01.234Z');
|
||||
const BODY = JSON.stringify({
|
||||
event: 'colony.created',
|
||||
data: { id: 'c1', notes: 'private note' },
|
||||
});
|
||||
|
||||
/** A claimed WebhookDelivery row, as RETURNING * yields it. */
|
||||
interface ClaimedRow {
|
||||
id: string;
|
||||
subscriptionId: string;
|
||||
event: string;
|
||||
body: string;
|
||||
status: 'PROCESSING';
|
||||
attempts: number;
|
||||
claimedAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface SubscriptionRow {
|
||||
url: string;
|
||||
secret: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface StatusCodeUpdate {
|
||||
where: { id: string; status: string; claimedAt: Date };
|
||||
data: { lastStatusCode: number | null };
|
||||
}
|
||||
|
||||
function row(overrides: Partial<ClaimedRow> = {}): ClaimedRow {
|
||||
return {
|
||||
id: 'd1',
|
||||
subscriptionId: 'wh-1',
|
||||
event: 'colony.created',
|
||||
body: BODY,
|
||||
status: 'PROCESSING',
|
||||
attempts: 0,
|
||||
claimedAt: CLAIMED_AT,
|
||||
createdAt: new Date('2026-10-13T08:00:00.000Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function text(query: Prisma.Sql): string {
|
||||
return query.sql.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
/**
|
||||
* Stands in for Postgres: a queue of claim results, the subscriptions, and
|
||||
* every statement the worker sent.
|
||||
*/
|
||||
class FakeDb {
|
||||
readonly claims: ClaimedRow[][] = [];
|
||||
/** Rows the stale release returns, one list per call. */
|
||||
readonly releases: { id: string; status: 'PENDING' | 'FAILED' }[][] = [];
|
||||
readonly subscriptions = new Map<string, SubscriptionRow>();
|
||||
|
||||
readonly tx = {
|
||||
webhookDelivery: {
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||
};
|
||||
|
||||
readonly prisma = {
|
||||
$queryRaw: jest.fn((query: Prisma.Sql) =>
|
||||
Promise.resolve(
|
||||
(query.sql.includes('SKIP LOCKED')
|
||||
? this.claims.shift()
|
||||
: this.releases.shift()) ?? [],
|
||||
),
|
||||
),
|
||||
$transaction: jest.fn((fn: (tx: unknown) => Promise<unknown>) =>
|
||||
fn(this.tx),
|
||||
),
|
||||
webhookSubscription: {
|
||||
findUnique: jest.fn(({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve(this.subscriptions.get(where.id) ?? null),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
claimQuery(call = 0): Prisma.Sql {
|
||||
return (this.prisma.$queryRaw.mock.calls as unknown as [Prisma.Sql][])[
|
||||
call
|
||||
][0];
|
||||
}
|
||||
|
||||
/** Settle statements (outbox-claims), in the order they were sent. */
|
||||
settles(): Prisma.Sql[] {
|
||||
return (this.tx.$executeRaw.mock.calls as [Prisma.Sql][]).map(
|
||||
([query]) => query,
|
||||
);
|
||||
}
|
||||
|
||||
statusCodeUpdates(): StatusCodeUpdate[] {
|
||||
return (
|
||||
this.tx.webhookDelivery.updateMany.mock.calls as [StatusCodeUpdate][]
|
||||
).map(([args]) => args);
|
||||
}
|
||||
}
|
||||
|
||||
describe('WebhookDeliveryWorker', () => {
|
||||
let db: FakeDb;
|
||||
let worker: WebhookDeliveryWorker;
|
||||
let logWarn: jest.SpyInstance;
|
||||
let logError: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret-0123456789';
|
||||
db = new FakeDb();
|
||||
db.subscriptions.set('wh-1', {
|
||||
url: 'https://receiver.example/hook',
|
||||
secret: sealWebhookSecret(SECRET),
|
||||
active: true,
|
||||
});
|
||||
postWebhookMock.mockReset();
|
||||
postWebhookMock.mockResolvedValue({ status: 200 });
|
||||
worker = new WebhookDeliveryWorker(db.prisma as unknown as PrismaService);
|
||||
logWarn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
logError = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function logLines(): string {
|
||||
const calls = [
|
||||
...(logWarn.mock.calls as unknown[][]),
|
||||
...(logError.mock.calls as unknown[][]),
|
||||
];
|
||||
return calls
|
||||
.flat()
|
||||
.map((part) => String(part))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
it('pins batch size and concurrency', () => {
|
||||
expect(WEBHOOK_DELIVERY_BATCH_SIZE).toBe(50);
|
||||
expect(WEBHOOK_DELIVERY_CONCURRENCY).toBe(5);
|
||||
});
|
||||
|
||||
it('claims at most 50 due deliveries, oldest first, skipping locked rows, by the database clock', async () => {
|
||||
await worker.runOnce();
|
||||
|
||||
const claim = db.claimQuery();
|
||||
expect(text(claim)).toContain(
|
||||
`UPDATE "WebhookDelivery" SET "status" = 'PROCESSING', "claimedAt" = now()`,
|
||||
);
|
||||
expect(text(claim)).toContain(
|
||||
`WHERE "status" = 'PENDING' AND "availableAt" <= now()`,
|
||||
);
|
||||
expect(text(claim)).toContain(
|
||||
'ORDER BY "createdAt" LIMIT ? FOR UPDATE SKIP LOCKED',
|
||||
);
|
||||
expect(claim.values).toEqual([50]);
|
||||
});
|
||||
|
||||
it('restricts the claim to the given subscriptions', async () => {
|
||||
await worker.runOnce({ subscriptionIds: ['wh-1', 'wh-2'] });
|
||||
|
||||
const claim = db.claimQuery();
|
||||
expect(text(claim)).toContain(
|
||||
`"availableAt" <= now() AND "subscriptionId" = ANY(?) ORDER BY`,
|
||||
);
|
||||
expect(claim.values).toEqual([['wh-1', 'wh-2'], 50]);
|
||||
});
|
||||
|
||||
it('signs "<t>.<delivery id>.<body>" and sends the delivery id as X-ApiCore-Delivery', async () => {
|
||||
db.claims.push([row()]);
|
||||
|
||||
await expect(worker.runOnce()).resolves.toBe(1);
|
||||
|
||||
expect(postWebhookMock).toHaveBeenCalledTimes(1);
|
||||
const [url, headers, body] = postWebhookMock.mock.calls[0];
|
||||
expect(url).toBe('https://receiver.example/hook');
|
||||
// The body stored at enqueue time, byte for byte.
|
||||
expect(body).toBe(BODY);
|
||||
expect(headers).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'ApiCore-Webhooks/1',
|
||||
'X-ApiCore-Event': 'colony.created',
|
||||
'X-ApiCore-Delivery': 'd1',
|
||||
'X-ApiCore-Timestamp': expect.stringMatching(/^\d+$/) as unknown,
|
||||
'X-ApiCore-Signature': expect.stringMatching(
|
||||
/^sha256=[0-9a-f]{64}$/,
|
||||
) as unknown,
|
||||
});
|
||||
const expected = crypto
|
||||
.createHmac('sha256', SECRET)
|
||||
.update(`${headers['X-ApiCore-Timestamp']}.d1.${BODY}`)
|
||||
.digest('hex');
|
||||
expect(headers['X-ApiCore-Signature']).toBe(`sha256=${expected}`);
|
||||
});
|
||||
|
||||
it.each([200, 201, 204, 299])(
|
||||
'counts %i as delivered and stores the status code',
|
||||
async (status) => {
|
||||
postWebhookMock.mockResolvedValueOnce({ status });
|
||||
db.claims.push([row()]);
|
||||
|
||||
await worker.runOnce();
|
||||
|
||||
expect(db.statusCodeUpdates()).toEqual([
|
||||
{
|
||||
where: { id: 'd1', status: 'PROCESSING', claimedAt: CLAIMED_AT },
|
||||
data: { lastStatusCode: status },
|
||||
},
|
||||
]);
|
||||
const [settle] = db.settles();
|
||||
expect(text(settle)).toContain('"deliveredAt" = now()');
|
||||
expect(text(settle)).toContain(
|
||||
`WHERE "id" = ? AND "status" = 'PROCESSING' AND "claimedAt" = ?`,
|
||||
);
|
||||
expect(settle.values).toEqual(['DELIVERED', 'd1', CLAIMED_AT]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([301, 302, 400, 404, 429, 500, 503])(
|
||||
'counts only 2xx as delivered: %i is a failed attempt and its status code is stored',
|
||||
async (status) => {
|
||||
postWebhookMock.mockResolvedValueOnce({ status });
|
||||
db.claims.push([row()]);
|
||||
|
||||
await worker.runOnce();
|
||||
|
||||
expect(db.statusCodeUpdates()[0].data).toEqual({
|
||||
lastStatusCode: status,
|
||||
});
|
||||
const [settle] = db.settles();
|
||||
expect(text(settle)).not.toContain('"deliveredAt"');
|
||||
expect(settle.values).toContain('PENDING');
|
||||
expect(settle.values).toContain(`Receiver answered HTTP ${status}`);
|
||||
},
|
||||
);
|
||||
|
||||
it('schedules a retry 30 s ahead after a 500 and keeps the same id', async () => {
|
||||
postWebhookMock
|
||||
.mockResolvedValueOnce({ status: 500 })
|
||||
.mockResolvedValueOnce({ status: 200 });
|
||||
db.claims.push([row({ attempts: 0 })]);
|
||||
|
||||
await worker.runOnce();
|
||||
|
||||
const [retry] = db.settles();
|
||||
expect(text(retry)).toContain(`"attempts" = "attempts" + 1`);
|
||||
expect(text(retry)).toContain(
|
||||
`"availableAt" = now() + (? * interval '1 millisecond')`,
|
||||
);
|
||||
expect(text(retry)).toContain(
|
||||
`WHERE "id" = ? AND "status" = 'PROCESSING' AND "claimedAt" = ?`,
|
||||
);
|
||||
expect(retry.values).toEqual([
|
||||
'PENDING',
|
||||
'Receiver answered HTTP 500',
|
||||
30_000,
|
||||
'd1',
|
||||
CLAIMED_AT,
|
||||
]);
|
||||
|
||||
// The retry is the same row, claimed again: same id, same body.
|
||||
const reclaimedAt = new Date('2026-10-13T08:00:31.500Z');
|
||||
db.claims.push([row({ attempts: 1, claimedAt: reclaimedAt })]);
|
||||
await worker.runOnce();
|
||||
|
||||
const [first, second] = postWebhookMock.mock.calls;
|
||||
expect(second[1]['X-ApiCore-Delivery']).toBe(
|
||||
first[1]['X-ApiCore-Delivery'],
|
||||
);
|
||||
expect(second[2]).toBe(first[2]);
|
||||
expect(db.settles()[1].values).toEqual(['DELIVERED', 'd1', reclaimedAt]);
|
||||
});
|
||||
|
||||
it('marks FAILED after the 8th attempt', async () => {
|
||||
postWebhookMock.mockResolvedValueOnce({ status: 500 });
|
||||
db.claims.push([row({ attempts: MAX_ATTEMPTS - 1 })]);
|
||||
|
||||
await worker.runOnce();
|
||||
|
||||
const [settle] = db.settles();
|
||||
expect(settle.values).toEqual([
|
||||
'FAILED',
|
||||
'Receiver answered HTTP 500',
|
||||
'd1',
|
||||
CLAIMED_AT,
|
||||
]);
|
||||
expect(text(settle)).not.toContain('"availableAt"');
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Webhook delivery d1 (colony.created) FAILED'),
|
||||
);
|
||||
});
|
||||
|
||||
it('fails a delivery for an inactive subscription without sending ("subscription inactive")', async () => {
|
||||
db.subscriptions.set('wh-1', {
|
||||
...db.subscriptions.get('wh-1')!,
|
||||
active: false,
|
||||
});
|
||||
db.claims.push([row()]);
|
||||
|
||||
await worker.runOnce();
|
||||
|
||||
expect(postWebhookMock).not.toHaveBeenCalled();
|
||||
expect(db.statusCodeUpdates()[0].data).toEqual({ lastStatusCode: null });
|
||||
const [settle] = db.settles();
|
||||
expect(settle.values).toEqual([
|
||||
'PENDING',
|
||||
'subscription inactive',
|
||||
30_000,
|
||||
'd1',
|
||||
CLAIMED_AT,
|
||||
]);
|
||||
});
|
||||
|
||||
it('stores the loggable error of a failed connection without a status code', async () => {
|
||||
postWebhookMock.mockRejectedValueOnce(
|
||||
new Error('Webhook host receiver.example resolves to a blocked address'),
|
||||
);
|
||||
db.claims.push([row()]);
|
||||
|
||||
await worker.runOnce();
|
||||
|
||||
expect(db.statusCodeUpdates()[0].data).toEqual({ lastStatusCode: null });
|
||||
expect(db.settles()[0].values).toContain(
|
||||
'Webhook host receiver.example resolves to a blocked address',
|
||||
);
|
||||
});
|
||||
|
||||
it('never stores the response body or the secret in lastError', async () => {
|
||||
postWebhookMock
|
||||
.mockResolvedValueOnce({ status: 500 })
|
||||
.mockRejectedValueOnce(new Error('socket hang up'));
|
||||
db.claims.push([row({ id: 'd1' }), row({ id: 'd2' })]);
|
||||
|
||||
await worker.runOnce();
|
||||
|
||||
const lastErrorOf = (id: string): string =>
|
||||
String(
|
||||
db.settles().find((settle) => settle.values.includes(id))?.values[1],
|
||||
);
|
||||
// A fixed text for an answer: postWebhook discards the body unread.
|
||||
expect(lastErrorOf('d1')).toBe('Receiver answered HTTP 500');
|
||||
expect(lastErrorOf('d2')).toBe('socket hang up');
|
||||
const signatures = postWebhookMock.mock.calls.map(([, headers]) =>
|
||||
headers['X-ApiCore-Signature'].slice('sha256='.length),
|
||||
);
|
||||
for (const stored of [lastErrorOf('d1'), lastErrorOf('d2'), logLines()]) {
|
||||
expect(stored).not.toContain(SECRET);
|
||||
expect(stored).not.toContain('private note');
|
||||
signatures.forEach((signature) =>
|
||||
expect(stored).not.toContain(signature),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('logs a Prisma error by name and code only', async () => {
|
||||
db.prisma.webhookSubscription.findUnique.mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientKnownRequestError(
|
||||
'Invalid query: url = "https://receiver.example/hook?token=abc"',
|
||||
{ code: 'P2024', clientVersion: '7.9.1' },
|
||||
),
|
||||
);
|
||||
db.claims.push([row()]);
|
||||
|
||||
await worker.runOnce();
|
||||
|
||||
expect(logLines()).toContain('P2024');
|
||||
expect(logLines()).not.toContain('token=abc');
|
||||
});
|
||||
|
||||
it('sends at most 5 at once', async () => {
|
||||
const answers = Array.from({ length: 12 }, () =>
|
||||
deferred<WebhookResponse>(),
|
||||
);
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
answers.forEach((answer) =>
|
||||
postWebhookMock.mockImplementationOnce(() => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
return answer.promise.finally(() => {
|
||||
inFlight -= 1;
|
||||
});
|
||||
}),
|
||||
);
|
||||
db.claims.push(
|
||||
Array.from({ length: 12 }, (_, index) => row({ id: `d${index}` })),
|
||||
);
|
||||
|
||||
const run = worker.runOnce();
|
||||
await flush();
|
||||
expect(postWebhookMock).toHaveBeenCalledTimes(5);
|
||||
|
||||
answers[0].resolve({ status: 200 });
|
||||
await flush();
|
||||
expect(postWebhookMock).toHaveBeenCalledTimes(6);
|
||||
|
||||
answers.forEach((answer) => answer.resolve({ status: 200 }));
|
||||
await expect(run).resolves.toBe(12);
|
||||
expect(postWebhookMock).toHaveBeenCalledTimes(12);
|
||||
expect(maxInFlight).toBe(5);
|
||||
});
|
||||
|
||||
// postWebhook ends the exchange - request and socket - at its deadline
|
||||
// (webhook-delivery.spec.ts); the worker keeps the default and counts it.
|
||||
it('counts a missed deadline as a failed attempt without a status code', async () => {
|
||||
postWebhookMock.mockRejectedValueOnce(
|
||||
new Error('Webhook receiver did not finish answering in time'),
|
||||
);
|
||||
db.claims.push([row()]);
|
||||
|
||||
await expect(worker.runOnce()).resolves.toBe(1);
|
||||
|
||||
expect(postWebhookMock.mock.calls[0]).toEqual([
|
||||
'https://receiver.example/hook',
|
||||
expect.any(Object),
|
||||
BODY,
|
||||
]);
|
||||
expect(db.statusCodeUpdates()[0].data).toEqual({ lastStatusCode: null });
|
||||
expect(db.settles()[0].values).toContain(
|
||||
'Webhook receiver did not finish answering in time',
|
||||
);
|
||||
});
|
||||
|
||||
it('settles the rest of the batch when one delivery cannot be processed', async () => {
|
||||
db.prisma.webhookSubscription.findUnique.mockRejectedValueOnce(
|
||||
new Error('connection reset'),
|
||||
);
|
||||
db.claims.push([row({ id: 'd1' }), row({ id: 'd2' })]);
|
||||
|
||||
await expect(worker.runOnce()).resolves.toBe(2);
|
||||
|
||||
expect(db.settles().map((settle) => settle.values)).toEqual([
|
||||
['DELIVERED', 'd2', CLAIMED_AT],
|
||||
]);
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Webhook delivery d1 (colony.created) not settled',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('changes nothing when the claim was lost before settling', async () => {
|
||||
db.tx.webhookDelivery.updateMany.mockResolvedValueOnce({ count: 0 });
|
||||
db.claims.push([row()]);
|
||||
|
||||
await worker.runOnce();
|
||||
|
||||
expect(db.settles()).toEqual([]);
|
||||
expect(logWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('claim lost before settling'),
|
||||
);
|
||||
});
|
||||
|
||||
describe('drain', () => {
|
||||
it('runs batches until one claims nothing', async () => {
|
||||
db.claims.push([row({ id: 'd1' })], [row({ id: 'd2' })]);
|
||||
|
||||
await expect(worker.drain()).resolves.toBe(2);
|
||||
|
||||
expect(db.prisma.$queryRaw).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it(`stops after ${DRAIN_MAX_ROUNDS} rounds by default and after maxRounds when given`, async () => {
|
||||
for (let i = 0; i < DRAIN_MAX_ROUNDS + 5; i++) {
|
||||
db.claims.push([row({ id: `d${i}` })]);
|
||||
}
|
||||
|
||||
await expect(worker.drain(undefined, 2)).resolves.toBe(2);
|
||||
await expect(worker.drain()).resolves.toBe(DRAIN_MAX_ROUNDS);
|
||||
});
|
||||
|
||||
it('passes the subscription filter to every claim', async () => {
|
||||
db.claims.push([row()]);
|
||||
|
||||
await worker.drain({ subscriptionIds: ['wh-1'] });
|
||||
|
||||
expect(db.claimQuery(0).values).toEqual([['wh-1'], 50]);
|
||||
expect(db.claimQuery(1).values).toEqual([['wh-1'], 50]);
|
||||
});
|
||||
|
||||
it('claims no further batch once the signal aborts, but finishes the claimed one', async () => {
|
||||
const stop = new AbortController();
|
||||
postWebhookMock.mockImplementationOnce(() => {
|
||||
stop.abort();
|
||||
return Promise.resolve({ status: 200 });
|
||||
});
|
||||
db.claims.push([row({ id: 'd1' })], [row({ id: 'd2' })]);
|
||||
|
||||
await expect(worker.drain(undefined, 20, stop.signal)).resolves.toBe(1);
|
||||
|
||||
expect(db.prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(db.settles()[0].values).toEqual(['DELIVERED', 'd1', CLAIMED_AT]);
|
||||
});
|
||||
|
||||
it('claims nothing when the signal is already aborted', async () => {
|
||||
const stop = new AbortController();
|
||||
stop.abort();
|
||||
|
||||
await expect(worker.drain(undefined, 20, stop.signal)).resolves.toBe(0);
|
||||
|
||||
expect(db.prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('releases stale claims of the delivery table and logs the ones that FAILED', async () => {
|
||||
db.releases.push([
|
||||
{ id: 'd1', status: 'PENDING' },
|
||||
{ id: 'd2', status: 'FAILED' },
|
||||
]);
|
||||
|
||||
await expect(worker.releaseStaleClaims()).resolves.toBe(2);
|
||||
|
||||
const release = db.claimQuery();
|
||||
expect(text(release)).toContain('UPDATE "WebhookDelivery"');
|
||||
expect(text(release)).toContain(
|
||||
`"claimedAt" < now() - (? * interval '1 millisecond')`,
|
||||
);
|
||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining('d2'));
|
||||
expect(logLines()).not.toContain('d1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import {
|
||||
claimBatch,
|
||||
releaseStaleClaimRows,
|
||||
settleFailure,
|
||||
settleSuccess,
|
||||
} from '../common/outbox/outbox-claims';
|
||||
import { DRAIN_MAX_ROUNDS } from '../domain-events/domain-event-dispatcher';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { postWebhook } from './webhook-delivery';
|
||||
import { openWebhookSecret } from './webhook-secret';
|
||||
|
||||
export const WEBHOOK_DELIVERY_BATCH_SIZE = 50;
|
||||
export const WEBHOOK_DELIVERY_CONCURRENCY = 5;
|
||||
|
||||
const SUBSCRIPTION_INACTIVE = 'subscription inactive';
|
||||
|
||||
export interface WebhookDeliveryFilter {
|
||||
/** Only deliveries of these subscriptions (e2e tests: no foreign rows). */
|
||||
subscriptionIds?: readonly string[];
|
||||
}
|
||||
|
||||
/** A claimed WebhookDelivery row, as RETURNING * yields it. */
|
||||
interface ClaimedWebhookDelivery {
|
||||
id: string;
|
||||
subscriptionId: string;
|
||||
event: string;
|
||||
body: string;
|
||||
attempts: number;
|
||||
claimedAt: Date;
|
||||
}
|
||||
|
||||
interface DeliveryTarget {
|
||||
url: string;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
/** What one attempt came to; `error` is set exactly when not delivered. */
|
||||
type AttemptOutcome =
|
||||
| { delivered: true; statusCode: number }
|
||||
| { delivered: false; statusCode: number | null; error: string };
|
||||
|
||||
/**
|
||||
* Sends the queued webhook deliveries (WebhooksService.enqueueDeliveries):
|
||||
* claims up to 50 due rows, posts at most 5 at once and settles each one -
|
||||
* DELIVERED on a 2xx answer, otherwise a failed attempt under the shared
|
||||
* retry rule (retry-policy.ts), FAILED after the 8th. The delivery id is
|
||||
* the row id, so a receiver sees the same X-ApiCore-Delivery and the same
|
||||
* body on every retry and can drop duplicates. Run by the worker's
|
||||
* webhook-deliveries loop.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WebhookDeliveryWorker {
|
||||
private readonly logger = new Logger(WebhookDeliveryWorker.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** One batch: claims up to 50 deliveries and returns how many it claimed. */
|
||||
async runOnce(filter?: WebhookDeliveryFilter): Promise<number> {
|
||||
const deliveries = await claimBatch<ClaimedWebhookDelivery>(
|
||||
this.prisma,
|
||||
'WebhookDelivery',
|
||||
{
|
||||
limit: WEBHOOK_DELIVERY_BATCH_SIZE,
|
||||
filter: claimFilter(filter),
|
||||
orderBy: 'createdAt',
|
||||
},
|
||||
);
|
||||
await forEachConcurrently(
|
||||
deliveries,
|
||||
WEBHOOK_DELIVERY_CONCURRENCY,
|
||||
(delivery) => this.deliverSafely(delivery),
|
||||
);
|
||||
return deliveries.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs batches until one claims nothing, at most `maxRounds`. Returns the
|
||||
* claimed total. Once `signal` aborts (worker shutdown), no further batch
|
||||
* is claimed; the batch already claimed is finished, since its deliveries
|
||||
* would otherwise wait for the stale-claim release.
|
||||
*/
|
||||
async drain(
|
||||
filter?: WebhookDeliveryFilter,
|
||||
maxRounds = DRAIN_MAX_ROUNDS,
|
||||
signal?: AbortSignal,
|
||||
): Promise<number> {
|
||||
let total = 0;
|
||||
for (let round = 0; round < maxRounds && !signal?.aborted; round++) {
|
||||
const claimed = await this.runOnce(filter);
|
||||
if (claimed === 0) {
|
||||
break;
|
||||
}
|
||||
total += claimed;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Puts deliveries whose worker died back in line (or to FAILED). */
|
||||
async releaseStaleClaims(): Promise<number> {
|
||||
const released = await releaseStaleClaimRows(
|
||||
this.prisma,
|
||||
'WebhookDelivery',
|
||||
);
|
||||
const failed = released
|
||||
.filter((claim) => claim.status === 'FAILED')
|
||||
.map((claim) => claim.id);
|
||||
if (failed.length > 0) {
|
||||
this.logger.error(
|
||||
`Webhook deliveries FAILED, an expired claim used up their attempts: ${failed.join(', ')}`,
|
||||
);
|
||||
}
|
||||
return released.length;
|
||||
}
|
||||
|
||||
/** One delivery must not stop the batch; an unsettled one goes stale. */
|
||||
private async deliverSafely(delivery: ClaimedWebhookDelivery): Promise<void> {
|
||||
try {
|
||||
await this.deliver(delivery);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Webhook delivery ${delivery.id} (${delivery.event}) not settled: ${loggableError(error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async deliver(delivery: ClaimedWebhookDelivery): Promise<void> {
|
||||
const subscription = await this.prisma.webhookSubscription.findUnique({
|
||||
where: { id: delivery.subscriptionId },
|
||||
select: { url: true, secret: true, active: true },
|
||||
});
|
||||
// A paused subscription sends nothing; the attempt counts, so its
|
||||
// deliveries go out if it is resumed within the retry window.
|
||||
const outcome: AttemptOutcome = subscription?.active
|
||||
? await this.attempt(delivery, subscription)
|
||||
: { delivered: false, statusCode: null, error: SUBSCRIPTION_INACTIVE };
|
||||
await this.settle(delivery, outcome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs and posts the stored body. Signed together with a timestamp and
|
||||
* the delivery id, so a receiver can reject a captured request replayed
|
||||
* later ("t.id.body", the same idea as Stripe's signatures). Only a 2xx
|
||||
* answer counts as delivered; the answer's body is never read.
|
||||
*/
|
||||
private async attempt(
|
||||
delivery: ClaimedWebhookDelivery,
|
||||
target: DeliveryTarget,
|
||||
): Promise<AttemptOutcome> {
|
||||
try {
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
const signature = crypto
|
||||
.createHmac('sha256', openWebhookSecret(target.secret))
|
||||
.update(`${timestamp}.${delivery.id}.${delivery.body}`)
|
||||
.digest('hex');
|
||||
// No redirects, connect-time address check, 5 s idle timeout and a
|
||||
// 10 s deadline that also closes the connection - a trickling
|
||||
// receiver can hold neither a slot nor a socket. See postWebhook().
|
||||
const { status } = await postWebhook(
|
||||
target.url,
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'ApiCore-Webhooks/1',
|
||||
'X-ApiCore-Event': delivery.event,
|
||||
'X-ApiCore-Delivery': delivery.id,
|
||||
'X-ApiCore-Timestamp': timestamp,
|
||||
'X-ApiCore-Signature': `sha256=${signature}`,
|
||||
},
|
||||
delivery.body,
|
||||
);
|
||||
return status >= 200 && status < 300
|
||||
? { delivered: true, statusCode: status }
|
||||
: {
|
||||
delivered: false,
|
||||
statusCode: status,
|
||||
error: `Receiver answered HTTP ${status}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
delivered: false,
|
||||
statusCode: null,
|
||||
error: loggableError(error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the status code and settles the claim in one transaction. Both
|
||||
* are a compare-and-set on id + claimedAt: a worker whose claim was
|
||||
* released in the meantime changes nothing.
|
||||
*/
|
||||
private async settle(
|
||||
delivery: ClaimedWebhookDelivery,
|
||||
outcome: AttemptOutcome,
|
||||
): Promise<void> {
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const { count } = await tx.webhookDelivery.updateMany({
|
||||
where: {
|
||||
id: delivery.id,
|
||||
status: 'PROCESSING',
|
||||
claimedAt: delivery.claimedAt,
|
||||
},
|
||||
data: { lastStatusCode: outcome.statusCode },
|
||||
});
|
||||
if (count === 0) {
|
||||
return 'lost' as const;
|
||||
}
|
||||
if (outcome.delivered) {
|
||||
await settleSuccess(
|
||||
tx,
|
||||
'WebhookDelivery',
|
||||
delivery.id,
|
||||
delivery.claimedAt,
|
||||
'DELIVERED',
|
||||
);
|
||||
return 'delivered' as const;
|
||||
}
|
||||
return settleFailure(
|
||||
tx,
|
||||
'WebhookDelivery',
|
||||
delivery.id,
|
||||
delivery.claimedAt,
|
||||
delivery.attempts,
|
||||
outcome.error,
|
||||
);
|
||||
});
|
||||
this.logOutcome(delivery, outcome, result);
|
||||
}
|
||||
|
||||
private logOutcome(
|
||||
delivery: ClaimedWebhookDelivery,
|
||||
outcome: AttemptOutcome,
|
||||
result: 'lost' | 'delivered' | 'retry' | 'failed',
|
||||
): void {
|
||||
const subject = `Webhook delivery ${delivery.id} (${delivery.event}) to subscription ${delivery.subscriptionId}`;
|
||||
const error = outcome.delivered ? '' : outcome.error;
|
||||
const attempt = delivery.attempts + 1;
|
||||
if (result === 'lost') {
|
||||
this.logger.warn(
|
||||
`${subject}: claim lost before settling, left to its current owner`,
|
||||
);
|
||||
} else if (result === 'failed') {
|
||||
this.logger.error(
|
||||
`Webhook delivery ${delivery.id} (${delivery.event}) FAILED after ${attempt} attempts to subscription ${delivery.subscriptionId}: ${error}`,
|
||||
);
|
||||
} else if (result === 'retry') {
|
||||
this.logger.warn(
|
||||
`${subject}: attempt ${attempt} failed, retrying: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function claimFilter(filter?: WebhookDeliveryFilter): Prisma.Sql | undefined {
|
||||
if (filter?.subscriptionIds === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return Prisma.sql`"subscriptionId" = ANY(${[...filter.subscriptionIds]})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `work` for every item, at most `limit` at a time, and resolves once
|
||||
* all are done. `work` must not reject - every delivery settles its own
|
||||
* errors (deliverSafely).
|
||||
*/
|
||||
async function forEachConcurrently<T>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
work: (item: T) => Promise<void>,
|
||||
): Promise<void> {
|
||||
let next = 0;
|
||||
const lane = async (): Promise<void> => {
|
||||
while (next < items.length) {
|
||||
const item = items[next];
|
||||
next += 1;
|
||||
await work(item);
|
||||
}
|
||||
};
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(limit, items.length) }, lane),
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
export const WEBHOOK_EVENTS = [
|
||||
'colony.created',
|
||||
'inspection.created',
|
||||
'varroa.measured',
|
||||
'treatment.created',
|
||||
'feeding.created',
|
||||
'queen.created',
|
||||
'queen.replaced',
|
||||
'harvest.created',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Logger } from '@nestjs/common';
|
||||
import { encryptMfaSecret } from '../auth/mfa/mfa-crypto.util';
|
||||
import { KEY_ID_PATTERN } from '../common/crypto/key-ring';
|
||||
import {
|
||||
isPlaintextWebhookSecret,
|
||||
openWebhookSecret,
|
||||
sealWebhookSecret,
|
||||
webhookSecretNeedsRekey,
|
||||
@@ -46,6 +47,14 @@ describe('webhook-secret', () => {
|
||||
expect(webhookSecretNeedsRekey(LEGACY_PLAIN)).toBe(true);
|
||||
});
|
||||
|
||||
it('tells plaintext secrets from sealed ones, so rekey can report them apart', () => {
|
||||
expect(isPlaintextWebhookSecret(LEGACY_PLAIN)).toBe(true);
|
||||
expect(isPlaintextWebhookSecret(LEGACY_SEALED)).toBe(false);
|
||||
expect(isPlaintextWebhookSecret(sealWebhookSecret('topsecret'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a secret sealed with WEBHOOK_ENCRYPTION_KEY_PREVIOUS and flags it for rekey', () => {
|
||||
const stored = sealWebhookSecret('topsecret');
|
||||
process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS =
|
||||
|
||||
@@ -34,3 +34,8 @@ export function openWebhookSecret(stored: string): string {
|
||||
export function webhookSecretNeedsRekey(stored: string): boolean {
|
||||
return !isSealedWithCurrentKey(loadKeyRing('webhook'), stored);
|
||||
}
|
||||
|
||||
/** True for a secret from before encryption, stored as it is (neither `k…:` nor `enc:v1:`). */
|
||||
export function isPlaintextWebhookSecret(stored: string): boolean {
|
||||
return !KEY_ID_PATTERN.test(stored) && !stored.startsWith(LEGACY_PREFIX);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import type {
|
||||
AnyDomainEventEnvelope,
|
||||
DomainEventEnvelope,
|
||||
DomainEventType,
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
|
||||
import {
|
||||
DOMAIN_EVENT_WEBHOOKS,
|
||||
WebhookEventHandlers,
|
||||
} from './webhook.handlers';
|
||||
import type { WebhooksService } from './webhooks.service';
|
||||
|
||||
type RecordModel =
|
||||
| 'inspection'
|
||||
| 'varroaMeasurement'
|
||||
| 'treatment'
|
||||
| 'feeding'
|
||||
| 'harvest'
|
||||
| 'honeyBatch'
|
||||
| 'task';
|
||||
|
||||
const RECORD_MODELS: readonly RecordModel[] = [
|
||||
'inspection',
|
||||
'varroaMeasurement',
|
||||
'treatment',
|
||||
'feeding',
|
||||
'harvest',
|
||||
'honeyBatch',
|
||||
'task',
|
||||
];
|
||||
|
||||
function envelope<T extends DomainEventType>(
|
||||
type: T,
|
||||
aggregateId: string,
|
||||
payload: DomainEventEnvelope<T>['payload'],
|
||||
): AnyDomainEventEnvelope {
|
||||
return {
|
||||
id: 'e1',
|
||||
type,
|
||||
aggregateType: 'Inspection',
|
||||
aggregateId,
|
||||
payload,
|
||||
actorUserId: 'u1',
|
||||
correlationId: 'corr-1',
|
||||
occurredAt: new Date('2026-10-13T08:00:00.000Z'),
|
||||
attempts: 0,
|
||||
} as AnyDomainEventEnvelope;
|
||||
}
|
||||
|
||||
describe('WebhookEventHandlers', () => {
|
||||
let tx: Record<RecordModel, { findUnique: jest.Mock }>;
|
||||
let webhooks: { enqueueDeliveries: jest.Mock };
|
||||
let handlers: WebhookEventHandlers;
|
||||
|
||||
const forward = (event: AnyDomainEventEnvelope) =>
|
||||
handlers.forward(event, tx as unknown as Prisma.TransactionClient);
|
||||
|
||||
beforeEach(() => {
|
||||
tx = Object.fromEntries(
|
||||
RECORD_MODELS.map((model) => [
|
||||
model,
|
||||
{ findUnique: jest.fn().mockResolvedValue(null) },
|
||||
]),
|
||||
) as Record<RecordModel, { findUnique: jest.Mock }>;
|
||||
webhooks = { enqueueDeliveries: jest.fn().mockResolvedValue(1) };
|
||||
handlers = new WebhookEventHandlers(webhooks as unknown as WebhooksService);
|
||||
});
|
||||
|
||||
it('maps exactly these domain events to webhook events', () => {
|
||||
expect(DOMAIN_EVENT_WEBHOOKS).toEqual({
|
||||
InspectionRecorded: 'inspection.created',
|
||||
VarroaMeasured: 'varroa.measured',
|
||||
TreatmentRecorded: 'treatment.created',
|
||||
FeedingRecorded: 'feeding.created',
|
||||
HarvestRecorded: 'harvest.created',
|
||||
BatchBottled: 'batch.updated',
|
||||
BottlingCorrected: 'batch.updated',
|
||||
StockChanged: 'stock.changed',
|
||||
TaskCreated: 'task.created',
|
||||
TaskCompleted: 'task.completed',
|
||||
});
|
||||
});
|
||||
|
||||
it('registers forward as "webhooks.forward" for every mapped type', () => {
|
||||
// The decorator stores its metadata on the method itself.
|
||||
const method = Reflect.get(WebhookEventHandlers.prototype, 'forward');
|
||||
expect(domainEventHandlerMetadata(method as object)).toEqual([
|
||||
{
|
||||
types: Object.keys(DOMAIN_EVENT_WEBHOOKS),
|
||||
handlerId: 'webhooks.forward',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('enqueues inspection.created with the inspection as data', async () => {
|
||||
const inspection = { id: 'i1', colonyId: 'c1', notes: 'calm' };
|
||||
tx.inspection.findUnique.mockResolvedValueOnce(inspection);
|
||||
|
||||
await forward(envelope('InspectionRecorded', 'i1', { colonyId: 'c1' }));
|
||||
|
||||
expect(tx.inspection.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'i1' },
|
||||
});
|
||||
// Through the delivery transaction: the rows commit with the delivery.
|
||||
expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'inspection.created',
|
||||
inspection,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['VarroaMeasured', 'varroaMeasurement', 'varroa.measured'],
|
||||
['TreatmentRecorded', 'treatment', 'treatment.created'],
|
||||
['FeedingRecorded', 'feeding', 'feeding.created'],
|
||||
] as const)(
|
||||
'enqueues %s as %s record under %s',
|
||||
async (type, model, webhookEvent) => {
|
||||
const record = { id: 'r1', colonyId: 'c1' };
|
||||
tx[model].findUnique.mockResolvedValueOnce(record);
|
||||
|
||||
await forward(envelope(type, 'r1', { colonyId: 'c1' }));
|
||||
|
||||
expect(tx[model].findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'r1' },
|
||||
});
|
||||
expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith(
|
||||
tx,
|
||||
webhookEvent,
|
||||
record,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['TaskCreated', 'task.created'],
|
||||
['TaskCompleted', 'task.completed'],
|
||||
] as const)('enqueues %s as %s with the task', async (type, webhookEvent) => {
|
||||
const task = { id: 't1', title: 'Varroa zählen' };
|
||||
tx.task.findUnique.mockResolvedValueOnce(task);
|
||||
|
||||
await forward(
|
||||
envelope(type, 't1', {
|
||||
relatedType: 'COLONY',
|
||||
relatedId: 'c1',
|
||||
dueDate: null,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(tx.task.findUnique).toHaveBeenCalledWith({ where: { id: 't1' } });
|
||||
expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith(
|
||||
tx,
|
||||
webhookEvent,
|
||||
task,
|
||||
);
|
||||
});
|
||||
|
||||
it('enqueues harvest.created with the harvest and its colonies', async () => {
|
||||
const harvest = {
|
||||
id: 'h1',
|
||||
colonies: [{ colonyId: 'c1', colony: { id: 'c1', code: 'V-1' } }],
|
||||
};
|
||||
tx.harvest.findUnique.mockResolvedValueOnce(harvest);
|
||||
|
||||
await forward(
|
||||
envelope('HarvestRecorded', 'h1', { apiaryId: 'a1', colonyIds: ['c1'] }),
|
||||
);
|
||||
|
||||
expect(tx.harvest.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'h1' },
|
||||
include: { colonies: { include: { colony: true } } },
|
||||
});
|
||||
expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'harvest.created',
|
||||
harvest,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['BatchBottled', { honeyBatchId: 'hb1' }],
|
||||
['BottlingCorrected', { honeyBatchId: 'hb1', removed: true }],
|
||||
] as const)(
|
||||
'enqueues batch.updated with the honey batch for %s',
|
||||
async (type, payload) => {
|
||||
const honeyBatch = { id: 'hb1', lotNumber: 'L-2026-001' };
|
||||
tx.honeyBatch.findUnique.mockResolvedValueOnce(honeyBatch);
|
||||
|
||||
// The aggregate is the bottling batch; the data is its honey batch,
|
||||
// which still exists after a bottling was removed.
|
||||
await forward(
|
||||
envelope(
|
||||
type,
|
||||
'bb1',
|
||||
payload as DomainEventEnvelope<'BottlingCorrected'>['payload'],
|
||||
),
|
||||
);
|
||||
|
||||
expect(tx.honeyBatch.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'hb1' },
|
||||
});
|
||||
expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'batch.updated',
|
||||
honeyBatch,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('enqueues stock.changed with the bottling batch and product ids, without raw material kinds', async () => {
|
||||
await forward(
|
||||
envelope('StockChanged', 'm1', {
|
||||
bottlingBatchIds: ['b1', 'b1', 'b2'],
|
||||
productIds: ['p1'],
|
||||
rawMaterialKinds: ['BEESWAX'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'stock.changed',
|
||||
{ bottlingBatchIds: ['b1', 'b2'], productIds: ['p1'] },
|
||||
);
|
||||
RECORD_MODELS.forEach((model) =>
|
||||
expect(tx[model].findUnique).not.toHaveBeenCalled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('enqueues nothing for StockChanged that only touches raw material', async () => {
|
||||
await forward(
|
||||
envelope('StockChanged', 'm1', {
|
||||
bottlingBatchIds: [],
|
||||
productIds: [],
|
||||
rawMaterialKinds: ['PROPOLIS'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips a record that no longer exists', async () => {
|
||||
tx.inspection.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
await forward(envelope('InspectionRecorded', 'gone', { colonyId: 'c1' }));
|
||||
|
||||
expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enqueues nothing for a type without a webhook', async () => {
|
||||
await forward(envelope('TaskDue', 't1', { dueOn: '2026-10-13' }));
|
||||
|
||||
expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled();
|
||||
RECORD_MODELS.forEach((model) =>
|
||||
expect(tx[model].findUnique).not.toHaveBeenCalled(),
|
||||
);
|
||||
});
|
||||
|
||||
// The dispatcher rolls the delivery transaction back and retries.
|
||||
it('fails when the deliveries cannot be enqueued', async () => {
|
||||
tx.task.findUnique.mockResolvedValueOnce({ id: 't1' });
|
||||
webhooks.enqueueDeliveries.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
forward(
|
||||
envelope('TaskCompleted', 't1', {
|
||||
relatedType: 'GENERAL',
|
||||
relatedId: null,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
AnyDomainEventEnvelope,
|
||||
DomainEventPayloads,
|
||||
DomainEventType,
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { WebhookEvent } from './webhook-events';
|
||||
import { WebhooksService } from './webhooks.service';
|
||||
|
||||
/** Which domain event goes out as which webhook event. */
|
||||
export const DOMAIN_EVENT_WEBHOOKS: Readonly<
|
||||
Partial<Record<DomainEventType, WebhookEvent>>
|
||||
> = Object.freeze({
|
||||
InspectionRecorded: 'inspection.created',
|
||||
VarroaMeasured: 'varroa.measured',
|
||||
TreatmentRecorded: 'treatment.created',
|
||||
FeedingRecorded: 'feeding.created',
|
||||
HarvestRecorded: 'harvest.created',
|
||||
BatchBottled: 'batch.updated',
|
||||
BottlingCorrected: 'batch.updated',
|
||||
StockChanged: 'stock.changed',
|
||||
TaskCreated: 'task.created',
|
||||
TaskCompleted: 'task.completed',
|
||||
});
|
||||
|
||||
/**
|
||||
* Turns domain events into queued webhook deliveries. Pure DB writer: it
|
||||
* reads the record through the delivery transaction and queues the rows in
|
||||
* it (WebhooksService.enqueueDeliveries); the worker's webhook-deliveries
|
||||
* loop sends them. Errors propagate, so a failed delivery is retried.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WebhookEventHandlers {
|
||||
constructor(private readonly webhooks: WebhooksService) {}
|
||||
|
||||
@OnDomainEvent(
|
||||
Object.keys(DOMAIN_EVENT_WEBHOOKS) as DomainEventType[],
|
||||
'webhooks.forward',
|
||||
)
|
||||
async forward(
|
||||
event: AnyDomainEventEnvelope,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
const webhookEvent = DOMAIN_EVENT_WEBHOOKS[event.type];
|
||||
if (webhookEvent === undefined) {
|
||||
return;
|
||||
}
|
||||
const data = await webhookData(event, tx);
|
||||
// The record is gone (deleted before the event was dispatched), or the
|
||||
// change is of no interest to receivers: nothing to send.
|
||||
if (data === null) {
|
||||
return;
|
||||
}
|
||||
await this.webhooks.enqueueDeliveries(tx, webhookEvent, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The webhook's `data`, as the legacy path sent it: the record the event is
|
||||
* about. Null when there is nothing to send.
|
||||
*/
|
||||
async function webhookData(
|
||||
event: AnyDomainEventEnvelope,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<unknown> {
|
||||
const byAggregate = { where: { id: event.aggregateId } };
|
||||
switch (event.type) {
|
||||
case 'InspectionRecorded':
|
||||
return tx.inspection.findUnique(byAggregate);
|
||||
case 'VarroaMeasured':
|
||||
return tx.varroaMeasurement.findUnique(byAggregate);
|
||||
case 'TreatmentRecorded':
|
||||
return tx.treatment.findUnique(byAggregate);
|
||||
case 'FeedingRecorded':
|
||||
return tx.feeding.findUnique(byAggregate);
|
||||
case 'HarvestRecorded':
|
||||
return tx.harvest.findUnique({
|
||||
...byAggregate,
|
||||
include: { colonies: { include: { colony: true } } },
|
||||
});
|
||||
// The aggregate is the bottling batch; receivers get its honey batch,
|
||||
// which outlives a removed bottling.
|
||||
case 'BatchBottled':
|
||||
case 'BottlingCorrected':
|
||||
return tx.honeyBatch.findUnique({
|
||||
where: { id: event.payload.honeyBatchId },
|
||||
});
|
||||
case 'StockChanged':
|
||||
return stockChange(event.payload);
|
||||
case 'TaskCreated':
|
||||
case 'TaskCompleted':
|
||||
return tx.task.findUnique(byAggregate);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receivers (the shop) fetch the numbers themselves. Raw material is not
|
||||
* sold there, so a change that only touches raw material is not sent.
|
||||
*/
|
||||
function stockChange(
|
||||
payload: DomainEventPayloads['StockChanged'],
|
||||
): { bottlingBatchIds: string[]; productIds: string[] } | null {
|
||||
if (
|
||||
payload.bottlingBatchIds.length === 0 &&
|
||||
payload.productIds.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
bottlingBatchIds: [...new Set(payload.bottlingBatchIds)],
|
||||
productIds: [...new Set(payload.productIds)],
|
||||
};
|
||||
}
|
||||
@@ -7,12 +7,17 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateWebhookDto } from './dto/create-webhook.dto';
|
||||
import {
|
||||
ListWebhookDeliveriesDto,
|
||||
WEBHOOK_DELIVERIES_DEFAULT_LIMIT,
|
||||
} from './dto/list-webhook-deliveries.dto';
|
||||
import { UpdateWebhookDto } from './dto/update-webhook.dto';
|
||||
import { WebhooksService } from './webhooks.service';
|
||||
|
||||
@@ -28,6 +33,19 @@ export class WebhooksController {
|
||||
return this.webhooksService.findAll();
|
||||
}
|
||||
|
||||
/** The delivery log of one subscription, newest first, without bodies. */
|
||||
@RequirePermission('webhooks:read')
|
||||
@Get(':id/deliveries')
|
||||
findDeliveries(
|
||||
@Param('id') id: string,
|
||||
@Query() query: ListWebhookDeliveriesDto,
|
||||
) {
|
||||
return this.webhooksService.findDeliveries(
|
||||
id,
|
||||
query.limit ?? WEBHOOK_DELIVERIES_DEFAULT_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission('webhooks:create')
|
||||
@Post()
|
||||
create(
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { WebhookDeliveryWorker } from './webhook-delivery.worker';
|
||||
import { WebhookEventHandlers } from './webhook.handlers';
|
||||
import { WebhooksController } from './webhooks.controller';
|
||||
import { WebhooksService } from './webhooks.service';
|
||||
|
||||
/**
|
||||
* Subscriptions, the delivery queue and the handler that fills it from
|
||||
* domain events. WebhookDeliveryWorker sends the queue; only the worker
|
||||
* runtime (and e2e tests) call it.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [WebhooksController],
|
||||
providers: [WebhooksService],
|
||||
exports: [WebhooksService],
|
||||
providers: [WebhooksService, WebhookDeliveryWorker, WebhookEventHandlers],
|
||||
exports: [WebhooksService, WebhookDeliveryWorker],
|
||||
})
|
||||
export class WebhooksModule {}
|
||||
@@ -1,15 +1,15 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { WebhooksService } from './webhooks.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { postWebhook } from './webhook-delivery';
|
||||
import { openWebhookSecret, sealWebhookSecret } from './webhook-secret';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { openWebhookSecret } from './webhook-secret';
|
||||
|
||||
jest.mock('./webhook-delivery', () => ({ postWebhook: jest.fn() }));
|
||||
|
||||
const postWebhookMock = postWebhook as jest.MockedFunction<typeof postWebhook>;
|
||||
const flushDeliveries = () => new Promise((resolve) => setImmediate(resolve));
|
||||
interface DeliveryRowInput {
|
||||
subscriptionId: string;
|
||||
event: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
describe('WebhooksService', () => {
|
||||
let service: WebhooksService;
|
||||
@@ -21,13 +21,16 @@ describe('WebhooksService', () => {
|
||||
update: jest.Mock;
|
||||
delete: jest.Mock;
|
||||
};
|
||||
webhookDelivery: {
|
||||
createMany: jest.Mock;
|
||||
findMany: jest.Mock;
|
||||
};
|
||||
$executeRaw: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret-0123456789';
|
||||
postWebhookMock.mockReset();
|
||||
postWebhookMock.mockResolvedValue({ status: 200 });
|
||||
prisma = {
|
||||
webhookSubscription: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
@@ -36,6 +39,15 @@ describe('WebhooksService', () => {
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
webhookDelivery: {
|
||||
createMany: jest
|
||||
.fn()
|
||||
.mockImplementation(({ data }: { data: DeliveryRowInput[] }) =>
|
||||
Promise.resolve({ count: data.length }),
|
||||
),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new WebhooksService(
|
||||
@@ -44,6 +56,10 @@ describe('WebhooksService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
const db = () => prisma as unknown as DbClient;
|
||||
|
||||
it('create generates a random secret and stores it', async () => {
|
||||
prisma.webhookSubscription.create.mockImplementation(
|
||||
({ data }: { data: Record<string, unknown> }) => ({
|
||||
@@ -85,108 +101,191 @@ describe('WebhooksService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatch only queries subscriptions matching the event and active flag', async () => {
|
||||
await service.dispatch('colony.created', { id: 'colony-1' });
|
||||
describe('enqueueDeliveries', () => {
|
||||
it('enqueueDeliveries writes one row per active subscription for the event and wakes the worker', async () => {
|
||||
prisma.webhookSubscription.findMany.mockResolvedValueOnce([
|
||||
{ id: 'wh-1' },
|
||||
{ id: 'wh-2' },
|
||||
]);
|
||||
const payload = { id: 'colony-1', code: 'V-1' };
|
||||
|
||||
expect(prisma.webhookSubscription.findMany).toHaveBeenCalledWith({
|
||||
where: { active: true, events: { has: 'colony.created' } },
|
||||
await expect(
|
||||
service.enqueueDeliveries(db(), 'colony.created', payload),
|
||||
).resolves.toBe(2);
|
||||
|
||||
expect(prisma.webhookSubscription.findMany).toHaveBeenCalledWith({
|
||||
where: { active: true, events: { has: 'colony.created' } },
|
||||
select: { id: true },
|
||||
});
|
||||
// The body is fixed here, so every retry sends the same bytes.
|
||||
const body = JSON.stringify({ event: 'colony.created', data: payload });
|
||||
expect(prisma.webhookDelivery.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{ subscriptionId: 'wh-1', event: 'colony.created', body },
|
||||
{ subscriptionId: 'wh-2', event: 'colony.created', body },
|
||||
],
|
||||
});
|
||||
expect(prisma.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
const [[strings, ...values]] = prisma.$executeRaw.mock.calls as [
|
||||
[TemplateStringsArray, ...unknown[]],
|
||||
];
|
||||
expect(strings.join('$')).toBe("SELECT pg_notify($, '')");
|
||||
expect(values).toEqual(['webhook_delivery']);
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatch signs timestamp, delivery id and payload with HMAC-SHA256', async () => {
|
||||
const subscription = {
|
||||
id: 'wh-1',
|
||||
url: 'https://example.test/hook',
|
||||
secret: sealWebhookSecret('topsecret'),
|
||||
active: true,
|
||||
};
|
||||
prisma.webhookSubscription.findMany.mockResolvedValueOnce([subscription]);
|
||||
it('writes nothing and wakes nobody without a matching subscription', async () => {
|
||||
await expect(
|
||||
service.enqueueDeliveries(db(), 'task.created', { id: 't1' }),
|
||||
).resolves.toBe(0);
|
||||
|
||||
await service.dispatch('colony.created', { id: 'colony-1' });
|
||||
await flushDeliveries();
|
||||
expect(prisma.webhookDelivery.createMany).not.toHaveBeenCalled();
|
||||
expect(prisma.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(postWebhookMock).toHaveBeenCalledTimes(1);
|
||||
const [url, headers, body] = postWebhookMock.mock.calls[0];
|
||||
expect(url).toBe('https://example.test/hook');
|
||||
expect(headers['X-ApiCore-Event']).toBe('colony.created');
|
||||
expect(headers['X-ApiCore-Delivery']).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(Number(headers['X-ApiCore-Timestamp'])).toBeGreaterThan(0);
|
||||
it('works on the client it is given, e.g. a handler transaction', async () => {
|
||||
const tx = {
|
||||
webhookSubscription: {
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'wh-1' }]),
|
||||
},
|
||||
webhookDelivery: {
|
||||
createMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||
};
|
||||
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', 'topsecret')
|
||||
.update(
|
||||
`${headers['X-ApiCore-Timestamp']}.${headers['X-ApiCore-Delivery']}.${body}`,
|
||||
)
|
||||
.digest('hex');
|
||||
expect(headers['X-ApiCore-Signature']).toBe(`sha256=${expectedSignature}`);
|
||||
});
|
||||
|
||||
it('dispatch returns before a slow receiver has answered', async () => {
|
||||
prisma.webhookSubscription.findMany.mockResolvedValueOnce([
|
||||
{ id: 'wh-1', url: 'https://example.test/hook', secret: 's' },
|
||||
]);
|
||||
postWebhookMock.mockReturnValueOnce(new Promise(() => undefined));
|
||||
|
||||
await expect(
|
||||
service.dispatch('colony.created', { id: 'colony-1' }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('dispatch does not throw when delivery fails', async () => {
|
||||
prisma.webhookSubscription.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'wh-1',
|
||||
url: 'https://example.test/hook',
|
||||
secret: 's',
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
postWebhookMock.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
await expect(
|
||||
service.dispatch('colony.created', { id: 'colony-1' }),
|
||||
).resolves.toBeUndefined();
|
||||
await flushDeliveries();
|
||||
});
|
||||
|
||||
// dispatch() runs after the event's record is committed: a failure must
|
||||
// not fail that request (the client would retry and store it twice).
|
||||
describe('dispatch after commit', () => {
|
||||
let errorSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
prisma.webhookSubscription.findMany.mockRejectedValue(
|
||||
new Error('db down'),
|
||||
await service.enqueueDeliveries(
|
||||
tx as unknown as DbClient,
|
||||
'stock.changed',
|
||||
{ bottlingBatchIds: ['b1'], productIds: [] },
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore();
|
||||
expect(tx.webhookDelivery.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.webhookDelivery.createMany).not.toHaveBeenCalled();
|
||||
expect(prisma.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatch (legacy path)', () => {
|
||||
it('enqueues on the root client after the commit and sends nothing itself', async () => {
|
||||
prisma.webhookSubscription.findMany.mockResolvedValueOnce([
|
||||
{ id: 'wh-1' },
|
||||
]);
|
||||
|
||||
it('never throws when the subscriptions cannot be loaded and logs the record', async () => {
|
||||
await expect(
|
||||
service.dispatch('colony.created', { id: 'colony-1' }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
const [message] = errorSpy.mock.calls[0] as [string];
|
||||
expect(message).toContain('colony.created colony-1');
|
||||
expect(message).toContain('db down');
|
||||
expect(prisma.webhookSubscription.findMany).toHaveBeenCalledWith({
|
||||
where: { active: true, events: { has: 'colony.created' } },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(prisma.webhookDelivery.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{
|
||||
subscriptionId: 'wh-1',
|
||||
event: 'colony.created',
|
||||
body: '{"event":"colony.created","data":{"id":"colony-1"}}',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(prisma.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('also logs a payload without its own id', async () => {
|
||||
await service.dispatch('stock.changed', {
|
||||
bottlingBatchIds: ['b-1'],
|
||||
productIds: [],
|
||||
// dispatch() runs after the event's record is committed: a failure must
|
||||
// not fail that request (the client would retry and store it twice).
|
||||
describe('when enqueuing fails', () => {
|
||||
let errorSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
const [message] = errorSpy.mock.calls[0] as [string];
|
||||
expect(message).toContain('stock.changed');
|
||||
expect(message).toContain('db down');
|
||||
it('dispatch never throws when enqueuing fails (legacy path)', async () => {
|
||||
prisma.webhookSubscription.findMany.mockResolvedValueOnce([
|
||||
{ id: 'wh-1' },
|
||||
]);
|
||||
prisma.webhookDelivery.createMany.mockRejectedValueOnce(
|
||||
new Error('db down'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.dispatch('colony.created', { id: 'colony-1' }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
const [message] = errorSpy.mock.calls[0] as [string];
|
||||
expect(message).toContain('colony.created colony-1');
|
||||
expect(message).toContain('db down');
|
||||
});
|
||||
|
||||
it('never throws when the subscriptions cannot be loaded and logs the record', async () => {
|
||||
prisma.webhookSubscription.findMany.mockRejectedValueOnce(
|
||||
new Error('db down'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.dispatch('colony.created', { id: 'colony-1' }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const [message] = errorSpy.mock.calls[0] as [string];
|
||||
expect(message).toContain('colony.created colony-1');
|
||||
expect(message).toContain('db down');
|
||||
});
|
||||
|
||||
it('also logs a payload without its own id', async () => {
|
||||
prisma.webhookSubscription.findMany.mockRejectedValueOnce(
|
||||
new Error('db down'),
|
||||
);
|
||||
|
||||
await service.dispatch('stock.changed', {
|
||||
bottlingBatchIds: ['b-1'],
|
||||
productIds: [],
|
||||
});
|
||||
|
||||
const [message] = errorSpy.mock.calls[0] as [string];
|
||||
expect(message).toContain('stock.changed');
|
||||
expect(message).toContain('db down');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDeliveries', () => {
|
||||
it('lists the newest deliveries of a subscription without their body', async () => {
|
||||
prisma.webhookSubscription.findUnique.mockResolvedValueOnce({
|
||||
id: 'wh-1',
|
||||
});
|
||||
const rows = [{ id: 'd2' }, { id: 'd1' }];
|
||||
prisma.webhookDelivery.findMany.mockResolvedValueOnce(rows);
|
||||
|
||||
await expect(service.findDeliveries('wh-1', 20)).resolves.toBe(rows);
|
||||
|
||||
expect(prisma.webhookDelivery.findMany).toHaveBeenCalledWith({
|
||||
where: { subscriptionId: 'wh-1' },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: 20,
|
||||
select: {
|
||||
id: true,
|
||||
event: true,
|
||||
status: true,
|
||||
attempts: true,
|
||||
lastStatusCode: true,
|
||||
lastError: true,
|
||||
createdAt: true,
|
||||
deliveredAt: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NotFoundException for an unknown subscription', async () => {
|
||||
prisma.webhookSubscription.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
service.findDeliveries('missing', 50),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(prisma.webhookDelivery.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,17 +2,33 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhookSubscription } from '../../generated/prisma';
|
||||
import {
|
||||
WebhookDeliveryStatus,
|
||||
WebhookSubscription,
|
||||
} from '../../generated/prisma';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { CreateWebhookDto } from './dto/create-webhook.dto';
|
||||
import { UpdateWebhookDto } from './dto/update-webhook.dto';
|
||||
import { WebhookEvent } from './webhook-events';
|
||||
import { assertPublicWebhookUrl } from './webhook-url-guard';
|
||||
import { postWebhook } from './webhook-delivery';
|
||||
import { openWebhookSecret, sealWebhookSecret } from './webhook-secret';
|
||||
import { runAfterCommit } from '../common/after-commit';
|
||||
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
|
||||
|
||||
const SECRET_BYTES = 32;
|
||||
|
||||
/** One entry of a subscription's delivery log - without the body. */
|
||||
export interface WebhookDeliveryView {
|
||||
id: string;
|
||||
event: string;
|
||||
status: WebhookDeliveryStatus;
|
||||
attempts: number;
|
||||
lastStatusCode: number | null;
|
||||
lastError: string | null;
|
||||
createdAt: Date;
|
||||
deliveredAt: Date | null;
|
||||
}
|
||||
|
||||
/** The record an event is about, for the log - some payloads have none. */
|
||||
function payloadId(payload: unknown): string {
|
||||
if (
|
||||
@@ -146,12 +162,46 @@ export class WebhooksService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts delivery and returns without waiting for the receivers: a slow
|
||||
* or hanging endpoint must never hold up the request that caused the
|
||||
* event. Failures are logged by deliver().
|
||||
* Queues one WebhookDelivery per active subscription of `event` and wakes
|
||||
* the worker, which sends them (webhook-delivery.worker.ts) - nothing is
|
||||
* sent here. The body is fixed now, so every retry sends the same bytes
|
||||
* under the same delivery id. On a transaction client the rows commit (and
|
||||
* the wake-up fires) with the caller's transaction, e.g. a domain event
|
||||
* handler's. Returns how many deliveries were queued.
|
||||
*/
|
||||
async enqueueDeliveries(
|
||||
db: DbClient,
|
||||
event: WebhookEvent,
|
||||
payload: unknown,
|
||||
): Promise<number> {
|
||||
const subscriptions = await db.webhookSubscription.findMany({
|
||||
where: { active: true, events: { has: event } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (subscriptions.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const body = JSON.stringify({ event, data: payload });
|
||||
const { count } = await db.webhookDelivery.createMany({
|
||||
data: subscriptions.map((subscription) => ({
|
||||
subscriptionId: subscription.id,
|
||||
event,
|
||||
body,
|
||||
})),
|
||||
});
|
||||
if (count > 0) {
|
||||
await notifyWorker(db, WAKE_CHANNELS.webhookDeliveries);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy path for producers that do not record a domain event yet: queues
|
||||
* the deliveries right after the record was committed. A slow or hanging
|
||||
* receiver can never hold up the request - the worker sends.
|
||||
*
|
||||
* Called after the event's record is committed, so it never throws - not
|
||||
* even when the subscriptions cannot be loaded (see runAfterCommit).
|
||||
* even when the deliveries cannot be queued (see runAfterCommit).
|
||||
*/
|
||||
async dispatch(event: WebhookEvent, payload: unknown): Promise<void> {
|
||||
await runAfterCommit(
|
||||
@@ -162,51 +212,38 @@ export class WebhooksService {
|
||||
entityId: payloadId(payload),
|
||||
},
|
||||
async () => {
|
||||
const subscriptions = await this.prisma.webhookSubscription.findMany({
|
||||
where: { active: true, events: { has: event } },
|
||||
});
|
||||
for (const subscription of subscriptions) {
|
||||
void this.deliver(subscription, event, payload);
|
||||
}
|
||||
await this.enqueueDeliveries(this.prisma, event, payload);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async deliver(
|
||||
subscription: WebhookSubscription,
|
||||
event: WebhookEvent,
|
||||
payload: unknown,
|
||||
): Promise<void> {
|
||||
const body = JSON.stringify({ event, data: payload });
|
||||
// Signed together with a timestamp and a unique delivery id, so a
|
||||
// receiver can reject a captured request replayed later
|
||||
// ("t.id.body", the same idea as Stripe's signatures).
|
||||
const deliveryId = crypto.randomUUID();
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
try {
|
||||
const signature = crypto
|
||||
.createHmac('sha256', openWebhookSecret(subscription.secret))
|
||||
.update(`${timestamp}.${deliveryId}.${body}`)
|
||||
.digest('hex');
|
||||
// No redirects, 5 s timeout, connect-time address check - see
|
||||
// postWebhook().
|
||||
await postWebhook(
|
||||
subscription.url,
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'ApiCore-Webhooks/1',
|
||||
'X-ApiCore-Event': event,
|
||||
'X-ApiCore-Delivery': deliveryId,
|
||||
'X-ApiCore-Timestamp': timestamp,
|
||||
'X-ApiCore-Signature': `sha256=${signature}`,
|
||||
},
|
||||
body,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(
|
||||
`Webhook delivery to ${subscription.url} failed for event ${event}: ${message}`,
|
||||
);
|
||||
/** The newest deliveries of a subscription, for its delivery log. */
|
||||
async findDeliveries(
|
||||
subscriptionId: string,
|
||||
limit: number,
|
||||
): Promise<WebhookDeliveryView[]> {
|
||||
const subscription = await this.prisma.webhookSubscription.findUnique({
|
||||
where: { id: subscriptionId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!subscription) {
|
||||
throw new NotFoundException('Webhook subscription not found');
|
||||
}
|
||||
// Never the body: it holds the full record the event was about.
|
||||
return this.prisma.webhookDelivery.findMany({
|
||||
where: { subscriptionId },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
event: true,
|
||||
status: true,
|
||||
attempts: true,
|
||||
lastStatusCode: true,
|
||||
lastError: true,
|
||||
createdAt: true,
|
||||
deliveredAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user