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,19 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WebhookSubscription" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"events" TEXT[],
|
||||||
|
"secret" TEXT NOT NULL,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdByUserId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "WebhookSubscription_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WebhookSubscription_active_idx" ON "WebhookSubscription"("active");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WebhookSubscription" ADD CONSTRAINT "WebhookSubscription_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -52,6 +52,7 @@ model User {
|
|||||||
bottlingBatches BottlingBatch[]
|
bottlingBatches BottlingBatch[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
queenEvaluations QueenEvaluation[]
|
queenEvaluations QueenEvaluation[]
|
||||||
|
webhookSubscriptions WebhookSubscription[]
|
||||||
|
|
||||||
@@index([role])
|
@@index([role])
|
||||||
}
|
}
|
||||||
@@ -754,3 +755,21 @@ model Notification {
|
|||||||
@@index([userId, read])
|
@@index([userId, read])
|
||||||
@@index([priority])
|
@@index([priority])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Webhooks (Abschnitt 27) – v1.x Modul 28
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
model WebhookSubscription {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
url String
|
||||||
|
events String[]
|
||||||
|
secret String
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdByUserId String?
|
||||||
|
createdByUser User? @relation(fields: [createdByUserId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([active])
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import { EquipmentModule } from './equipment/equipment.module';
|
|||||||
import { CalendarModule } from './calendar/calendar.module';
|
import { CalendarModule } from './calendar/calendar.module';
|
||||||
import { NotificationsModule } from './notifications/notifications.module';
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
import { StatisticsModule } from './statistics/statistics.module';
|
import { StatisticsModule } from './statistics/statistics.module';
|
||||||
|
import { WebhooksModule } from './webhooks/webhooks.module';
|
||||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from './auth/guards/roles.guard';
|
import { RolesGuard } from './auth/guards/roles.guard';
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
|
|||||||
CalendarModule,
|
CalendarModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
StatisticsModule,
|
StatisticsModule,
|
||||||
|
WebhooksModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|||||||
import { BottlingService } from './bottling.service';
|
import { BottlingService } from './bottling.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
|
||||||
describe('BottlingService', () => {
|
describe('BottlingService', () => {
|
||||||
let service: BottlingService;
|
let service: BottlingService;
|
||||||
@@ -39,9 +40,11 @@ describe('BottlingService', () => {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn() };
|
auditLog = { record: jest.fn() };
|
||||||
|
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||||
service = new BottlingService(
|
service = new BottlingService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
|
webhooks as unknown as WebhooksService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { BottlingBatch } from '../../generated/prisma';
|
import { BottlingBatch } from '../../generated/prisma';
|
||||||
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
|
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export class BottlingService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
|
private readonly webhooks: WebhooksService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filter: BottlingBatchFilter = {}) {
|
async findAll(filter: BottlingBatchFilter = {}) {
|
||||||
@@ -103,6 +105,8 @@ export class BottlingService {
|
|||||||
afterData: bottlingBatch,
|
afterData: bottlingBatch,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.findById(bottlingBatch.id);
|
const created = await this.findById(bottlingBatch.id);
|
||||||
|
await this.webhooks.dispatch('batch.updated', created.honeyBatch);
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ConflictException, NotFoundException } from '@nestjs/common';
|
|||||||
import { ColoniesService } from './colonies.service';
|
import { ColoniesService } from './colonies.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
|
||||||
describe('ColoniesService', () => {
|
describe('ColoniesService', () => {
|
||||||
let service: ColoniesService;
|
let service: ColoniesService;
|
||||||
@@ -53,9 +54,11 @@ describe('ColoniesService', () => {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||||
service = new ColoniesService(
|
service = new ColoniesService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
|
webhooks as unknown as WebhooksService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { Colony, ColonyStatus } from '../../generated/prisma';
|
import { Colony, ColonyStatus } from '../../generated/prisma';
|
||||||
import { CreateColonyDto } from './dto/create-colony.dto';
|
import { CreateColonyDto } from './dto/create-colony.dto';
|
||||||
import { UpdateColonyDto } from './dto/update-colony.dto';
|
import { UpdateColonyDto } from './dto/update-colony.dto';
|
||||||
@@ -22,6 +23,7 @@ export class ColoniesService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
|
private readonly webhooks: WebhooksService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
|
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
|
||||||
@@ -56,6 +58,7 @@ export class ColoniesService {
|
|||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
afterData: colony,
|
afterData: colony,
|
||||||
});
|
});
|
||||||
|
await this.webhooks.dispatch('colony.created', colony);
|
||||||
return colony;
|
return colony;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common';
|
|||||||
import { HarvestsService } from './harvests.service';
|
import { HarvestsService } from './harvests.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
|
||||||
describe('HarvestsService', () => {
|
describe('HarvestsService', () => {
|
||||||
let service: HarvestsService;
|
let service: HarvestsService;
|
||||||
@@ -29,9 +30,11 @@ describe('HarvestsService', () => {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn() };
|
auditLog = { record: jest.fn() };
|
||||||
|
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||||
service = new HarvestsService(
|
service = new HarvestsService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
|
webhooks as unknown as WebhooksService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { CreateHarvestDto } from './dto/create-harvest.dto';
|
import { CreateHarvestDto } from './dto/create-harvest.dto';
|
||||||
|
|
||||||
const harvestInclude = {
|
const harvestInclude = {
|
||||||
@@ -12,6 +13,7 @@ export class HarvestsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
|
private readonly webhooks: WebhooksService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
@@ -61,6 +63,8 @@ export class HarvestsService {
|
|||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
afterData: harvest,
|
afterData: harvest,
|
||||||
});
|
});
|
||||||
return this.findById(harvest.id);
|
const created = await this.findById(harvest.id);
|
||||||
|
await this.webhooks.dispatch('harvest.created', created);
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common';
|
|||||||
import { HoneyBatchesService } from './honey-batches.service';
|
import { HoneyBatchesService } from './honey-batches.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
|
||||||
describe('HoneyBatchesService', () => {
|
describe('HoneyBatchesService', () => {
|
||||||
let service: HoneyBatchesService;
|
let service: HoneyBatchesService;
|
||||||
@@ -50,9 +51,11 @@ describe('HoneyBatchesService', () => {
|
|||||||
honeyMeasurement: { create: jest.fn() },
|
honeyMeasurement: { create: jest.fn() },
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn() };
|
auditLog = { record: jest.fn() };
|
||||||
|
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||||
service = new HoneyBatchesService(
|
service = new HoneyBatchesService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
|
webhooks as unknown as WebhooksService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { HoneyBatchStatus } from '../../generated/prisma';
|
import { HoneyBatchStatus } from '../../generated/prisma';
|
||||||
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
|
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
|
||||||
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
|
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
|
||||||
@@ -27,6 +28,7 @@ export class HoneyBatchesService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
|
private readonly webhooks: WebhooksService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filter: HoneyBatchFilter = {}) {
|
async findAll(filter: HoneyBatchFilter = {}) {
|
||||||
@@ -59,7 +61,9 @@ export class HoneyBatchesService {
|
|||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
afterData: honeyBatch,
|
afterData: honeyBatch,
|
||||||
});
|
});
|
||||||
return this.findById(honeyBatch.id);
|
const created = await this.findById(honeyBatch.id);
|
||||||
|
await this.webhooks.dispatch('batch.created', created);
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
async addMeasurement(
|
async addMeasurement(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
import { NotFoundException } from '@nestjs/common';
|
||||||
import { InspectionsService } from './inspections.service';
|
import { InspectionsService } from './inspections.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
|
||||||
describe('InspectionsService', () => {
|
describe('InspectionsService', () => {
|
||||||
let service: InspectionsService;
|
let service: InspectionsService;
|
||||||
@@ -28,7 +29,11 @@ describe('InspectionsService', () => {
|
|||||||
},
|
},
|
||||||
task: { create: jest.fn() },
|
task: { create: jest.fn() },
|
||||||
};
|
};
|
||||||
service = new InspectionsService(prisma as unknown as PrismaService);
|
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
service = new InspectionsService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
webhooks as unknown as WebhooksService,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('findById throws NotFoundException when missing', async () => {
|
it('findById throws NotFoundException when missing', async () => {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { Inspection, Task } from '../../generated/prisma';
|
import { Inspection, Task } from '../../generated/prisma';
|
||||||
import { CreateInspectionDto } from './dto/create-inspection.dto';
|
import { CreateInspectionDto } from './dto/create-inspection.dto';
|
||||||
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
|
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InspectionsService {
|
export class InspectionsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly webhooks: WebhooksService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async findAll(): Promise<Inspection[]> {
|
async findAll(): Promise<Inspection[]> {
|
||||||
return this.prisma.inspection.findMany({ orderBy: { date: 'desc' } });
|
return this.prisma.inspection.findMany({ orderBy: { date: 'desc' } });
|
||||||
@@ -33,9 +37,11 @@ export class InspectionsService {
|
|||||||
dto: CreateInspectionDto,
|
dto: CreateInspectionDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<Inspection> {
|
): Promise<Inspection> {
|
||||||
return this.prisma.inspection.create({
|
const inspection = await this.prisma.inspection.create({
|
||||||
data: { ...dto, date: new Date(dto.date), userId: actingUserId },
|
data: { ...dto, date: new Date(dto.date), userId: actingUserId },
|
||||||
});
|
});
|
||||||
|
await this.webhooks.dispatch('inspection.created', inspection);
|
||||||
|
return inspection;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTaskFromInspection(
|
async createTaskFromInspection(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common';
|
|||||||
import { QueensService } from './queens.service';
|
import { QueensService } from './queens.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
|
||||||
describe('QueensService', () => {
|
describe('QueensService', () => {
|
||||||
let service: QueensService;
|
let service: QueensService;
|
||||||
@@ -46,9 +47,11 @@ describe('QueensService', () => {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||||
service = new QueensService(
|
service = new QueensService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
|
webhooks as unknown as WebhooksService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { Queen, QueenStatus } from '../../generated/prisma';
|
import { Queen, QueenStatus } from '../../generated/prisma';
|
||||||
import { CreateQueenDto } from './dto/create-queen.dto';
|
import { CreateQueenDto } from './dto/create-queen.dto';
|
||||||
import { UpdateQueenDto } from './dto/update-queen.dto';
|
import { UpdateQueenDto } from './dto/update-queen.dto';
|
||||||
@@ -16,6 +17,7 @@ export class QueensService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
|
private readonly webhooks: WebhooksService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filter: QueenFilter = {}): Promise<Queen[]> {
|
async findAll(filter: QueenFilter = {}): Promise<Queen[]> {
|
||||||
@@ -42,6 +44,7 @@ export class QueensService {
|
|||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
afterData: queen,
|
afterData: queen,
|
||||||
});
|
});
|
||||||
|
await this.webhooks.dispatch('queen.created', queen);
|
||||||
return queen;
|
return queen;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +120,9 @@ export class QueensService {
|
|||||||
afterData: { ...queen, assignedColonyId: colony.id },
|
afterData: { ...queen, assignedColonyId: colony.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.findById(id);
|
const updated = await this.findById(id);
|
||||||
|
await this.webhooks.dispatch('queen.replaced', updated);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHistory(id: string) {
|
async getHistory(id: string) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common';
|
|||||||
import { StorageService } from './storage.service';
|
import { StorageService } from './storage.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
|
||||||
describe('StorageService', () => {
|
describe('StorageService', () => {
|
||||||
let service: StorageService;
|
let service: StorageService;
|
||||||
@@ -45,9 +46,11 @@ describe('StorageService', () => {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn() };
|
auditLog = { record: jest.fn() };
|
||||||
|
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||||
service = new StorageService(
|
service = new StorageService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
|
webhooks as unknown as WebhooksService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { StockItem, StorageContainer } from '../../generated/prisma';
|
import { StockItem, StorageContainer } from '../../generated/prisma';
|
||||||
import { CreateStorageContainerDto } from './dto/create-storage-container.dto';
|
import { CreateStorageContainerDto } from './dto/create-storage-container.dto';
|
||||||
import { CreateStockItemDto } from './dto/create-stock-item.dto';
|
import { CreateStockItemDto } from './dto/create-stock-item.dto';
|
||||||
@@ -11,6 +12,7 @@ export class StorageService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
|
private readonly webhooks: WebhooksService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAllContainers(): Promise<StorageContainer[]> {
|
async findAllContainers(): Promise<StorageContainer[]> {
|
||||||
@@ -96,6 +98,8 @@ export class StorageService {
|
|||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
afterData: movement,
|
afterData: movement,
|
||||||
});
|
});
|
||||||
return this.findStockItemById(id);
|
const stockItem = await this.findStockItemById(id);
|
||||||
|
await this.webhooks.dispatch('product.stock_changed', stockItem);
|
||||||
|
return stockItem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common';
|
|||||||
import { TasksService } from './tasks.service';
|
import { TasksService } from './tasks.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
|
||||||
describe('TasksService', () => {
|
describe('TasksService', () => {
|
||||||
let service: TasksService;
|
let service: TasksService;
|
||||||
@@ -27,9 +28,11 @@ describe('TasksService', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn() };
|
auditLog = { record: jest.fn() };
|
||||||
|
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||||
service = new TasksService(
|
service = new TasksService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
|
webhooks as unknown as WebhooksService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { Task, TaskPriority, TaskStatus } from '../../generated/prisma';
|
import { Task, TaskPriority, TaskStatus } from '../../generated/prisma';
|
||||||
import { CreateTaskDto } from './dto/create-task.dto';
|
import { CreateTaskDto } from './dto/create-task.dto';
|
||||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||||
@@ -15,6 +16,7 @@ export class TasksService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
|
private readonly webhooks: WebhooksService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filter: TaskFilter): Promise<Task[]> {
|
async findAll(filter: TaskFilter): Promise<Task[]> {
|
||||||
@@ -47,6 +49,7 @@ export class TasksService {
|
|||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
afterData: task,
|
afterData: task,
|
||||||
});
|
});
|
||||||
|
await this.webhooks.dispatch('task.created', task);
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +91,7 @@ export class TasksService {
|
|||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: task,
|
afterData: task,
|
||||||
});
|
});
|
||||||
|
await this.webhooks.dispatch('task.completed', task);
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import * as http from 'node:http';
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { createTestApp } from './utils/create-test-app';
|
||||||
|
import { login } from './utils/api';
|
||||||
|
|
||||||
|
interface WebhookSubscriptionBody {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
events: string[];
|
||||||
|
secret: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReceivedRequest {
|
||||||
|
body: string;
|
||||||
|
headers: http.IncomingHttpHeaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Webhooks (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
const adminEmail = `webhooks-e2e-admin-${Date.now()}@example.com`;
|
||||||
|
const staffEmail = `webhooks-e2e-staff-${Date.now()}@example.com`;
|
||||||
|
const password = 'SuperSecret123!';
|
||||||
|
let adminId: string;
|
||||||
|
let staffId: string;
|
||||||
|
let adminAccessToken: string;
|
||||||
|
let staffAccessToken: string;
|
||||||
|
let subscriptionId: string;
|
||||||
|
|
||||||
|
let receiverServer: http.Server;
|
||||||
|
let receiverUrl: string;
|
||||||
|
let receivedRequests: ReceivedRequest[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await createTestApp();
|
||||||
|
prisma = app.get(PrismaService);
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
const admin = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: adminEmail,
|
||||||
|
passwordHash,
|
||||||
|
name: 'Webhooks E2E Admin',
|
||||||
|
role: 'ADMIN',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
adminId = admin.id;
|
||||||
|
const staff = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: staffEmail,
|
||||||
|
passwordHash,
|
||||||
|
name: 'Webhooks E2E Staff',
|
||||||
|
role: 'STAFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
staffId = staff.id;
|
||||||
|
adminAccessToken = (await login(app, adminEmail, password)).accessToken;
|
||||||
|
staffAccessToken = (await login(app, staffEmail, password)).accessToken;
|
||||||
|
|
||||||
|
receivedRequests = [];
|
||||||
|
receiverServer = http.createServer((req, res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
req.on('end', () => {
|
||||||
|
receivedRequests.push({
|
||||||
|
body: Buffer.concat(chunks).toString('utf-8'),
|
||||||
|
headers: req.headers,
|
||||||
|
});
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end('ok');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
receiverServer.listen(0, '127.0.0.1', resolve),
|
||||||
|
);
|
||||||
|
const address = receiverServer.address();
|
||||||
|
if (address === null || typeof address === 'string') {
|
||||||
|
throw new Error('Failed to determine receiver server address');
|
||||||
|
}
|
||||||
|
receiverUrl = `http://127.0.0.1:${address.port}/hook`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await new Promise<void>((resolve, reject) =>
|
||||||
|
receiverServer.close((error) => (error ? reject(error) : resolve())),
|
||||||
|
);
|
||||||
|
await prisma.webhookSubscription.deleteMany({
|
||||||
|
where: { id: subscriptionId },
|
||||||
|
});
|
||||||
|
await prisma.task.deleteMany({
|
||||||
|
where: { createdByUserId: { in: [adminId, staffId] } },
|
||||||
|
});
|
||||||
|
await prisma.refreshToken.deleteMany({
|
||||||
|
where: { userId: { in: [adminId, staffId] } },
|
||||||
|
});
|
||||||
|
await prisma.auditLog.deleteMany({
|
||||||
|
where: { userId: { in: [adminId, staffId] } },
|
||||||
|
});
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: [adminId, staffId] } } });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-admin roles from listing webhooks', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/v1/webhooks')
|
||||||
|
.set('Authorization', `Bearer ${staffAccessToken}`)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an admin create a webhook subscription with a generated secret', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/webhooks')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ url: receiverUrl, events: ['task.created'] })
|
||||||
|
.expect(201);
|
||||||
|
const body = response.body as WebhookSubscriptionBody;
|
||||||
|
subscriptionId = body.id;
|
||||||
|
expect(body.secret).toHaveLength(64);
|
||||||
|
expect(body.events).toEqual(['task.created']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes the secret from the list response', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/webhooks')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
const body = response.body as Partial<WebhookSubscriptionBody>[];
|
||||||
|
const listed = body.find((entry) => entry.id === subscriptionId);
|
||||||
|
expect(listed?.secret).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delivers an HMAC-signed POST to the subscribed URL when task.created fires', async () => {
|
||||||
|
const subscription = await prisma.webhookSubscription.findUniqueOrThrow({
|
||||||
|
where: { id: subscriptionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskResponse = await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/tasks')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ title: `Webhook-Test ${Date.now()}` })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(receivedRequests).toHaveLength(1);
|
||||||
|
const delivery = receivedRequests[0];
|
||||||
|
expect(delivery.headers['x-apicore-event']).toBe('task.created');
|
||||||
|
|
||||||
|
const expectedSignature = crypto
|
||||||
|
.createHmac('sha256', subscription.secret)
|
||||||
|
.update(delivery.body)
|
||||||
|
.digest('hex');
|
||||||
|
expect(delivery.headers['x-apicore-signature']).toBe(
|
||||||
|
`sha256=${expectedSignature}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = JSON.parse(delivery.body) as {
|
||||||
|
event: string;
|
||||||
|
data: { id: string };
|
||||||
|
};
|
||||||
|
expect(payload.event).toBe('task.created');
|
||||||
|
expect(payload.data.id).toBe((taskResponse.body as { id: string }).id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an admin delete a webhook subscription', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.delete(`/api/v1/webhooks/${subscriptionId}`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user