diff --git a/kits/storage-resize-images/src/event-context.ts b/kits/storage-resize-images/src/event-context.ts new file mode 100644 index 000000000..35c121f4d --- /dev/null +++ b/kits/storage-resize-images/src/event-context.ts @@ -0,0 +1,71 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { StorageEvent } from "firebase-functions/v2/storage"; + +/** + * The 1st gen `EventContext` shape that the extension published inside its + * `onCompletion` payload. + */ +export interface EventContext { + eventId: string; + timestamp: string; + eventType: string; + resource: { + service: string; + name: string; + type?: string; + }; + params: Record; +} + +/** + * The extension's only trigger was `onFinalize`, so subscribers matching on + * `context.eventType` keep matching this value. + */ +const STORAGE_FINALIZE_EVENT_TYPE = "google.storage.object.finalize"; + +const STORAGE_SERVICE = "storage.googleapis.com"; + +/** + * The extension's timestamps had millisecond precision with trailing zeros + * trimmed (`...36.06Z`), while 2nd gen event times carry microseconds. + */ +function toLegacyTimestamp(time: string | undefined): string { + return new Date(time ?? Date.now()).toISOString().replace(/\.?0+Z$/, "Z"); +} + +/** + * Rebuilds the 1st gen `EventContext` from a 2nd gen `StorageEvent`, so the + * `onCompletion` payload keeps the shape the extension published and + * `resource.name` still names the uploaded object. + * + * `params` is always empty: the extension registered + * `functions.storage.object()`, whose trigger path has no wildcards. + */ +export function toEventContext(event: StorageEvent): EventContext { + return { + eventId: event.id, + timestamp: toLegacyTimestamp(event.time), + eventType: STORAGE_FINALIZE_EVENT_TYPE, + resource: { + service: STORAGE_SERVICE, + name: `projects/_/buckets/${event.data.bucket}/objects/${event.data.name}`, + type: event.data.kind, + }, + params: {}, + }; +} diff --git a/kits/storage-resize-images/src/handlers.ts b/kits/storage-resize-images/src/handlers.ts index 800ce369e..fe13e8d49 100644 --- a/kits/storage-resize-images/src/handlers.ts +++ b/kits/storage-resize-images/src/handlers.ts @@ -20,6 +20,7 @@ import type { File } from "@google-cloud/storage"; import type * as admin from "firebase-admin"; import type { StorageEvent } from "firebase-functions/v2/storage"; import { checkImageContent } from "./content-filter"; +import { toEventContext } from "./event-context"; import * as events from "./events"; import { DELETE_IMAGE, type ResolvedResizeImagesConfig } from "./export-config"; import { @@ -48,14 +49,7 @@ export async function handleObjectFinalized( event.data as unknown as StorageObjectMetadata, ctx ); - await events.recordCompletionEvent({ - context: { - eventId: event.id, - eventType: event.type, - resource: event.source, - timestamp: event.time, - }, - }); + await events.recordCompletionEvent({ context: toEventContext(event) }); } export async function generateResizedImageHandler( diff --git a/kits/storage-resize-images/tests/event-context.test.ts b/kits/storage-resize-images/tests/event-context.test.ts new file mode 100644 index 000000000..fb8b1fcf4 --- /dev/null +++ b/kits/storage-resize-images/tests/event-context.test.ts @@ -0,0 +1,127 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, test } from "vitest"; +import type { StorageEvent } from "firebase-functions/v2/storage"; +import { toEventContext } from "../src/event-context"; + +const BUCKET = "demo-bucket"; +const OBJECT_NAME = "images/nested/photo.jpg"; + +const event = { + id: "event-1", + type: "google.cloud.storage.object.v1.finalized", + source: `//storage.googleapis.com/projects/_/buckets/${BUCKET}`, + subject: `objects/${OBJECT_NAME}`, + time: "2026-01-01T00:00:36.898303Z", + specversion: "1.0", + bucket: BUCKET, + data: { + kind: "storage#object", + bucket: BUCKET, + name: OBJECT_NAME, + contentType: "image/jpeg", + }, +} as unknown as StorageEvent; + +describe("toEventContext", () => { + test("rebuilds the 1st gen event context from a 2nd gen event", () => { + expect(toEventContext(event)).toEqual({ + eventId: "event-1", + timestamp: "2026-01-01T00:00:36.898Z", + eventType: "google.storage.object.finalize", + resource: { + service: "storage.googleapis.com", + name: `projects/_/buckets/${BUCKET}/objects/${OBJECT_NAME}`, + type: "storage#object", + }, + params: {}, + }); + }); + + test.each([ + ["2026-01-01T00:00:36.898303Z", "2026-01-01T00:00:36.898Z"], + ["2026-01-01T00:00:36.060999Z", "2026-01-01T00:00:36.06Z"], + ["2026-01-01T00:00:36.000400Z", "2026-01-01T00:00:36Z"], + ])( + "formats event time %s as the extension's timestamp %s", + (time, timestamp) => { + expect(toEventContext({ ...event, time }).timestamp).toBe(timestamp); + } + ); +}); + +/** + * One upload of `in/img-7.jpg` captured from both sides: the 2nd gen finalize + * event the kit receives, and the `context` storage-resize-images 0.3.6 + * published. The extension took `eventId` and `timestamp` from a separate + * delivery of the notification, so only their format can match. + */ +const CAPTURED_STORAGE_EVENT = { + id: "22480302742407839", + type: "google.cloud.storage.object.v1.finalized", + source: "//storage.googleapis.com/projects/_/buckets/izaak-testing-pairc-ext", + subject: "objects/in/img-7.jpg", + time: "2026-10-26T15:20:21.630831Z", + specversion: "1.0", + bucket: "izaak-testing-pairc-ext", + data: { + kind: "storage#object", + bucket: "izaak-testing-pairc-ext", + name: "in/img-7.jpg", + contentType: "image/jpeg", + }, +} as unknown as StorageEvent; + +const CAPTURED_EXTENSION_CONTEXT = { + eventId: "22480386067619997", + eventType: "google.storage.object.finalize", + resource: { + name: "projects/_/buckets/izaak-testing-pairc-ext/objects/in/img-7.jpg", + service: "storage.googleapis.com", + type: "storage#object", + }, + timestamp: "2026-10-26T15:20:21.676Z", + params: {}, +}; + +const LEGACY_TIMESTAMP = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d{0,2}[1-9])?Z$/; +const NUMERIC_EVENT_ID = /^\d+$/; + +describe("the context the extension actually published", () => { + test("matches the extension's eventType, resource and params exactly", () => { + const { eventType, resource, params } = toEventContext( + CAPTURED_STORAGE_EVENT + ); + + expect({ eventType, resource, params }).toEqual({ + eventType: CAPTURED_EXTENSION_CONTEXT.eventType, + resource: CAPTURED_EXTENSION_CONTEXT.resource, + params: CAPTURED_EXTENSION_CONTEXT.params, + }); + }); + + test("matches the format of the extension's eventId and timestamp", () => { + const { eventId, timestamp } = toEventContext(CAPTURED_STORAGE_EVENT); + + expect(eventId).toBe("22480302742407839"); + expect(timestamp).toBe("2026-10-26T15:20:21.63Z"); + expect(CAPTURED_EXTENSION_CONTEXT.eventId).toMatch(NUMERIC_EVENT_ID); + expect(eventId).toMatch(NUMERIC_EVENT_ID); + expect(CAPTURED_EXTENSION_CONTEXT.timestamp).toMatch(LEGACY_TIMESTAMP); + expect(timestamp).toMatch(LEGACY_TIMESTAMP); + }); +}); diff --git a/kits/storage-resize-images/tests/handlers.test.ts b/kits/storage-resize-images/tests/handlers.test.ts index b7060109c..044f94f09 100644 --- a/kits/storage-resize-images/tests/handlers.test.ts +++ b/kits/storage-resize-images/tests/handlers.test.ts @@ -506,19 +506,24 @@ describe("handleObjectFinalized", () => { type: "google.cloud.storage.object.v1.finalized", source: "//storage.googleapis.com/projects/_/buckets/demo-bucket", time: "2026-01-01T00:00:00.000Z", - data: mockObject, + data: { ...mockObject, kind: "storage#object" }, } as unknown as Parameters[0]; test("brackets the resize with start and completion events", async () => { await handleObjectFinalized(storageEvent, makeCtx()); - expect(events.recordStartEvent).toHaveBeenCalledWith(mockObject); + expect(events.recordStartEvent).toHaveBeenCalledWith(storageEvent.data); expect(events.recordCompletionEvent).toHaveBeenCalledWith({ context: { eventId: "event-1", - eventType: "google.cloud.storage.object.v1.finalized", - resource: "//storage.googleapis.com/projects/_/buckets/demo-bucket", - timestamp: "2026-01-01T00:00:00.000Z", + timestamp: "2026-01-01T00:00:00Z", + eventType: "google.storage.object.finalize", + resource: { + service: "storage.googleapis.com", + name: "projects/_/buckets/demo-bucket/objects/images/test.jpg", + type: "storage#object", + }, + params: {}, }, }); });