From bdc22035a9c6c7aaf88c640c51e8665bf28d1fe4 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Tue, 20 Oct 2026 14:58:14 +0100 Subject: [PATCH 1/3] fix(storage-resize-images): publish the 1st gen context on onCompletion The kit published a context built from the 2nd gen event, so resource.name was missing. It now rebuilds the context the extension published. --- .../src/event-context.ts | 63 ++++++++++++ kits/storage-resize-images/src/handlers.ts | 10 +- .../tests/event-context.test.ts | 98 +++++++++++++++++++ .../tests/handlers.test.ts | 13 ++- 4 files changed, 172 insertions(+), 12 deletions(-) create mode 100644 kits/storage-resize-images/src/event-context.ts create mode 100644 kits/storage-resize-images/tests/event-context.test.ts 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 0000000000..5d3027c6c8 --- /dev/null +++ b/kits/storage-resize-images/src/event-context.ts @@ -0,0 +1,63 @@ +/** + * 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"; + +/** + * 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: event.time ?? new Date().toISOString(), + 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 800ce369e0..fe13e8d49b 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 0000000000..20f569abdf --- /dev/null +++ b/kits/storage-resize-images/tests/event-context.test.ts @@ -0,0 +1,98 @@ +/** + * 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 { afterEach, describe, expect, test, vi } from "vitest"; +import * as storageV1 from "firebase-functions/v1/storage"; +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:00.000Z", + 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:00.000Z", + eventType: "google.storage.object.finalize", + resource: { + service: "storage.googleapis.com", + name: `projects/_/buckets/${BUCKET}/objects/${OBJECT_NAME}`, + type: "storage#object", + }, + params: {}, + }); + }); +}); + +/** + * The oracle: run the extension's own 1st gen registration through the real + * `firebase-functions` v1 code path and compare the `context` its handler + * received, which the extension published verbatim. + */ +describe("the 1st gen context the extension actually published", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test("matches the context rebuilt from the equivalent 2nd gen event", async () => { + vi.stubEnv("GCLOUD_PROJECT", "demo-project"); + vi.stubEnv( + "FIREBASE_CONFIG", + JSON.stringify({ projectId: "demo-project", storageBucket: BUCKET }) + ); + let published: unknown; + + // The extension registered `functions.storage.object().onFinalize(...)`. + const cloudFunction = storageV1 + .object() + .onFinalize(async (_object, context) => { + published = JSON.parse(JSON.stringify(context)); + }); + + // The legacy event body the functions framework builds for this + // CloudEvent (`marshalConvertableCloudEvent` in + // @google-cloud/functions-framework). + await (cloudFunction as any)(event.data, { + eventId: "event-1", + timestamp: "2026-01-01T00:00:00.000Z", + eventType: "google.storage.object.finalize", + resource: { + service: "storage.googleapis.com", + name: `projects/_/buckets/${BUCKET}/objects/${OBJECT_NAME}`, + type: "storage#object", + }, + }); + + expect(published).toEqual(toEventContext(event)); + }); +}); diff --git a/kits/storage-resize-images/tests/handlers.test.ts b/kits/storage-resize-images/tests/handlers.test.ts index b7060109ca..b9142aee8c 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", + eventType: "google.storage.object.finalize", + resource: { + service: "storage.googleapis.com", + name: "projects/_/buckets/demo-bucket/objects/images/test.jpg", + type: "storage#object", + }, + params: {}, }, }); }); From 0cff22154d5ec5e12f51f3856d5a37c74f258fb1 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Mon, 26 Oct 2026 14:27:09 +0000 Subject: [PATCH 2/3] fix(storage-resize-images): match the extension's onCompletion timestamp format --- kits/storage-resize-images/src/event-context.ts | 10 +++++++++- .../tests/event-context.test.ts | 17 ++++++++++++++--- .../tests/handlers.test.ts | 2 +- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/kits/storage-resize-images/src/event-context.ts b/kits/storage-resize-images/src/event-context.ts index 5d3027c6c8..35c121f4d8 100644 --- a/kits/storage-resize-images/src/event-context.ts +++ b/kits/storage-resize-images/src/event-context.ts @@ -40,6 +40,14 @@ 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 @@ -51,7 +59,7 @@ const STORAGE_SERVICE = "storage.googleapis.com"; export function toEventContext(event: StorageEvent): EventContext { return { eventId: event.id, - timestamp: event.time ?? new Date().toISOString(), + timestamp: toLegacyTimestamp(event.time), eventType: STORAGE_FINALIZE_EVENT_TYPE, resource: { service: STORAGE_SERVICE, diff --git a/kits/storage-resize-images/tests/event-context.test.ts b/kits/storage-resize-images/tests/event-context.test.ts index 20f569abdf..be2ca9db12 100644 --- a/kits/storage-resize-images/tests/event-context.test.ts +++ b/kits/storage-resize-images/tests/event-context.test.ts @@ -27,7 +27,7 @@ const event = { type: "google.cloud.storage.object.v1.finalized", source: `//storage.googleapis.com/projects/_/buckets/${BUCKET}`, subject: `objects/${OBJECT_NAME}`, - time: "2026-01-01T00:00:00.000Z", + time: "2026-01-01T00:00:36.898303Z", specversion: "1.0", bucket: BUCKET, data: { @@ -42,7 +42,7 @@ 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:00.000Z", + timestamp: "2026-01-01T00:00:36.898Z", eventType: "google.storage.object.finalize", resource: { service: "storage.googleapis.com", @@ -52,6 +52,17 @@ describe("toEventContext", () => { 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); + } + ); }); /** @@ -84,7 +95,7 @@ describe("the 1st gen context the extension actually published", () => { // @google-cloud/functions-framework). await (cloudFunction as any)(event.data, { eventId: "event-1", - timestamp: "2026-01-01T00:00:00.000Z", + timestamp: "2026-01-01T00:00:36.898Z", eventType: "google.storage.object.finalize", resource: { service: "storage.googleapis.com", diff --git a/kits/storage-resize-images/tests/handlers.test.ts b/kits/storage-resize-images/tests/handlers.test.ts index b9142aee8c..044f94f09e 100644 --- a/kits/storage-resize-images/tests/handlers.test.ts +++ b/kits/storage-resize-images/tests/handlers.test.ts @@ -516,7 +516,7 @@ describe("handleObjectFinalized", () => { expect(events.recordCompletionEvent).toHaveBeenCalledWith({ context: { eventId: "event-1", - timestamp: "2026-01-01T00:00:00.000Z", + timestamp: "2026-01-01T00:00:00Z", eventType: "google.storage.object.finalize", resource: { service: "storage.googleapis.com", From 1c7af8e501089708ac39f2d1757e850a9e9c8f36 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Mon, 26 Oct 2026 14:38:50 +0000 Subject: [PATCH 3/3] test(storage-resize-images): check onCompletion context against a captured event pair --- .../tests/event-context.test.ts | 88 +++++++++++-------- 1 file changed, 53 insertions(+), 35 deletions(-) diff --git a/kits/storage-resize-images/tests/event-context.test.ts b/kits/storage-resize-images/tests/event-context.test.ts index be2ca9db12..fb8b1fcf44 100644 --- a/kits/storage-resize-images/tests/event-context.test.ts +++ b/kits/storage-resize-images/tests/event-context.test.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { afterEach, describe, expect, test, vi } from "vitest"; -import * as storageV1 from "firebase-functions/v1/storage"; +import { describe, expect, test } from "vitest"; import type { StorageEvent } from "firebase-functions/v2/storage"; import { toEventContext } from "../src/event-context"; @@ -66,44 +65,63 @@ describe("toEventContext", () => { }); /** - * The oracle: run the extension's own 1st gen registration through the real - * `firebase-functions` v1 code path and compare the `context` its handler - * received, which the extension published verbatim. + * 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. */ -describe("the 1st gen context the extension actually published", () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); +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; - test("matches the context rebuilt from the equivalent 2nd gen event", async () => { - vi.stubEnv("GCLOUD_PROJECT", "demo-project"); - vi.stubEnv( - "FIREBASE_CONFIG", - JSON.stringify({ projectId: "demo-project", storageBucket: BUCKET }) - ); - let published: unknown; +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: {}, +}; - // The extension registered `functions.storage.object().onFinalize(...)`. - const cloudFunction = storageV1 - .object() - .onFinalize(async (_object, context) => { - published = JSON.parse(JSON.stringify(context)); - }); +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+$/; - // The legacy event body the functions framework builds for this - // CloudEvent (`marshalConvertableCloudEvent` in - // @google-cloud/functions-framework). - await (cloudFunction as any)(event.data, { - 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", - }, +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(published).toEqual(toEventContext(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); }); });