From d06ad4508ee58acdcbe46784278bd82187691602 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 16 Sep 2025 10:39:24 +0100 Subject: [PATCH] feat(core): Add module overrides system --- packages/core/src/auth.test.ts | 1 - packages/core/src/auth.ts | 46 ++--- packages/core/src/behaviors.ts | 10 +- packages/core/src/imp/auth.test.ts | 292 +++++++++++++++++++++++++++++ packages/core/src/imp/auth.ts | 55 ++++++ packages/core/src/imp/index.ts | 41 ++++ 6 files changed, 411 insertions(+), 34 deletions(-) create mode 100644 packages/core/src/imp/auth.test.ts create mode 100644 packages/core/src/imp/auth.ts create mode 100644 packages/core/src/imp/index.ts diff --git a/packages/core/src/auth.test.ts b/packages/core/src/auth.test.ts index 53649a10..4ea4ea03 100644 --- a/packages/core/src/auth.test.ts +++ b/packages/core/src/auth.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { signInWithEmailAndPassword, createUserWithEmailAndPassword, signInWithPhoneNumber, confirmPhoneNumber, sendPasswordResetEmail, sendSignInLinkToEmail, signInWithEmailLink, signInAnonymously, signInWithProvider, completeEmailLinkSignIn, } from "./auth"; -import type { FirebaseUIConfiguration } from "./config"; // Mock the external dependencies vi.mock("firebase/auth", () => ({ diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index a7e2dfe0..b8da7a86 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -15,26 +15,18 @@ */ import { - createUserWithEmailAndPassword as _createUserWithEmailAndPassword, - isSignInWithEmailLink as _isSignInWithEmailLink, - sendPasswordResetEmail as _sendPasswordResetEmail, - sendSignInLinkToEmail as _sendSignInLinkToEmail, - signInAnonymously as _signInAnonymously, - signInWithPhoneNumber as _signInWithPhoneNumber, - ActionCodeSettings, - AuthProvider, - ConfirmationResult, + type ActionCodeSettings, + type AuthProvider, + type ConfirmationResult, + type RecaptchaVerifier, + type UserCredential, EmailAuthProvider, - linkWithCredential, PhoneAuthProvider, - RecaptchaVerifier, - signInWithCredential, - signInWithRedirect, - UserCredential, } from "firebase/auth"; import { getBehavior, hasBehavior } from "./behaviors"; import { FirebaseUIConfiguration } from "./config"; import { handleFirebaseError } from "./errors"; +import { getAuthImp } from "./imp/auth"; async function handlePendingCredential(ui: FirebaseUIConfiguration, user: UserCredential): Promise { const pendingCredString = window.sessionStorage.getItem("pendingCred"); @@ -43,7 +35,7 @@ async function handlePendingCredential(ui: FirebaseUIConfiguration, user: UserCr try { const pendingCred = JSON.parse(pendingCredString); ui.setState("pending"); - const result = await linkWithCredential(user.user, pendingCred); + const result = await getAuthImp(ui).linkWithCredential(user.user, pendingCred); ui.setState("idle"); window.sessionStorage.removeItem("pendingCred"); return result; @@ -63,14 +55,14 @@ export async function signInWithEmailAndPassword( if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) { const result = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential); - + if (result) { return handlePendingCredential(ui, result); } } ui.setState("pending"); - const result = await signInWithCredential(ui.auth, credential); + const result = await getAuthImp(ui).signInWithCredential(ui.auth, credential); return handlePendingCredential(ui, result); } catch (error) { handleFirebaseError(ui, error); @@ -96,7 +88,7 @@ export async function createUserWithEmailAndPassword( } ui.setState("pending"); - const result = await _createUserWithEmailAndPassword(ui.auth, email, password); + const result = await getAuthImp(ui).createUserWithEmailAndPassword(ui.auth, email, password); return handlePendingCredential(ui, result); } catch (error) { handleFirebaseError(ui, error); @@ -112,7 +104,7 @@ export async function signInWithPhoneNumber( ): Promise { try { ui.setState("pending"); - return await _signInWithPhoneNumber(ui.auth, phoneNumber, recaptchaVerifier); + return await getAuthImp(ui).signInWithPhoneNumber(ui.auth, phoneNumber, recaptchaVerifier); } catch (error) { handleFirebaseError(ui, error); } finally { @@ -138,7 +130,7 @@ export async function confirmPhoneNumber( } ui.setState("pending"); - const result = await signInWithCredential(ui.auth, credential); + const result = await getAuthImp(ui).signInWithCredential(ui.auth, credential); return handlePendingCredential(ui, result); } catch (error) { handleFirebaseError(ui, error); @@ -150,7 +142,7 @@ export async function confirmPhoneNumber( export async function sendPasswordResetEmail(ui: FirebaseUIConfiguration, email: string): Promise { try { ui.setState("pending"); - await _sendPasswordResetEmail(ui.auth, email); + await getAuthImp(ui).sendPasswordResetEmail(ui.auth, email); } catch (error) { handleFirebaseError(ui, error); } finally { @@ -167,7 +159,7 @@ export async function sendSignInLinkToEmail(ui: FirebaseUIConfiguration, email: } satisfies ActionCodeSettings; ui.setState("pending"); - await _sendSignInLinkToEmail(ui.auth, email, actionCodeSettings); + await getAuthImp(ui).sendSignInLinkToEmail(ui.auth, email, actionCodeSettings); // TODO: Should this be a behavior ("storageStrategy")? window.localStorage.setItem("emailForSignIn", email); } catch (error) { @@ -193,7 +185,7 @@ export async function signInWithEmailLink( } ui.setState("pending"); - const result = await signInWithCredential(ui.auth, credential); + const result = await getAuthImp(ui).signInWithCredential(ui.auth, credential); return handlePendingCredential(ui, result); } catch (error) { handleFirebaseError(ui, error); @@ -205,7 +197,7 @@ export async function signInWithEmailLink( export async function signInAnonymously(ui: FirebaseUIConfiguration): Promise { try { ui.setState("pending"); - const result = await _signInAnonymously(ui.auth); + const result = await getAuthImp(ui).signInAnonymously(ui.auth); return handlePendingCredential(ui, result); } catch (error) { handleFirebaseError(ui, error); @@ -225,7 +217,7 @@ export async function signInWithProvider(ui: FirebaseUIConfiguration, provider: ui.setState("pending"); // TODO(ehesp): Handle popup or redirect based on behavior - await signInWithRedirect(ui.auth, provider); + await getAuthImp(ui).signInWithRedirect(ui.auth, provider); // We don't modify state here since the user is redirected. // If we support popups, we'd need to modify state here. } catch (error) { @@ -240,7 +232,7 @@ export async function completeEmailLinkSignIn( currentUrl: string ): Promise { try { - if (!_isSignInWithEmailLink(ui.auth, currentUrl)) { + if (!getAuthImp(ui).isSignInWithEmailLink(ui.auth, currentUrl)) { return null; } @@ -248,7 +240,7 @@ export async function completeEmailLinkSignIn( if (!email) return null; ui.setState("pending"); - const result = await signInWithEmailLink(ui, email, currentUrl); + const result = await getAuthImp(ui).signInWithEmailLink(ui.auth, email, currentUrl); ui.setState("idle"); // TODO(ehesp): Do we need this here? return handlePendingCredential(ui, result); } catch (error) { diff --git a/packages/core/src/behaviors.ts b/packages/core/src/behaviors.ts index 5b7d2c0e..cb0b4c35 100644 --- a/packages/core/src/behaviors.ts +++ b/packages/core/src/behaviors.ts @@ -17,13 +17,11 @@ import { AuthCredential, AuthProvider, - linkWithCredential, - linkWithRedirect, - signInAnonymously, User, UserCredential, } from "firebase/auth"; import { FirebaseUIConfiguration } from "./config"; +import { getAuthImp } from "./imp/auth"; export type BehaviorHandlers = { autoAnonymousLogin: (ui: FirebaseUIConfiguration) => Promise; @@ -71,7 +69,7 @@ export function autoAnonymousLogin(): Behavior<"autoAnonymousLogin"> { if (!auth.currentUser) { ui.setState("loading"); - await signInAnonymously(auth); + await getAuthImp(ui).signInAnonymously(auth); } ui.setState("idle"); @@ -93,7 +91,7 @@ export function autoUpgradeAnonymousUsers(): Behavior< } ui.setState("pending"); - const result = await linkWithCredential(currentUser, credential); + const result = await getAuthImp(ui).linkWithCredential(currentUser, credential); ui.setState("idle"); return result; }, @@ -105,7 +103,7 @@ export function autoUpgradeAnonymousUsers(): Behavior< } ui.setState("pending"); - await linkWithRedirect(currentUser, provider); + await getAuthImp(ui).linkWithRedirect(currentUser, provider); // We don't modify state here since the user is redirected. // If we support popups, we'd need to modify state here. }, diff --git a/packages/core/src/imp/auth.test.ts b/packages/core/src/imp/auth.test.ts new file mode 100644 index 00000000..f0166d1c --- /dev/null +++ b/packages/core/src/imp/auth.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { setAuthOverrideModule, getAuthImp, type AuthOverrides } from "./auth"; +import { createMockUI } from "~/tests/utils"; + +// Mock firebase/auth module +vi.mock("firebase/auth", () => ({ + signInWithEmailAndPassword: vi.fn(), + createUserWithEmailAndPassword: vi.fn(), + isSignInWithEmailLink: vi.fn(), +})); + +// Import the mocked functions after the mock +import { signInWithEmailAndPassword } from "firebase/auth"; + +describe("Auth Implementation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + // Clear any overrides that might have been set + vi.clearAllMocks(); + }); + + describe("setAuthOverrideModule", () => { + it("should store override module for a UI instance", () => { + const mockUI = createMockUI(); + const overrideModule = vi.fn().mockResolvedValue({ + signInWithEmailAndPassword: vi.fn(), + }); + + setAuthOverrideModule(mockUI, overrideModule); + + // The function should not throw and should store the module + expect(() => setAuthOverrideModule(mockUI, overrideModule)).not.toThrow(); + }); + + it("should allow different UI instances to have different override modules", () => { + const ui1 = createMockUI(); + const ui2 = createMockUI(); + + const override1 = vi.fn().mockResolvedValue({ + signInWithEmailAndPassword: vi.fn().mockResolvedValue("result1"), + }); + + const override2 = vi.fn().mockResolvedValue({ + signInWithEmailAndPassword: vi.fn().mockResolvedValue("result2"), + }); + + setAuthOverrideModule(ui1, override1); + setAuthOverrideModule(ui2, override2); + + // Both should be stored independently + expect(() => setAuthOverrideModule(ui1, override1)).not.toThrow(); + expect(() => setAuthOverrideModule(ui2, override2)).not.toThrow(); + }); + }); + + describe("getAuthImp", () => { + it("should return a proxy object", () => { + const mockUI = createMockUI(); + const authImp = getAuthImp(mockUI); + + expect(authImp).toBeDefined(); + expect(typeof authImp).toBe("object"); + }); + + it("should resolve to firebase/auth functions when no override is set", async () => { + const mockSignIn = vi.mocked(signInWithEmailAndPassword); + mockSignIn.mockResolvedValue({ providerId: "password" } as any); + + const mockUI = createMockUI(); + const authImp = getAuthImp(mockUI); + const result = await authImp.signInWithEmailAndPassword(mockUI.auth, "test@example.com", "password"); + + expect(mockSignIn).toHaveBeenCalledWith(mockUI.auth, "test@example.com", "password"); + expect(result.providerId).toBe("password"); + }); + + it("should use override functions when override module is set", async () => { + const mockOverrideFunction = vi.fn().mockResolvedValue({ providerId: "override" }); + const overrideModule = vi.fn().mockResolvedValue({ + signInWithEmailAndPassword: mockOverrideFunction, + }); + + const mockUI = createMockUI(); + setAuthOverrideModule(mockUI, overrideModule); + + const authImp = getAuthImp(mockUI); + const result = await authImp.signInWithEmailAndPassword(mockUI.auth, "test@example.com", "password"); + + expect(overrideModule).toHaveBeenCalled(); + expect(mockOverrideFunction).toHaveBeenCalledWith(mockUI.auth, "test@example.com", "password"); + expect(result.providerId).toBe("override"); + }); + + it("should fall back to firebase/auth when override function is not provided", async () => { + const mockSignIn = vi.mocked(signInWithEmailAndPassword); + mockSignIn.mockResolvedValue({ providerId: "fallback" } as any); + + // Set override module but don't include the specific function + const overrideModule = vi.fn().mockResolvedValue({ + createUserWithEmailAndPassword: vi.fn(), // Different function + }); + + const mockUI = createMockUI(); + setAuthOverrideModule(mockUI, overrideModule); + + const authImp = getAuthImp(mockUI); + const result = await authImp.signInWithEmailAndPassword(mockUI.auth, "test@example.com", "password"); + + expect(overrideModule).toHaveBeenCalled(); + expect(mockSignIn).toHaveBeenCalledWith(mockUI.auth, "test@example.com", "password"); + expect(result.providerId).toBe("fallback"); + }); + + it("should handle all auth override functions", async () => { + const mockSignIn = vi.fn().mockResolvedValue("signInResult"); + const mockCreateUser = vi.fn().mockResolvedValue("createUserResult"); + const mockIsSignInWithEmailLink = vi.fn().mockResolvedValue("isSignInResult"); + + const overrideModule = vi.fn().mockResolvedValue({ + signInWithEmailAndPassword: mockSignIn, + createUserWithEmailAndPassword: mockCreateUser, + isSignInWithEmailLink: mockIsSignInWithEmailLink, + }); + + const mockUI = createMockUI(); + setAuthOverrideModule(mockUI, overrideModule); + + const authImp = getAuthImp(mockUI); + + // Test all functions + await authImp.signInWithEmailAndPassword(mockUI.auth, "test@example.com", "password"); + await authImp.createUserWithEmailAndPassword(mockUI.auth, "test@example.com", "password"); + await authImp.isSignInWithEmailLink(mockUI.auth, "https://example.com"); + + expect(mockSignIn).toHaveBeenCalledWith(mockUI.auth, "test@example.com", "password"); + expect(mockCreateUser).toHaveBeenCalledWith(mockUI.auth, "test@example.com", "password"); + expect(mockIsSignInWithEmailLink).toHaveBeenCalledWith(mockUI.auth, "https://example.com"); + }); + + it("should throw error when firebase/auth function is not available", async () => { + const mockUI = createMockUI(); + + // Create a mock that doesn't have the function + const originalMock = vi.mocked(signInWithEmailAndPassword); + vi.mocked(signInWithEmailAndPassword).mockImplementation(() => { + throw new Error("Function not available"); + }); + + const authImp = getAuthImp(mockUI); + + await expect( + authImp.signInWithEmailAndPassword(mockUI.auth, "test@example.com", "password") + ).rejects.toThrow("Function not available"); + + // Restore the original mock + vi.mocked(signInWithEmailAndPassword).mockImplementation(originalMock); + }); + + it("should handle override module that returns undefined", async () => { + const mockSignIn = vi.mocked(signInWithEmailAndPassword); + mockSignIn.mockResolvedValue({ providerId: "fallback" } as any); + + const mockUI = createMockUI(); + const overrideModule = vi.fn().mockResolvedValue(undefined); + + setAuthOverrideModule(mockUI, overrideModule); + + const authImp = getAuthImp(mockUI); + const result = await authImp.signInWithEmailAndPassword(mockUI.auth, "test@example.com", "password"); + + expect(overrideModule).toHaveBeenCalled(); + expect(mockSignIn).toHaveBeenCalledWith(mockUI.auth, "test@example.com", "password"); + expect(result.providerId).toBe("fallback"); + }); + + it("should handle override module that throws an error", async () => { + const mockSignIn = vi.mocked(signInWithEmailAndPassword); + mockSignIn.mockResolvedValue({ providerId: "fallback" } as any); + + const mockUI = createMockUI(); + const overrideModule = vi.fn().mockRejectedValue(new Error("Override module error")); + + setAuthOverrideModule(mockUI, overrideModule); + + const authImp = getAuthImp(mockUI); + + await expect( + authImp.signInWithEmailAndPassword(mockUI.auth, "test@example.com", "password") + ).rejects.toThrow("Override module error"); + }); + + it("should handle override function that throws an error", async () => { + const mockOverrideFunction = vi.fn().mockRejectedValue(new Error("Override function error")); + const overrideModule = vi.fn().mockResolvedValue({ + signInWithEmailAndPassword: mockOverrideFunction, + }); + + const mockUI = createMockUI(); + setAuthOverrideModule(mockUI, overrideModule); + + const authImp = getAuthImp(mockUI); + + await expect( + authImp.signInWithEmailAndPassword(mockUI.auth, "test@example.com", "password") + ).rejects.toThrow("Override function error"); + }); + + it("should pass through all arguments to override functions", async () => { + const mockOverrideFunction = vi.fn().mockResolvedValue("result"); + const overrideModule = vi.fn().mockResolvedValue({ + signInWithEmailAndPassword: mockOverrideFunction, + }); + + const mockUI = createMockUI(); + setAuthOverrideModule(mockUI, overrideModule); + + const authImp = getAuthImp(mockUI); + await authImp.signInWithEmailAndPassword(mockUI.auth, "test@example.com", "password"); + + expect(mockOverrideFunction).toHaveBeenCalledWith(mockUI.auth, "test@example.com", "password"); + }); + + it("should pass through all arguments to firebase/auth functions", async () => { + const mockSignIn = vi.mocked(signInWithEmailAndPassword); + mockSignIn.mockResolvedValue({ providerId: "password" } as any); + + const mockUI = createMockUI(); + const authImp = getAuthImp(mockUI); + await authImp.signInWithEmailAndPassword(mockUI.auth, "test@example.com", "password"); + + expect(mockSignIn).toHaveBeenCalledWith(mockUI.auth, "test@example.com", "password"); + }); + + it("should maintain separate override modules for different UI instances", async () => { + const ui1 = createMockUI(); + const ui2 = createMockUI(); + + const mockOverride1 = vi.fn().mockResolvedValue("result1"); + const mockOverride2 = vi.fn().mockResolvedValue("result2"); + + const overrideModule1 = vi.fn().mockResolvedValue({ + signInWithEmailAndPassword: mockOverride1, + }); + + const overrideModule2 = vi.fn().mockResolvedValue({ + signInWithEmailAndPassword: mockOverride2, + }); + + setAuthOverrideModule(ui1, overrideModule1); + setAuthOverrideModule(ui2, overrideModule2); + + const authImp1 = getAuthImp(ui1); + const authImp2 = getAuthImp(ui2); + + await authImp1.signInWithEmailAndPassword(ui1.auth, "test@example.com", "password"); + await authImp2.signInWithEmailAndPassword(ui2.auth, "test@example.com", "password"); + + expect(mockOverride1).toHaveBeenCalledWith(ui1.auth, "test@example.com", "password"); + expect(mockOverride2).toHaveBeenCalledWith(ui2.auth, "test@example.com", "password"); + expect(mockOverride1).toHaveBeenCalledTimes(1); + expect(mockOverride2).toHaveBeenCalledTimes(1); + }); + }); + + describe("Type Safety", () => { + it("should maintain correct types for AuthOverrides", () => { + const overrides: Partial = { + signInWithEmailAndPassword: vi.fn(), + createUserWithEmailAndPassword: vi.fn(), + isSignInWithEmailLink: vi.fn(), + }; + + expect(overrides).toBeDefined(); + expect(typeof overrides.signInWithEmailAndPassword).toBe("function"); + expect(typeof overrides.createUserWithEmailAndPassword).toBe("function"); + expect(typeof overrides.isSignInWithEmailLink).toBe("function"); + }); + + it("should maintain correct types for AuthImp", () => { + const mockUI = createMockUI(); + const authImp = getAuthImp(mockUI); + + expect(authImp).toBeDefined(); + expect(typeof authImp.signInWithEmailAndPassword).toBe("function"); + expect(typeof authImp.createUserWithEmailAndPassword).toBe("function"); + expect(typeof authImp.isSignInWithEmailLink).toBe("function"); + }); + }); +}); diff --git a/packages/core/src/imp/auth.ts b/packages/core/src/imp/auth.ts new file mode 100644 index 00000000..331bc6f4 --- /dev/null +++ b/packages/core/src/imp/auth.ts @@ -0,0 +1,55 @@ +import type { signInWithEmailAndPassword, createUserWithEmailAndPassword, isSignInWithEmailLink, linkWithCredential, signInWithCredential, signInWithPhoneNumber, sendSignInLinkToEmail, sendPasswordResetEmail, signInAnonymously, signInWithRedirect, signInWithEmailLink, linkWithRedirect } from 'firebase/auth'; +import type { FirebaseUIConfiguration } from '~/config'; + +export type AuthOverrides = { + linkWithCredential: typeof linkWithCredential, + signInWithCredential: typeof signInWithCredential, + signInWithEmailAndPassword: typeof signInWithEmailAndPassword, + createUserWithEmailAndPassword: typeof createUserWithEmailAndPassword, + isSignInWithEmailLink: typeof isSignInWithEmailLink, + signInWithPhoneNumber: typeof signInWithPhoneNumber, + sendPasswordResetEmail: typeof sendPasswordResetEmail, + sendSignInLinkToEmail: typeof sendSignInLinkToEmail, + signInAnonymously: typeof signInAnonymously, + signInWithRedirect: typeof signInWithRedirect, + signInWithEmailLink: typeof signInWithEmailLink, + linkWithRedirect: typeof linkWithRedirect, +}; + +// Type for the implementation object that provides the same interface as the overrides +export type AuthImplementation = { + [K in keyof AuthOverrides]: (...args: Parameters) => Promise>>; +}; + +// Support both individual function overrides and module imports +type OverrideModule = () => Promise>; + +const MODULE_OVERRIDES = new WeakMap(); + +export function setAuthOverrideModule(ui: FirebaseUIConfiguration, moduleImportResolver: OverrideModule) { + MODULE_OVERRIDES.set(ui, moduleImportResolver); +} + +export function getAuthImp(ui: FirebaseUIConfiguration): AuthImplementation { + return new Proxy({} as AuthImplementation, { + get(_, property: keyof AuthOverrides) { + return async (...args: unknown[]) => { + const override = await MODULE_OVERRIDES.get(ui)?.(); + const fn = override?.[property]; + + if (fn) { + return (fn as any)(...args); + } + + // Fall back to default firebase/auth import + const exported = await import('firebase/auth').then(m => m[property]); + + if (!exported) { + throw new Error(`Invalid override for ${property}`); + } + + return (exported as any)(...args); + }; + }, + }); +} diff --git a/packages/core/src/imp/index.ts b/packages/core/src/imp/index.ts new file mode 100644 index 00000000..1e2154c7 --- /dev/null +++ b/packages/core/src/imp/index.ts @@ -0,0 +1,41 @@ +import type { signInWithEmailAndPassword, createUserWithEmailAndPassword, isSignInWithEmailLink } from 'firebase/auth'; +import type { FirebaseUI } from '~/config'; + +type Overrides = { + signInWithEmailAndPassword: typeof signInWithEmailAndPassword, + createUserWithEmailAndPassword: typeof createUserWithEmailAndPassword, + isSignInWithEmailLink: typeof isSignInWithEmailLink, +}; + +// Type for the implementation object that provides the same interface as the overrides +type Imp = { + [K in keyof Overrides]: Overrides[K]; +}; + +const OVERRIDES = new WeakMap>(); + +export function setOverrides(ui: FirebaseUI, overrides: Partial) { + OVERRIDES.set(ui, overrides); +} + +export function getImp(ui: FirebaseUI): Imp { + return new Proxy({} as Imp, { + get(_, property: keyof Overrides) { + return async (...args: any[]) => { + const override = OVERRIDES.get(ui)?.[property]; + + if (override) { + return (override as any)(...args); + } + + const exported = await import('firebase/auth').then(m => m[property]); + + if (!exported) { + throw new Error(`Invalid override for ${property}`); + } + + return (exported as any)(...args); + }; + }, + }); +}