diff --git a/spec/v2/providers/identity.spec.ts b/spec/v2/providers/identity.spec.ts index 158a5ec13..8ca595372 100644 --- a/spec/v2/providers/identity.spec.ts +++ b/spec/v2/providers/identity.spec.ts @@ -632,6 +632,149 @@ describe("identity", () => { func(mockEvent); expect(called).to.be.true; }); + + describe("v1-compatible getters", () => { + it("should provide v1-compatible getters on the event object", () => { + let capturedEvent: any; + const func = identity.onUserCreated((e) => { + capturedEvent = e; + }); + + const mockEvent = { + specversion: "1.0" as const, + source: "//identitytoolkit.googleapis.com/projects/my-project", + id: "event-id", + type: "google.firebase.auth.user.v2.created", + time: "2023-01-01T00:00:00Z", + data: { + value: { + uid: "user-uid-123", + email: "test@example.com", + }, + } as any, + }; + + func(mockEvent); + + expect(capturedEvent.context).to.deep.equal({ + eventId: "event-id", + timestamp: "2023-01-01T00:00:00Z", + eventType: "providers/firebase.auth/eventTypes/user.create", + resource: { + service: "firebaseauth.googleapis.com", + name: "projects/my-project", + }, + params: {}, + }); + + expect(capturedEvent.user.uid).to.equal("user-uid-123"); + expect(capturedEvent.user.email).to.equal("test@example.com"); + }); + + it("should support v1 destructuring assignment ({ user, context })", () => { + let destructuredUser: any; + let destructuredContext: any; + const func = identity.onUserCreated(({ user, context }) => { + destructuredUser = user; + destructuredContext = context; + }); + + const mockEvent = { + specversion: "1.0" as const, + source: "//identitytoolkit.googleapis.com/projects/my-project", + id: "event-id", + type: "google.firebase.auth.user.v2.created", + time: "2023-01-01T00:00:00Z", + data: { + value: { + uid: "destructured-uid", + email: "destructured@example.com", + }, + } as any, + }; + + func(mockEvent); + + expect(destructuredUser.uid).to.equal("destructured-uid"); + expect(destructuredContext.eventId).to.equal("event-id"); + }); + + it("preserves backward compatibility for user tests passing POJOs without v1 getters", async () => { + const func = identity.onUserCreated((event) => { + return event.data.uid; + }); + + const vanillaV2Event: identity.AuthEvent = { + specversion: "1.0", + source: "//identitytoolkit.googleapis.com/projects/my-project", + id: "vanilla-id", + type: "google.firebase.auth.user.v2.created", + time: "2023-01-01T00:00:00Z", + data: { + uid: "vanilla-uid", + } as any, + }; + + const result = await func.run(vanillaV2Event); + expect(result).to.equal("vanilla-uid"); + }); + + it("supports calling .run() on destructured handlers with vanilla POJO mock events", async () => { + const func = identity.onUserCreated(({ user, context }) => { + return { uid: user.uid, eventId: context.eventId }; + }); + + const vanillaV2Event: identity.AuthEvent = { + specversion: "1.0", + source: "//identitytoolkit.googleapis.com/projects/my-project", + id: "run-event-id", + type: "google.firebase.auth.user.v2.created", + time: "2023-01-01T00:00:00Z", + data: { + uid: "run-destructured-uid", + } as any, + }; + + const result = await func.run(vanillaV2Event); + expect(result).to.deep.equal({ + uid: "run-destructured-uid", + eventId: "run-event-id", + }); + }); + + it("supports calling .run() with an object that already has user and context", async () => { + const func = identity.onUserCreated(({ user, context }) => { + return { uid: user.uid, eventId: context.eventId }; + }); + + const directCompatObject = { + user: { uid: "direct-uid" } as any, + context: { eventId: "direct-event-id" } as any, + }; + + const result = await func.run(directCompatObject as any); + expect(result).to.deep.equal({ + uid: "direct-uid", + eventId: "direct-event-id", + }); + }); + + it("handles calling .run() with null or undefined gracefully", async () => { + let received: any; + const func = identity.onUserCreated((event) => { + received = event; + return "handled"; + }); + + const resultNull = await func.run(null); + expect(resultNull).to.equal("handled"); + expect(received).to.be.null; + + const resultUndef = await func.run(undefined); + expect(resultUndef).to.equal("handled"); + expect(received).to.be.undefined; + }); + }); }); describe("onUserDeleted", () => { @@ -747,6 +890,97 @@ describe("identity", () => { func(mockEvent); expect(called).to.be.true; }); + + describe("v1-compatible getters", () => { + it("should provide v1-compatible getters on the event object", () => { + let capturedEvent: any; + const func = identity.onUserDeleted((e) => { + capturedEvent = e; + }); + + const mockEvent = { + specversion: "1.0" as const, + source: "//identitytoolkit.googleapis.com/projects/my-project", + id: "event-id-del", + type: "google.firebase.auth.user.v2.deleted", + time: "2023-01-01T00:00:00Z", + data: { + oldValue: { + uid: "user-uid-del", + email: "del@example.com", + }, + } as any, + }; + + func(mockEvent); + + expect(capturedEvent.context).to.deep.equal({ + eventId: "event-id-del", + timestamp: "2023-01-01T00:00:00Z", + eventType: "providers/firebase.auth/eventTypes/user.delete", + resource: { + service: "firebaseauth.googleapis.com", + name: "projects/my-project", + }, + params: {}, + }); + + expect(capturedEvent.user.uid).to.equal("user-uid-del"); + expect(capturedEvent.user.email).to.equal("del@example.com"); + }); + + it("should support v1 destructuring assignment ({ user, context })", () => { + let destructuredUser: any; + let destructuredContext: any; + const func = identity.onUserDeleted(({ user, context }) => { + destructuredUser = user; + destructuredContext = context; + }); + + const mockEvent = { + specversion: "1.0" as const, + source: "//identitytoolkit.googleapis.com/projects/my-project", + id: "event-id-del", + type: "google.firebase.auth.user.v2.deleted", + time: "2023-01-01T00:00:00Z", + data: { + oldValue: { + uid: "destructured-del-uid", + }, + } as any, + }; + + func(mockEvent); + + expect(destructuredUser.uid).to.equal("destructured-del-uid"); + expect(destructuredContext.eventType).to.equal( + "providers/firebase.auth/eventTypes/user.delete" + ); + }); + + it("supports calling .run() on destructured handlers with vanilla POJO mock events", async () => { + const func = identity.onUserDeleted(({ user, context }) => { + return { uid: user.uid, eventType: context.eventType }; + }); + + const vanillaV2Event: identity.AuthEvent = { + specversion: "1.0", + source: "//identitytoolkit.googleapis.com/projects/my-project", + id: "run-del-event-id", + type: "google.firebase.auth.user.v2.deleted", + time: "2023-01-01T00:00:00Z", + data: { + uid: "run-destructured-del-uid", + } as any, + }; + + const result = await func.run(vanillaV2Event); + expect(result).to.deep.equal({ + uid: "run-destructured-del-uid", + eventType: "providers/firebase.auth/eventTypes/user.delete", + }); + }); + }); }); describe("getOpts", () => { diff --git a/src/v2/providers/identity.ts b/src/v2/providers/identity.ts index f992b22de..b06be65c1 100644 --- a/src/v2/providers/identity.ts +++ b/src/v2/providers/identity.ts @@ -46,6 +46,7 @@ import * as options from "../options"; import { SupportedSecretParam } from "../../params/types"; import { withInit } from "../../common/onInit"; import { CloudEvent, CloudFunction } from "../core"; +import { addV1Compat, V1Compat } from "../compat"; import { UserRecord as AdminUserRecord } from "firebase-admin/auth"; import { userRecordConstructor } from "../../common/providers/identity"; @@ -415,10 +416,19 @@ export interface AuthOptions extends options.EventHandlerOptions { */ export const IS_NOT_TENANT = RESET_VALUE; +/** + * Event handler type for Authentication triggers that supports both standard `AuthEvent` + * and V1 compatibility destructuring (`{ user, context }`). + * @internal + */ +export type AuthEventHandler = ( + event: AuthEvent & V1Compat<"user", User> +) => any | Promise; + // Helper to handle overloaded function signature function getOptsAndHandler( - optsOrHandler: AuthOptions | ((event: AuthEvent) => any | Promise), - handler?: (event: AuthEvent) => any | Promise + optsOrHandler: AuthOptions | AuthEventHandler, + handler?: AuthEventHandler ) { if (typeof optsOrHandler === "function") { return { opts: {}, handler: optsOrHandler }; @@ -428,6 +438,10 @@ function getOptsAndHandler( // Matches both absolute paths (/projects/...) and relative paths (projects/...) const PROJECT_ID_REGEX = /(?:^|\/)projects\/([^\/]+)/; +const IDENTITY_TOOLKIT_SOURCE_PREFIX = "//identitytoolkit.googleapis.com/"; +const FIREBASE_AUTH_SERVICE = "firebaseauth.googleapis.com"; +const USER_CREATED_EVENT = "google.firebase.auth.user.v2.created"; +const USER_DELETED_EVENT = "google.firebase.auth.user.v2.deleted"; /** * Converts the v2 Eventarc (`AuthEventData`) protobuf wire protocol (`value`/`oldValue`, `createTime`, `photoUrl`) @@ -495,11 +509,36 @@ function getAuthEvent(raw: CloudEvent): AuthEvent { return event; } +/** @internal */ +export function getV1AuthContext(event: AuthEvent) { + let resourceName = event.source || ""; + if (resourceName.startsWith(IDENTITY_TOOLKIT_SOURCE_PREFIX)) { + resourceName = resourceName.substring(IDENTITY_TOOLKIT_SOURCE_PREFIX.length); + } else if (event.project) { + resourceName = `projects/${event.project}`; + } + return { + eventId: event.id, + timestamp: event.time, + eventType: + event.type === USER_CREATED_EVENT + ? "providers/firebase.auth/eventTypes/user.create" + : event.type === USER_DELETED_EVENT + ? "providers/firebase.auth/eventTypes/user.delete" + : event.type, + resource: { + service: FIREBASE_AUTH_SERVICE, + name: resourceName, + }, + params: {}, + }; +} + /** @hidden */ function makeAuthTrigger( eventType: string, - optsOrHandler: AuthOptions | ((event: AuthEvent) => any | Promise), - handler?: (event: AuthEvent) => any | Promise + optsOrHandler: AuthOptions | AuthEventHandler, + handler?: AuthEventHandler ): CloudFunction> { const { opts, handler: handlerFunc } = getOptsAndHandler(optsOrHandler, handler); @@ -511,10 +550,25 @@ function makeAuthTrigger( const func = ((raw: CloudEvent) => { const event = getAuthEvent(raw); - return wrappedHandler(event); + const compatEvent = addV1Compat(event, { + context: () => getV1AuthContext(event), + user: () => event.data, + }); + return wrappedHandler(compatEvent); }) as CloudFunction>; - func.run = handlerFunc; + func.run = ((event: AuthEvent) => { + if (!event) { + return handlerFunc(event as Parameters[0]); + } + const existingUser = (event as any).user; + const existingContext = (event as any).context; + const compatEvent = addV1Compat(event, { + context: () => existingContext ?? getV1AuthContext(event), + user: () => event.data ?? existingUser, + }); + return handlerFunc(compatEvent); + }) as any; const baseOptsEndpoint = options.optionsToEndpoint(options.getGlobalOptions()); const specificOptsEndpoint = options.optionsToEndpoint(opts); const endpoint: ManifestEndpoint = { @@ -546,8 +600,20 @@ function makeAuthTrigger( return func; } -const USER_CREATED_EVENT = "google.firebase.auth.user.v2.created"; - +/** + * Handles user creation events in Firebase Authentication. + * + * To filter for users not associated with a tenant, use the `IS_NOT_TENANT` constant in options. + * + * @beta + * @internal + * + * @param handler - Event handler which is run every time a new user is created. + * @returns A Cloud Function that you can export. + */ +export function onUserCreated( + handler: (event: AuthEvent & V1Compat<"user", User>) => any | Promise +): CloudFunction>; /** * Handles user creation events in Firebase Authentication. * @@ -562,6 +628,20 @@ const USER_CREATED_EVENT = "google.firebase.auth.user.v2.created"; export function onUserCreated( handler: (event: AuthEvent) => any | Promise ): CloudFunction>; +/** + * Handles user creation events in Firebase Authentication. + * + * @beta + * @internal + * + * @param opts - Object containing function options. + * @param handler - Event handler which is run every time a new user is created. + * @returns A Cloud Function that you can export. + */ +export function onUserCreated( + opts: AuthOptions, + handler: (event: AuthEvent & V1Compat<"user", User>) => any | Promise +): CloudFunction>; /** * Handles user creation events in Firebase Authentication. * @@ -577,14 +657,26 @@ export function onUserCreated( handler: (event: AuthEvent) => any | Promise ): CloudFunction>; export function onUserCreated( - optsOrHandler: AuthOptions | ((event: AuthEvent) => any | Promise), - handler?: (event: AuthEvent) => any | Promise + optsOrHandler: AuthOptions | AuthEventHandler, + handler?: AuthEventHandler ): CloudFunction> { return makeAuthTrigger(USER_CREATED_EVENT, optsOrHandler, handler); } -const USER_DELETED_EVENT = "google.firebase.auth.user.v2.deleted"; - +/** + * Handles user deletion events in Firebase Authentication. + * + * To filter for users not associated with a tenant, use the `IS_NOT_TENANT` constant in options. + * + * @beta + * @internal + * + * @param handler - Event handler that is run every time a user is deleted. + * @returns A Cloud Function that you can export. + */ +export function onUserDeleted( + handler: (event: AuthEvent & V1Compat<"user", User>) => any | Promise +): CloudFunction>; /** * Handles user deletion events in Firebase Authentication. * @@ -599,6 +691,20 @@ const USER_DELETED_EVENT = "google.firebase.auth.user.v2.deleted"; export function onUserDeleted( handler: (event: AuthEvent) => any | Promise ): CloudFunction>; +/** + * Handles user deletion events in Firebase Authentication. + * + * @beta + * @internal + * + * @param opts - Object containing function options. + * @param handler - Event handler that is run every time a user is deleted. + * @returns A Cloud Function that you can export. + */ +export function onUserDeleted( + opts: AuthOptions, + handler: (event: AuthEvent & V1Compat<"user", User>) => any | Promise +): CloudFunction>; /** * Handles user deletion events in Firebase Authentication. * @@ -614,8 +720,8 @@ export function onUserDeleted( handler: (event: AuthEvent) => any | Promise ): CloudFunction>; export function onUserDeleted( - optsOrHandler: AuthOptions | ((event: AuthEvent) => any | Promise), - handler?: (event: AuthEvent) => any | Promise + optsOrHandler: AuthOptions | AuthEventHandler, + handler?: AuthEventHandler ): CloudFunction> { return makeAuthTrigger(USER_DELETED_EVENT, optsOrHandler, handler); }