Skip to content

Commit bf144bf

Browse files
fix: persist webhook health state
1 parent 50b5b69 commit bf144bf

2 files changed

Lines changed: 89 additions & 0 deletions

File tree

backend/src/services/enterprise/webhook-system.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,7 @@ export class WebhookSystem {
849849
}
850850

851851
this.deliveries.set(delivery.deliveryId, delivery);
852+
await this.persistWebhookHealth(webhook);
852853
await this.persistDelivery(delivery);
853854
}
854855

@@ -1114,6 +1115,36 @@ export class WebhookSystem {
11141115
}
11151116
}
11161117

1118+
private async persistWebhookHealth(webhook: RegisteredWebhook): Promise<void> {
1119+
try {
1120+
await prisma.webhook.update({
1121+
where: { id: webhook.id },
1122+
data: {
1123+
failureCount: webhook.health.consecutiveFailures,
1124+
lastStatusCode: webhook.health.lastStatusCode,
1125+
...(webhook.health.lastSuccessAt
1126+
? { lastDeliveredAt: new Date(webhook.health.lastSuccessAt) }
1127+
: {}),
1128+
...(webhook.health.disabled ? { status: 'DISABLED' as const } : {}),
1129+
},
1130+
});
1131+
1132+
await this.persistWebhookConfig(webhook.id, {
1133+
description: webhook.description,
1134+
metadata: webhook.metadata,
1135+
batchDelivery: webhook.batchDelivery,
1136+
batchIntervalMs: webhook.batchIntervalMs,
1137+
headers: webhook.headers,
1138+
health: webhook.health,
1139+
});
1140+
} catch (error) {
1141+
logger.error('webhook_health_persist_failed', {
1142+
webhookId: webhook.id,
1143+
error: error instanceof Error ? error.message : 'Unknown error',
1144+
});
1145+
}
1146+
}
1147+
11171148
private async getWebhookForClient(
11181149
webhookId: string,
11191150
clientId: string,

backend/test/webhook-system-persistence.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,56 @@ describe('WebhookSystem persistence', () => {
561561
}
562562
});
563563

564+
it('persists webhook auto-disable state after repeated failures', async () => {
565+
process.env.NODE_ENV = 'production';
566+
process.env.WEBHOOK_SECRET_ENCRYPTION_KEY = Buffer.alloc(32, 9).toString('base64');
567+
mockWebhookFindMany.mockResolvedValue([
568+
{
569+
id: 'wh-auto-disable',
570+
organizationId: 'org-1',
571+
url: 'https://hooks.zeroid.example/private',
572+
secret: encryptWebhookSecretForTest('s'.repeat(64)),
573+
events: ['credential.issued'],
574+
status: 'ACTIVE',
575+
failureCount: 9,
576+
lastDeliveredAt: null,
577+
lastStatusCode: 500,
578+
createdAt: new Date('2026-04-21T00:00:00.000Z'),
579+
updatedAt: new Date('2026-04-21T00:00:00.000Z'),
580+
},
581+
]);
582+
mockWebhookDeliveryUpsert.mockResolvedValue({});
583+
const dnsSpy = jest.spyOn(dns, 'lookup').mockResolvedValue([
584+
{ address: '127.0.0.1', family: 4 },
585+
]);
586+
587+
try {
588+
const deliveryIds = await webhookSystem.emit('credential.issued', {
589+
credentialId: 'cred-1',
590+
}, 'org-1');
591+
592+
expect(deliveryIds).toHaveLength(1);
593+
expect(mockWebhookUpdate).toHaveBeenCalledWith(expect.objectContaining({
594+
where: { id: 'wh-auto-disable' },
595+
data: expect.objectContaining({
596+
failureCount: 10,
597+
lastStatusCode: 0,
598+
status: 'DISABLED',
599+
}),
600+
}));
601+
expect(JSON.parse(redisStore['enterprise:webhook-config:wh-auto-disable'] as string)).toMatchObject({
602+
health: expect.objectContaining({
603+
consecutiveFailures: 10,
604+
disabled: true,
605+
}),
606+
});
607+
} finally {
608+
dnsSpy.mockRestore();
609+
process.env.NODE_ENV = 'test';
610+
delete process.env.WEBHOOK_SECRET_ENCRYPTION_KEY;
611+
}
612+
});
613+
564614
it('pins vetted DNS address during production webhook delivery', async () => {
565615
process.env.NODE_ENV = 'production';
566616
process.env.WEBHOOK_SECRET_ENCRYPTION_KEY = Buffer.alloc(32, 9).toString('base64');
@@ -700,6 +750,14 @@ describe('WebhookSystem persistence', () => {
700750
success: true,
701751
}),
702752
}));
753+
expect(mockWebhookUpdate).toHaveBeenCalledWith(expect.objectContaining({
754+
where: { id: 'wh-deliver' },
755+
data: expect.objectContaining({
756+
failureCount: 0,
757+
lastDeliveredAt: expect.any(Date),
758+
lastStatusCode: 200,
759+
}),
760+
}));
703761
fetchSpy.mockRestore();
704762
});
705763

0 commit comments

Comments
 (0)