feat(backend): Webhooks mit HMAC-signierter Auslieferung (Modul 28)

WebhooksModule (WebhookSubscription: URL, Events, Secret, aktiv) mit
Event-Dispatcher, angehaengt an 10 Ereignisse in 8 bestehenden Services
(colony.created, inspection.created, queen.created, queen.replaced,
harvest.created, batch.created, batch.updated, product.stock_changed,
task.created, task.completed). Auslieferung ist best-effort und blockiert
die ausloesende Anfrage nicht bei Fehlern.
This commit is contained in:
Nick Szaunig committed 2026-08-23 06:56:56 +02:00
1 parent 8856d7d0f5
commit 59fdc816a0
26 files changed
+590 -8

No files matched your search

@@ -0,0 +1,120 @@
import * as crypto from 'node:crypto';
import { NotFoundException } from '@nestjs/common';
import { WebhooksService } from './webhooks.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
describe('WebhooksService', () => {
let service: WebhooksService;
let prisma: {
webhookSubscription: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
delete: jest.Mock;
};
};
let auditLog: { record: jest.Mock };
let fetchMock: jest.SpiedFunction<typeof fetch>;
beforeEach(() => {
prisma = {
webhookSubscription: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new WebhooksService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
fetchMock = jest
.spyOn(global, 'fetch')
.mockResolvedValue(new Response(null, { status: 200 }));
});
it('create generates a random secret and stores it', async () => {
prisma.webhookSubscription.create.mockImplementation(
({ data }: { data: Record<string, unknown> }) => ({
id: 'wh-1',
...data,
}),
);
const subscription = await service.create(
{ url: 'https://example.test/hook', events: ['colony.created'] },
'user-1',
);
expect(subscription.secret).toHaveLength(64);
const [[createArg]] = prisma.webhookSubscription.create.mock.calls as [
[{ data: { url: string; events: string[]; createdByUserId: string } }],
];
expect(createArg.data.url).toBe('https://example.test/hook');
expect(createArg.data.events).toEqual(['colony.created']);
expect(createArg.data.createdByUserId).toBe('user-1');
});
it('delete throws NotFoundException when the subscription is missing', async () => {
prisma.webhookSubscription.findUnique.mockResolvedValue(null);
await expect(service.delete('missing', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('dispatch only queries subscriptions matching the event and active flag', async () => {
await service.dispatch('colony.created', { id: 'colony-1' });
expect(prisma.webhookSubscription.findMany).toHaveBeenCalledWith({
where: { active: true, events: { has: 'colony.created' } },
});
});
it('dispatch signs the payload with HMAC-SHA256 and posts it with event headers', async () => {
const subscription = {
id: 'wh-1',
url: 'https://example.test/hook',
secret: 'topsecret',
active: true,
};
prisma.webhookSubscription.findMany.mockResolvedValueOnce([subscription]);
await service.dispatch('colony.created', { id: 'colony-1' });
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, options] = fetchMock.mock.calls[0] as [
string,
{ method: string; headers: Record<string, string>; body: string },
];
expect(url).toBe('https://example.test/hook');
expect(options.method).toBe('POST');
expect(options.headers['X-ApiCore-Event']).toBe('colony.created');
const expectedSignature = crypto
.createHmac('sha256', subscription.secret)
.update(options.body)
.digest('hex');
expect(options.headers['X-ApiCore-Signature']).toBe(
`sha256=${expectedSignature}`,
);
});
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,
},
]);
fetchMock.mockRejectedValueOnce(new Error('network down'));
await expect(
service.dispatch('colony.created', { id: 'colony-1' }),
).resolves.toBeUndefined();
});
});