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:
1 parent
8856d7d0f5
commit
59fdc816a0
26 files changed
+590
-8
No files matched your search
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMinSize, IsArray, IsIn, IsUrl } from 'class-validator';
|
||||
import { WEBHOOK_EVENTS, WebhookEvent } from '../webhook-events';
|
||||
|
||||
export class CreateWebhookDto {
|
||||
@ApiProperty()
|
||||
@IsUrl({ require_tld: false })
|
||||
url!: string;
|
||||
|
||||
@ApiProperty({ enum: WEBHOOK_EVENTS, isArray: true })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsIn(WEBHOOK_EVENTS, { each: true })
|
||||
events!: WebhookEvent[];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const WEBHOOK_EVENTS = [
|
||||
'colony.created',
|
||||
'inspection.created',
|
||||
'queen.created',
|
||||
'queen.replaced',
|
||||
'harvest.created',
|
||||
'batch.created',
|
||||
'batch.updated',
|
||||
'product.stock_changed',
|
||||
'task.created',
|
||||
'task.completed',
|
||||
] as const;
|
||||
|
||||
export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number];
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { Role } from '../../generated/prisma';
|
||||
import { Roles } from '../auth/decorators/roles.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 { WebhooksService } from './webhooks.service';
|
||||
|
||||
@ApiTags('webhooks')
|
||||
@ApiBearerAuth()
|
||||
@Roles(Role.ADMIN)
|
||||
@Controller('webhooks')
|
||||
export class WebhooksController {
|
||||
constructor(private readonly webhooksService: WebhooksService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.webhooksService.findAll();
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@Body() dto: CreateWebhookDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.webhooksService.create(dto, actor.id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
delete(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.webhooksService.delete(id, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { WebhooksController } from './webhooks.controller';
|
||||
import { WebhooksService } from './webhooks.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [WebhooksController],
|
||||
providers: [WebhooksService],
|
||||
exports: [WebhooksService],
|
||||
})
|
||||
export class WebhooksModule {}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
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 { CreateWebhookDto } from './dto/create-webhook.dto';
|
||||
import { WebhookEvent } from './webhook-events';
|
||||
|
||||
const SECRET_BYTES = 32;
|
||||
|
||||
@Injectable()
|
||||
export class WebhooksService {
|
||||
private readonly logger = new Logger(WebhooksService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<Omit<WebhookSubscription, 'secret'>[]> {
|
||||
return this.prisma.webhookSubscription.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
events: true,
|
||||
active: true,
|
||||
createdByUserId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateWebhookDto,
|
||||
actingUserId: string,
|
||||
): Promise<WebhookSubscription> {
|
||||
const secret = crypto.randomBytes(SECRET_BYTES).toString('hex');
|
||||
const subscription = await this.prisma.webhookSubscription.create({
|
||||
data: {
|
||||
url: dto.url,
|
||||
events: dto.events,
|
||||
secret,
|
||||
createdByUserId: actingUserId,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'WebhookSubscription',
|
||||
entityId: subscription.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: { ...subscription, secret: undefined },
|
||||
});
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async delete(id: string, actingUserId: string): Promise<void> {
|
||||
const subscription = await this.prisma.webhookSubscription.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!subscription) {
|
||||
throw new NotFoundException('Webhook subscription not found');
|
||||
}
|
||||
await this.prisma.webhookSubscription.delete({ where: { id } });
|
||||
await this.auditLog.record({
|
||||
entityType: 'WebhookSubscription',
|
||||
entityId: id,
|
||||
action: 'DELETE',
|
||||
userId: actingUserId,
|
||||
beforeData: { ...subscription, secret: undefined },
|
||||
});
|
||||
}
|
||||
|
||||
async dispatch(event: WebhookEvent, payload: unknown): Promise<void> {
|
||||
const subscriptions = await this.prisma.webhookSubscription.findMany({
|
||||
where: { active: true, events: { has: event } },
|
||||
});
|
||||
await Promise.all(
|
||||
subscriptions.map((subscription) =>
|
||||
this.deliver(subscription, event, payload),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async deliver(
|
||||
subscription: WebhookSubscription,
|
||||
event: WebhookEvent,
|
||||
payload: unknown,
|
||||
): Promise<void> {
|
||||
const body = JSON.stringify({ event, data: payload });
|
||||
const signature = crypto
|
||||
.createHmac('sha256', subscription.secret)
|
||||
.update(body)
|
||||
.digest('hex');
|
||||
try {
|
||||
await fetch(subscription.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-ApiCore-Event': event,
|
||||
'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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user