diff --git a/src/storage/events/objects/object-admin-delete-all-before.ts b/src/storage/events/objects/object-admin-delete-all-before.ts index c0b4ef852..fd205c6b3 100644 --- a/src/storage/events/objects/object-admin-delete-all-before.ts +++ b/src/storage/events/objects/object-admin-delete-all-before.ts @@ -5,6 +5,7 @@ import { Job, SendOptions, WorkOptions } from 'pg-boss' import { getConfig } from '../../../config' import { Storage } from '../../index' import { BaseEvent } from '../base-event' +import { ObjectRemoved } from '../lifecycle/object-removed' const DELETE_JOB_TIME_LIMIT_MS = 10_000 @@ -87,6 +88,19 @@ export class ObjectAdminDeleteAllBefore extends BaseEvent + ObjectRemoved.sendWebhook({ + tenant: job.data.tenant, + name: object.name, + bucketId, + reqId: job.data.reqId, + version: object.version, + metadata: object.metadata, + }) + ) + ) } }) } diff --git a/src/test/webhooks.test.ts b/src/test/webhooks.test.ts index 7795aff43..9123a7931 100644 --- a/src/test/webhooks.test.ts +++ b/src/test/webhooks.test.ts @@ -14,6 +14,7 @@ import { FastifyInstance } from 'fastify' import FormData from 'form-data' import fs from 'fs' import app from '../app' +import { ObjectAdminDeleteAllBefore } from '../storage/events/objects/object-admin-delete-all-before' import { mockQueue, useMockObject } from './common' describe('Webhooks', () => { @@ -385,10 +386,95 @@ describe('Webhooks', () => { }) ) }) + + it('will emit webhooks for each deleted object during empty bucket operation', async () => { + const emptyTestBucketName = 'bucket-empty-webhook-test' + const authorization = `Bearer ${await serviceKeyAsync}` + + // Create a dedicated bucket for this test + await appInstance.inject({ + method: 'POST', + url: `/bucket`, + headers: { + authorization, + }, + payload: { + name: emptyTestBucketName, + }, + }) + + const objects = await Promise.all([ + createObject(pg, emptyTestBucketName), + createObject(pg, emptyTestBucketName), + createObject(pg, emptyTestBucketName), + ]) + + const response = await appInstance.inject({ + method: 'POST', + url: `/bucket/${emptyTestBucketName}/empty`, + headers: { + authorization, + }, + }) + + expect(response.statusCode).toBe(200) + + // Pass call invoked by empty on to the job handler to trigger the webhooks + expect(sendSpy).toHaveBeenCalledTimes(1) + const deleteJobCall = sendSpy.mock.calls[0][0] + expect(deleteJobCall.name).toBe(ObjectAdminDeleteAllBefore.queueName) + await ObjectAdminDeleteAllBefore.handle(deleteJobCall) + + // Check ObjectRemoved:Delete webhooks were sent as expected + expect(sendSpy).toHaveBeenCalledTimes(1 + objects.length) // 1 for the delete job + 3 for webhooks + objects.forEach((obj) => { + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'webhooks', + options: expect.objectContaining({ + deadLetter: 'webhooks-dead-letter', + expireInSeconds: expect.any(Number), + }), + data: expect.objectContaining({ + $version: 'v1', + event: expect.objectContaining({ + $version: 'v1', + type: 'ObjectRemoved:Delete', + applyTime: expect.any(Number), + payload: expect.objectContaining({ + bucketId: emptyTestBucketName, + name: obj.name, + version: obj.version, + metadata: obj.metadata, + tenant: { + host: undefined, + ref: 'bjhaohmqunupljrqypxz', + }, + reqId: expect.any(String), + }), + }), + tenant: { + host: undefined, + ref: 'bjhaohmqunupljrqypxz', + }, + }), + }) + ) + }) + + // Clean up: delete the bucket + await appInstance.inject({ + method: 'DELETE', + url: `/bucket/${emptyTestBucketName}`, + headers: { + authorization, + }, + }) + }) }) async function createObject(pg: TenantConnection, bucketId: string) { - const objectName = Date.now() + const objectName = randomUUID() const tnx = await pg.transaction() const [data] = await tnx