diff --git a/packages/core/src/auth.test.ts b/packages/core/src/auth.test.ts index dd4f025d..408c8beb 100644 --- a/packages/core/src/auth.test.ts +++ b/packages/core/src/auth.test.ts @@ -1,5 +1,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { signInWithEmailAndPassword, createUserWithEmailAndPassword, signInWithPhoneNumber, confirmPhoneNumber, sendPasswordResetEmail, sendSignInLinkToEmail, signInWithEmailLink, signInWithCredential, signInAnonymously, signInWithProvider, completeEmailLinkSignIn, } from "./auth"; +import { + signInWithEmailAndPassword, + createUserWithEmailAndPassword, + signInWithPhoneNumber, + confirmPhoneNumber, + sendPasswordResetEmail, + sendSignInLinkToEmail, + signInWithEmailLink, + signInWithCredential, + signInAnonymously, + signInWithProvider, +} from "./auth"; vi.mock("firebase/auth", () => ({ signInWithCredential: vi.fn(), @@ -30,7 +41,22 @@ vi.mock("./errors", () => ({ })); // Import the mocked functions -import { signInWithCredential as _signInWithCredential, EmailAuthProvider, PhoneAuthProvider, createUserWithEmailAndPassword as _createUserWithEmailAndPassword, signInWithPhoneNumber as _signInWithPhoneNumber, sendPasswordResetEmail as _sendPasswordResetEmail, sendSignInLinkToEmail as _sendSignInLinkToEmail, signInAnonymously as _signInAnonymously, signInWithRedirect, isSignInWithEmailLink as _isSignInWithEmailLink, UserCredential, Auth, ConfirmationResult, AuthProvider } from "firebase/auth"; +import { + signInWithCredential as _signInWithCredential, + EmailAuthProvider, + PhoneAuthProvider, + createUserWithEmailAndPassword as _createUserWithEmailAndPassword, + signInWithPhoneNumber as _signInWithPhoneNumber, + sendPasswordResetEmail as _sendPasswordResetEmail, + sendSignInLinkToEmail as _sendSignInLinkToEmail, + signInAnonymously as _signInAnonymously, + signInWithRedirect, + isSignInWithEmailLink as _isSignInWithEmailLink, + UserCredential, + Auth, + ConfirmationResult, + AuthProvider, +} from "firebase/auth"; import { hasBehavior, getBehavior } from "./behaviors"; import { handleFirebaseError } from "./errors"; import { FirebaseError } from "firebase/app"; @@ -68,7 +94,7 @@ describe("signInWithEmailAndPassword", () => { expect(result.providerId).toBe("password"); }); - it('should call the autoUpgradeAnonymousCredential behavior if enabled and return a value', async () => { + it("should call the autoUpgradeAnonymousCredential behavior if enabled and return a value", async () => { const mockUI = createMockUI(); const email = "test@example.com"; const password = "password123"; @@ -78,7 +104,6 @@ describe("signInWithEmailAndPassword", () => { const mockBehavior = vi.fn().mockResolvedValue({ providerId: "password" } as UserCredential); vi.mocked(getBehavior).mockReturnValue(mockBehavior); - const result = await signInWithEmailAndPassword(mockUI, email, password); expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); @@ -88,10 +113,10 @@ describe("signInWithEmailAndPassword", () => { expect(result.providerId).toBe("password"); // Only the `finally` block is called here. - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([['idle']]); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["idle"]]); }); - it('should call the autoUpgradeAnonymousCredential behavior if enabled and handle no result from the behavior', async () => { + it("should call the autoUpgradeAnonymousCredential behavior if enabled and handle no result from the behavior", async () => { const mockUI = createMockUI(); const email = "test@example.com"; const password = "password123"; @@ -101,7 +126,6 @@ describe("signInWithEmailAndPassword", () => { const mockBehavior = vi.fn().mockResolvedValue(undefined); vi.mocked(getBehavior).mockReturnValue(mockBehavior); - await signInWithEmailAndPassword(mockUI, email, password); expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); @@ -116,26 +140,25 @@ describe("signInWithEmailAndPassword", () => { expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); - it('should call handleFirebaseError if an error is thrown', async () => { + it("should call handleFirebaseError if an error is thrown", async () => { const mockUI = createMockUI(); const email = "test@example.com"; const password = "password123"; vi.mocked(hasBehavior).mockReturnValue(false); - const error = new FirebaseError('foo/bar', 'Foo bar'); + const error = new FirebaseError("foo/bar", "Foo bar"); vi.mocked(_signInWithCredential).mockRejectedValue(error); await signInWithEmailAndPassword(mockUI, email, password); expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"],["idle"]]); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); }); describe("createUserWithEmailAndPassword", () => { - beforeEach(() => { vi.clearAllMocks(); }); @@ -164,7 +187,7 @@ describe("createUserWithEmailAndPassword", () => { expect(result.providerId).toBe("password"); }); - it('should call the autoUpgradeAnonymousCredential behavior if enabled and return a value', async () => { + it("should call the autoUpgradeAnonymousCredential behavior if enabled and return a value", async () => { const mockUI = createMockUI(); const email = "test@example.com"; const password = "password123"; @@ -183,10 +206,10 @@ describe("createUserWithEmailAndPassword", () => { expect(result.providerId).toBe("password"); // Only the `finally` block is called here. - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([['idle']]); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["idle"]]); }); - it('should call the autoUpgradeAnonymousCredential behavior if enabled and handle no result from the behavior', async () => { + it("should call the autoUpgradeAnonymousCredential behavior if enabled and handle no result from the behavior", async () => { const mockUI = createMockUI(); const email = "test@example.com"; const password = "password123"; @@ -210,21 +233,21 @@ describe("createUserWithEmailAndPassword", () => { expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); - it('should call handleFirebaseError if an error is thrown', async () => { + it("should call handleFirebaseError if an error is thrown", async () => { const mockUI = createMockUI(); const email = "test@example.com"; const password = "password123"; vi.mocked(hasBehavior).mockReturnValue(false); - const error = new FirebaseError('foo/bar', 'Foo bar'); + const error = new FirebaseError("foo/bar", "Foo bar"); vi.mocked(_createUserWithEmailAndPassword).mockRejectedValue(error); await createUserWithEmailAndPassword(mockUI, email, password); expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"],["idle"]]); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); }); @@ -261,7 +284,7 @@ describe("signInWithPhoneNumber", () => { const mockUI = createMockUI(); const phoneNumber = "+1234567890"; const mockRecaptchaVerifier = {} as any; - const error = new FirebaseError('auth/invalid-phone-number', 'Invalid phone number'); + const error = new FirebaseError("auth/invalid-phone-number", "Invalid phone number"); vi.mocked(_signInWithPhoneNumber).mockRejectedValue(error); @@ -299,7 +322,7 @@ describe("confirmPhoneNumber", () => { it("should update state and call _signInWithCredential with no behavior", async () => { const mockUI = createMockUI({ - auth: { currentUser: null } as Auth + auth: { currentUser: null } as Auth, }); const confirmationResult = { verificationId: "test-verification-id" } as ConfirmationResult; const verificationCode = "123456"; @@ -326,7 +349,7 @@ describe("confirmPhoneNumber", () => { it("should call autoUpgradeAnonymousCredential behavior when user is anonymous", async () => { const mockUI = createMockUI({ - auth: { currentUser: { isAnonymous: true } } as Auth + auth: { currentUser: { isAnonymous: true } } as Auth, }); const confirmationResult = { verificationId: "test-verification-id" } as ConfirmationResult; const verificationCode = "123456"; @@ -346,12 +369,12 @@ describe("confirmPhoneNumber", () => { expect(result.providerId).toBe("phone"); // Only the `finally` block is called here. - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([['idle']]); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["idle"]]); }); it("should not call behavior when user is not anonymous", async () => { const mockUI = createMockUI({ - auth: { currentUser: { isAnonymous: false } } as Auth + auth: { currentUser: { isAnonymous: false } } as Auth, }); const confirmationResult = { verificationId: "test-verification-id" } as ConfirmationResult; const verificationCode = "123456"; @@ -373,7 +396,7 @@ describe("confirmPhoneNumber", () => { it("should not call behavior when user is null", async () => { const mockUI = createMockUI({ - auth: { currentUser: null } as Auth + auth: { currentUser: null } as Auth, }); const confirmationResult = { verificationId: "test-verification-id" } as ConfirmationResult; const verificationCode = "123456"; @@ -395,7 +418,7 @@ describe("confirmPhoneNumber", () => { it("should fall back to normal sign-in when behavior returns undefined", async () => { const mockUI = createMockUI({ - auth: { currentUser: { isAnonymous: true } } as Auth + auth: { currentUser: { isAnonymous: true } } as Auth, }); const confirmationResult = { verificationId: "test-verification-id" } as ConfirmationResult; const verificationCode = "123456"; @@ -422,12 +445,12 @@ describe("confirmPhoneNumber", () => { it("should call handleFirebaseError if an error is thrown", async () => { const mockUI = createMockUI({ - auth: { currentUser: null } as Auth + auth: { currentUser: null } as Auth, }); const confirmationResult = { verificationId: "test-verification-id" } as ConfirmationResult; const verificationCode = "123456"; - const error = new FirebaseError('auth/invalid-verification-code', 'Invalid verification code'); + const error = new FirebaseError("auth/invalid-verification-code", "Invalid verification code"); vi.mocked(_signInWithCredential).mockRejectedValue(error); @@ -462,7 +485,7 @@ describe("sendPasswordResetEmail", () => { it("should call handleFirebaseError if an error is thrown", async () => { const mockUI = createMockUI(); const email = "test@example.com"; - const error = new FirebaseError('auth/user-not-found', 'User not found'); + const error = new FirebaseError("auth/user-not-found", "User not found"); vi.mocked(_sendPasswordResetEmail).mockRejectedValue(error); @@ -476,441 +499,428 @@ describe("sendPasswordResetEmail", () => { }); }); - describe("sendSignInLinkToEmail", () => { - beforeEach(() => { - vi.clearAllMocks(); - // Mock window.location.href - Object.defineProperty(window, 'location', { - value: { href: 'https://example.com' }, - writable: true - }); - }); - - afterEach(() => { - // Clean up localStorage after each test - window.localStorage.clear(); +describe("sendSignInLinkToEmail", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Mock window.location.href + Object.defineProperty(window, "location", { + value: { href: "https://example.com" }, + writable: true, }); + }); - it("should update state and call sendSignInLinkToEmail successfully", async () => { - const mockUI = createMockUI(); - const email = "test@example.com"; - - vi.mocked(_sendSignInLinkToEmail).mockResolvedValue(undefined); + afterEach(() => { + // Clean up localStorage after each test + window.localStorage.clear(); + }); - await sendSignInLinkToEmail(mockUI, email); + it("should update state and call sendSignInLinkToEmail successfully", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + vi.mocked(_sendSignInLinkToEmail).mockResolvedValue(undefined); - const expectedActionCodeSettings = { - url: 'https://example.com', - handleCodeInApp: true, - }; - expect(_sendSignInLinkToEmail).toHaveBeenCalledWith(mockUI.auth, email, expectedActionCodeSettings); - expect(_sendSignInLinkToEmail).toHaveBeenCalledTimes(1); + await sendSignInLinkToEmail(mockUI, email); - // Verify email is stored in localStorage - expect(window.localStorage.getItem("emailForSignIn")).toBe(email); - }); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); - it("should call handleFirebaseError if an error is thrown", async () => { - const mockUI = createMockUI(); - const email = "test@example.com"; - const error = new FirebaseError('auth/invalid-email', 'Invalid email address'); + const expectedActionCodeSettings = { + url: "https://example.com", + handleCodeInApp: true, + }; + expect(_sendSignInLinkToEmail).toHaveBeenCalledWith(mockUI.auth, email, expectedActionCodeSettings); + expect(_sendSignInLinkToEmail).toHaveBeenCalledTimes(1); - vi.mocked(_sendSignInLinkToEmail).mockRejectedValue(error); + // Verify email is stored in localStorage + expect(window.localStorage.getItem("emailForSignIn")).toBe(email); + }); - await sendSignInLinkToEmail(mockUI, email); + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const error = new FirebaseError("auth/invalid-email", "Invalid email address"); - // Verify error handling - expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + vi.mocked(_sendSignInLinkToEmail).mockRejectedValue(error); - // Verify state management still happens - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + await sendSignInLinkToEmail(mockUI, email); - // Verify email is NOT stored in localStorage on error - expect(window.localStorage.getItem("emailForSignIn")).toBeNull(); - }); + // Verify error handling + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); - it("should use current window location for action code settings", async () => { - const mockUI = createMockUI(); - const email = "test@example.com"; - - Object.defineProperty(window, 'location', { - value: { href: 'https://myapp.com/auth' }, - writable: true - }); + // Verify state management still happens + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); - vi.mocked(_sendSignInLinkToEmail).mockResolvedValue(undefined); + // Verify email is NOT stored in localStorage on error + expect(window.localStorage.getItem("emailForSignIn")).toBeNull(); + }); - await sendSignInLinkToEmail(mockUI, email); + it("should use current window location for action code settings", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; - const expectedActionCodeSettings = { - url: 'https://myapp.com/auth', - handleCodeInApp: true, - }; - expect(_sendSignInLinkToEmail).toHaveBeenCalledWith(mockUI.auth, email, expectedActionCodeSettings); + Object.defineProperty(window, "location", { + value: { href: "https://myapp.com/auth" }, + writable: true, }); - it("should overwrite existing email in localStorage", async () => { - const mockUI = createMockUI(); - const email = "test@example.com"; - const existingEmail = "old@example.com"; + vi.mocked(_sendSignInLinkToEmail).mockResolvedValue(undefined); - window.localStorage.setItem("emailForSignIn", existingEmail); + await sendSignInLinkToEmail(mockUI, email); - vi.mocked(_sendSignInLinkToEmail).mockResolvedValue(undefined); - - await sendSignInLinkToEmail(mockUI, email); - - expect(window.localStorage.getItem("emailForSignIn")).toBe(email); - }); + const expectedActionCodeSettings = { + url: "https://myapp.com/auth", + handleCodeInApp: true, + }; + expect(_sendSignInLinkToEmail).toHaveBeenCalledWith(mockUI.auth, email, expectedActionCodeSettings); }); - describe("signInWithEmailLink", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should create credential and call signInWithCredential with no behavior", async () => { - const mockUI = createMockUI(); - const email = "test@example.com"; - const link = "https://example.com/auth?oobCode=abc123"; - - const credential = EmailAuthProvider.credentialWithLink(email, link); - vi.mocked(hasBehavior).mockReturnValue(false); - vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(credential); - vi.mocked(_signInWithCredential).mockResolvedValue({ providerId: "emailLink" } as UserCredential); - - const result = await signInWithEmailLink(mockUI, email, link); + it("should overwrite existing email in localStorage", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const existingEmail = "old@example.com"; - // Verify credential was created correctly - expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, link); - - // Verify our signInWithCredential function was called (which internally calls Firebase) - expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); - expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); - expect(_signInWithCredential).toHaveBeenCalledTimes(1); + window.localStorage.setItem("emailForSignIn", existingEmail); - // Assert that the result is a valid UserCredential. - expect(result.providerId).toBe("emailLink"); - }); + vi.mocked(_sendSignInLinkToEmail).mockResolvedValue(undefined); - it('should call the autoUpgradeAnonymousCredential behavior if enabled and return a value', async () => { - const mockUI = createMockUI(); - const email = "test@example.com"; - const link = "https://example.com/auth?oobCode=abc123"; + await sendSignInLinkToEmail(mockUI, email); - const credential = EmailAuthProvider.credentialWithLink(email, link); - vi.mocked(hasBehavior).mockReturnValue(true); - vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(credential); - const mockBehavior = vi.fn().mockResolvedValue({ providerId: "emailLink" } as UserCredential); - vi.mocked(getBehavior).mockReturnValue(mockBehavior); + expect(window.localStorage.getItem("emailForSignIn")).toBe(email); + }); +}); - const result = await signInWithEmailLink(mockUI, email, link); +describe("signInWithEmailLink", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); - // Verify credential was created correctly - expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, link); - - // Verify our signInWithCredential function was called with behavior - expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); - expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + it("should create credential and call signInWithCredential with no behavior", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const link = "https://example.com/auth?oobCode=abc123"; - expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); - expect(result.providerId).toBe("emailLink"); + const credential = EmailAuthProvider.credentialWithLink(email, link); + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(credential); + vi.mocked(_signInWithCredential).mockResolvedValue({ providerId: "emailLink" } as UserCredential); - // Only the `finally` block is called here. - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([['idle']]); - }); + const result = await signInWithEmailLink(mockUI, email, link); - it('should call the autoUpgradeAnonymousCredential behavior if enabled and handle no result from the behavior', async () => { - const mockUI = createMockUI(); - const email = "test@example.com"; - const link = "https://example.com/auth?oobCode=abc123"; + // Verify credential was created correctly + expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, link); - const credential = EmailAuthProvider.credentialWithLink(email, link); - vi.mocked(hasBehavior).mockReturnValue(true); - vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(credential); - const mockBehavior = vi.fn().mockResolvedValue(undefined); - vi.mocked(getBehavior).mockReturnValue(mockBehavior); + // Verify our signInWithCredential function was called (which internally calls Firebase) + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(_signInWithCredential).toHaveBeenCalledTimes(1); - await signInWithEmailLink(mockUI, email, link); + // Assert that the result is a valid UserCredential. + expect(result.providerId).toBe("emailLink"); + }); - // Verify credential was created correctly - expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, link); - - // Verify our signInWithCredential function was called with behavior - expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); - expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + it("should call the autoUpgradeAnonymousCredential behavior if enabled and return a value", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const link = "https://example.com/auth?oobCode=abc123"; - expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + const credential = EmailAuthProvider.credentialWithLink(email, link); + vi.mocked(hasBehavior).mockReturnValue(true); + vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(credential); + const mockBehavior = vi.fn().mockResolvedValue({ providerId: "emailLink" } as UserCredential); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); - expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); - expect(_signInWithCredential).toHaveBeenCalledTimes(1); + const result = await signInWithEmailLink(mockUI, email, link); - // Calls pending pre-_signInWithCredential call, then idle after. - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); - }); + // Verify credential was created correctly + expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, link); - it("should call handleFirebaseError if an error is thrown", async () => { - const mockUI = createMockUI(); - const email = "test@example.com"; - const link = "https://example.com/auth?oobCode=abc123"; + // Verify our signInWithCredential function was called with behavior + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); - vi.mocked(hasBehavior).mockReturnValue(false); + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + expect(result.providerId).toBe("emailLink"); - const error = new FirebaseError('auth/invalid-action-code', 'Invalid action code'); + // Only the `finally` block is called here. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["idle"]]); + }); - vi.mocked(_signInWithCredential).mockRejectedValue(error); + it("should call the autoUpgradeAnonymousCredential behavior if enabled and handle no result from the behavior", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const link = "https://example.com/auth?oobCode=abc123"; - await signInWithEmailLink(mockUI, email, link); + const credential = EmailAuthProvider.credentialWithLink(email, link); + vi.mocked(hasBehavior).mockReturnValue(true); + vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(credential); + const mockBehavior = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); - // Verify credential was created correctly - expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, link); - - // Verify our signInWithCredential function was called and error was handled - expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); - expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); - }); - }); + await signInWithEmailLink(mockUI, email, link); - describe("signInWithCredential", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + // Verify credential was created correctly + expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, link); - it("should update state and call _signInWithCredential with no behavior", async () => { - const mockUI = createMockUI(); - const credential = { providerId: "password" } as any; + // Verify our signInWithCredential function was called with behavior + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); - vi.mocked(hasBehavior).mockReturnValue(false); - vi.mocked(_signInWithCredential).mockResolvedValue({ providerId: "password" } as UserCredential); + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); - const result = await signInWithCredential(mockUI, credential); + expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(_signInWithCredential).toHaveBeenCalledTimes(1); - expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + // Calls pending pre-_signInWithCredential call, then idle after. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); - // Calls pending pre-_signInWithCredential call, then idle after. - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const link = "https://example.com/auth?oobCode=abc123"; - expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); - expect(_signInWithCredential).toHaveBeenCalledTimes(1); + vi.mocked(hasBehavior).mockReturnValue(false); - // Assert that the result is a valid UserCredential. - expect(result.providerId).toBe("password"); - }); + const error = new FirebaseError("auth/invalid-action-code", "Invalid action code"); - it('should call the autoUpgradeAnonymousCredential behavior if enabled and return a value', async () => { - const mockUI = createMockUI(); - const credential = { providerId: "password" } as any; + vi.mocked(_signInWithCredential).mockRejectedValue(error); - vi.mocked(hasBehavior).mockReturnValue(true); - const mockBehavior = vi.fn().mockResolvedValue({ providerId: "password" } as UserCredential); - vi.mocked(getBehavior).mockReturnValue(mockBehavior); + await signInWithEmailLink(mockUI, email, link); - const result = await signInWithCredential(mockUI, credential); + // Verify credential was created correctly + expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith(email, link); - expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); - expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + // Verify our signInWithCredential function was called and error was handled + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); +}); - expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); - expect(result.providerId).toBe("password"); +describe("signInWithCredential", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); - // Only the `finally` block is called here. - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([['idle']]); - }); + it("should update state and call _signInWithCredential with no behavior", async () => { + const mockUI = createMockUI(); + const credential = { providerId: "password" } as any; - it('should call the autoUpgradeAnonymousCredential behavior if enabled and handle no result from the behavior', async () => { - const mockUI = createMockUI(); - const credential = { providerId: "password" } as any; + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(_signInWithCredential).mockResolvedValue({ providerId: "password" } as UserCredential); - vi.mocked(hasBehavior).mockReturnValue(true); - const mockBehavior = vi.fn().mockResolvedValue(undefined); - vi.mocked(getBehavior).mockReturnValue(mockBehavior); + const result = await signInWithCredential(mockUI, credential); - await signInWithCredential(mockUI, credential); + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); - expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); - expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + // Calls pending pre-_signInWithCredential call, then idle after. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); - expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(_signInWithCredential).toHaveBeenCalledTimes(1); - expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); - expect(_signInWithCredential).toHaveBeenCalledTimes(1); + // Assert that the result is a valid UserCredential. + expect(result.providerId).toBe("password"); + }); - // Calls pending pre-_signInWithCredential call, then idle after. - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); - }); + it("should call the autoUpgradeAnonymousCredential behavior if enabled and return a value", async () => { + const mockUI = createMockUI(); + const credential = { providerId: "password" } as any; - it("should call handleFirebaseError if an error is thrown", async () => { - const mockUI = createMockUI(); - const credential = { providerId: "password" } as any; + vi.mocked(hasBehavior).mockReturnValue(true); + const mockBehavior = vi.fn().mockResolvedValue({ providerId: "password" } as UserCredential); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); - vi.mocked(hasBehavior).mockReturnValue(false); + const result = await signInWithCredential(mockUI, credential); - const error = new FirebaseError('auth/invalid-credential', 'Invalid credential'); + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); - vi.mocked(_signInWithCredential).mockRejectedValue(error); + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + expect(result.providerId).toBe("password"); - await signInWithCredential(mockUI, credential); + // Only the `finally` block is called here. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["idle"]]); + }); - expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"],["idle"]]); - }); + it("should call the autoUpgradeAnonymousCredential behavior if enabled and handle no result from the behavior", async () => { + const mockUI = createMockUI(); + const credential = { providerId: "password" } as any; - it("should handle behavior errors", async () => { - const mockUI = createMockUI(); - const credential = { providerId: "password" } as any; - const error = new Error("Behavior error"); + vi.mocked(hasBehavior).mockReturnValue(true); + const mockBehavior = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); - vi.mocked(hasBehavior).mockReturnValue(true); - const mockBehavior = vi.fn().mockRejectedValue(error); - vi.mocked(getBehavior).mockReturnValue(mockBehavior); + await signInWithCredential(mockUI, credential); - await signInWithCredential(mockUI, credential); + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); - expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["idle"]]); + expect(_signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(_signInWithCredential).toHaveBeenCalledTimes(1); - expect(_signInWithCredential).not.toHaveBeenCalled(); - }); + // Calls pending pre-_signInWithCredential call, then idle after. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); }); - describe("signInAnonymously", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const credential = { providerId: "password" } as any; - it("should update state and call signInAnonymously successfully", async () => { - const mockUI = createMockUI(); - const mockUserCredential = { - user: { uid: "anonymous-uid", isAnonymous: true }, - providerId: "anonymous", - operationType: "signIn", - } as UserCredential; + vi.mocked(hasBehavior).mockReturnValue(false); - vi.mocked(_signInAnonymously).mockResolvedValue(mockUserCredential); + const error = new FirebaseError("auth/invalid-credential", "Invalid credential"); - const result = await signInAnonymously(mockUI); + vi.mocked(_signInWithCredential).mockRejectedValue(error); - // Verify state management - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + await signInWithCredential(mockUI, credential); - // Verify the Firebase function was called with correct parameters - expect(_signInAnonymously).toHaveBeenCalledWith(mockUI.auth); - expect(_signInAnonymously).toHaveBeenCalledTimes(1); + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); - // Verify the result - expect(result).toEqual(mockUserCredential); - }); + it("should handle behavior errors", async () => { + const mockUI = createMockUI(); + const credential = { providerId: "password" } as any; + const error = new Error("Behavior error"); - it("should call handleFirebaseError if an error is thrown", async () => { - const mockUI = createMockUI(); - const error = new FirebaseError('auth/operation-not-allowed', 'Anonymous sign-in is not enabled'); + vi.mocked(hasBehavior).mockReturnValue(true); + const mockBehavior = vi.fn().mockRejectedValue(error); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); - vi.mocked(_signInAnonymously).mockRejectedValue(error); + await signInWithCredential(mockUI, credential); - await signInAnonymously(mockUI); + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); - // Verify error handling - expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["idle"]]); - // Verify state management still happens - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); - }); + expect(_signInWithCredential).not.toHaveBeenCalled(); }); +}); - describe("signInWithProvider", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); +describe("signInAnonymously", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); - it("should call signInWithRedirect with no behavior", async () => { - const mockUI = createMockUI(); - const provider = { providerId: "google.com" } as AuthProvider; + it("should update state and call signInAnonymously successfully", async () => { + const mockUI = createMockUI(); + const mockUserCredential = { + user: { uid: "anonymous-uid", isAnonymous: true }, + providerId: "anonymous", + operationType: "signIn", + } as UserCredential; - vi.mocked(hasBehavior).mockReturnValue(false); - vi.mocked(signInWithRedirect).mockResolvedValue(undefined as never); + vi.mocked(_signInAnonymously).mockResolvedValue(mockUserCredential); - await signInWithProvider(mockUI, provider); + const result = await signInAnonymously(mockUI); - expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousProvider"); + // Verify state management + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + // Verify the Firebase function was called with correct parameters + expect(_signInAnonymously).toHaveBeenCalledWith(mockUI.auth); + expect(_signInAnonymously).toHaveBeenCalledTimes(1); - expect(signInWithRedirect).toHaveBeenCalledWith(mockUI.auth, provider); - expect(signInWithRedirect).toHaveBeenCalledTimes(1); - }); + // Verify the result + expect(result).toEqual(mockUserCredential); + }); - it("should call autoUpgradeAnonymousProvider behavior if enabled", async () => { - const mockUI = createMockUI(); - const provider = { providerId: "google.com" } as AuthProvider; + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const error = new FirebaseError("auth/operation-not-allowed", "Anonymous sign-in is not enabled"); - vi.mocked(hasBehavior).mockReturnValue(true); - const mockBehavior = vi.fn().mockResolvedValue(undefined); - vi.mocked(getBehavior).mockReturnValue(mockBehavior); - vi.mocked(signInWithRedirect).mockResolvedValue(undefined as never); + vi.mocked(_signInAnonymously).mockRejectedValue(error); - await signInWithProvider(mockUI, provider); + await signInAnonymously(mockUI); - expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousProvider"); - expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousProvider"); - expect(mockBehavior).toHaveBeenCalledWith(mockUI, provider); + // Verify error handling + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + // Verify state management still happens + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); +}); - expect(signInWithRedirect).toHaveBeenCalledWith(mockUI.auth, provider); - }); +describe("signInWithProvider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); - it("should call handleFirebaseError if an error is thrown", async () => { - const mockUI = createMockUI(); - const provider = { providerId: "google.com" } as AuthProvider; - const error = new FirebaseError('auth/operation-not-allowed', 'Google sign-in is not enabled'); + it("should call providerSignInStrategy behavior when no autoUpgradeAnonymousProvider", async () => { + const mockUI = createMockUI(); + const provider = { providerId: "google.com" } as AuthProvider; + const mockResult = { user: { uid: "test-user" } } as UserCredential; - vi.mocked(hasBehavior).mockReturnValue(false); - vi.mocked(signInWithRedirect).mockRejectedValue(error); + // Mock behaviors - no autoUpgradeAnonymousProvider + vi.mocked(hasBehavior).mockReturnValue(false); - await signInWithProvider(mockUI, provider); + const mockProviderStrategy = vi.fn().mockResolvedValue(mockResult); + vi.mocked(getBehavior).mockReturnValue(mockProviderStrategy); - expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + const result = await signInWithProvider(mockUI, provider); - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); - }); + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousProvider"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "providerSignInStrategy"); + expect(mockProviderStrategy).toHaveBeenCalledWith(mockUI, provider); + expect(result).toBe(mockResult); + }); - it("should handle behavior errors", async () => { - const mockUI = createMockUI(); - const provider = { providerId: "google.com" } as AuthProvider; - const error = new Error("Behavior error"); + it("should call autoUpgradeAnonymousProvider behavior if enabled and return result", async () => { + const mockUI = createMockUI(); + const provider = { providerId: "google.com" } as AuthProvider; + const mockCredential = { user: { uid: "upgraded-user" } } as UserCredential; - vi.mocked(hasBehavior).mockReturnValue(true); - const mockBehavior = vi.fn().mockRejectedValue(error); - vi.mocked(getBehavior).mockReturnValue(mockBehavior); + vi.mocked(hasBehavior).mockReturnValue(true); + const mockUpgradeBehavior = vi.fn().mockResolvedValue(mockCredential); + vi.mocked(getBehavior).mockReturnValue(mockUpgradeBehavior); - await signInWithProvider(mockUI, provider); + const result = await signInWithProvider(mockUI, provider); - expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousProvider"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousProvider"); + expect(mockUpgradeBehavior).toHaveBeenCalledWith(mockUI, provider); + expect(result).toBe(mockCredential); + }); - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["idle"]]); + it("should call providerSignInStrategy when autoUpgradeAnonymousProvider returns undefined", async () => { + const mockUI = createMockUI(); + const provider = { providerId: "google.com" } as AuthProvider; + const mockResult = { user: { uid: "test-user" } } as UserCredential; - expect(signInWithRedirect).not.toHaveBeenCalled(); + // Mock behaviors - autoUpgradeAnonymousProvider enabled but returns undefined + vi.mocked(hasBehavior).mockReturnValue(true); + + const mockUpgradeBehavior = vi.fn().mockResolvedValue(undefined); + const mockProviderStrategy = vi.fn().mockResolvedValue(mockResult); + vi.mocked(getBehavior).mockImplementation((_ui, behavior) => { + if (behavior === "autoUpgradeAnonymousProvider") return mockUpgradeBehavior; + if (behavior === "providerSignInStrategy") return mockProviderStrategy; + return vi.fn(); }); - it("should handle errors from signInWithRedirect", async () => { - const mockUI = createMockUI(); - const provider = { providerId: "google.com" } as AuthProvider; - const error = new FirebaseError("auth/operation-not-allowed", "Operation not allowed"); + const result = await signInWithProvider(mockUI, provider); - vi.mocked(hasBehavior).mockReturnValue(false); - vi.mocked(signInWithRedirect).mockRejectedValue(error); + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousProvider"); + expect(mockUpgradeBehavior).toHaveBeenCalledWith(mockUI, provider); + expect(mockProviderStrategy).toHaveBeenCalledWith(mockUI, provider); + expect(result).toBe(mockResult); + }); - await signInWithProvider(mockUI, provider); + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const provider = { providerId: "google.com" } as AuthProvider; + const error = new FirebaseError("auth/operation-not-allowed", "Google sign-in is not enabled"); - expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + vi.mocked(hasBehavior).mockReturnValue(false); + const mockProviderStrategy = vi.fn().mockRejectedValue(error); + vi.mocked(getBehavior).mockReturnValue(mockProviderStrategy); - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); - }); - }); + await signInWithProvider(mockUI, provider); -// TODO(ehesp): Test completeEmailLinkSignIn - it depends on an internal function -// which you can't mock: https://vitest.dev/guide/mocking.html#mocking-pitfalls \ No newline at end of file + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["idle"]]); + }); +}); diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 38de6518..22996d4c 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -29,7 +29,6 @@ import { EmailAuthProvider, linkWithCredential, PhoneAuthProvider, - signInWithRedirect, UserCredential, AuthCredential, } from "firebase/auth"; @@ -224,20 +223,24 @@ export async function signInAnonymously(ui: FirebaseUIConfiguration): Promise { +export async function signInWithProvider(ui: FirebaseUIConfiguration, provider: AuthProvider): Promise { try { if (hasBehavior(ui, "autoUpgradeAnonymousProvider")) { - await getBehavior(ui, "autoUpgradeAnonymousProvider")(ui, provider); - // If we get to here, the user is not anonymous, otherwise they - // have been redirected to the provider's sign in page. + const credential = await getBehavior(ui, "autoUpgradeAnonymousProvider")(ui, provider); + + // If we got here, the user is either not anonymous, or they have been linked + // via a popup, and the credential has been created. + if (credential) { + return handlePendingCredential(ui, credential); + } } - ui.setState("pending"); + const strategy = getBehavior(ui, "providerSignInStrategy"); + const result = await strategy(ui, provider); - // TODO(ehesp): Handle popup or redirect based on behavior - await 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. + // If we got here, the user has been signed in via a popup. + // Otherwise, they will have been redirected. + return handlePendingCredential(ui, result); } catch (error) { handleFirebaseError(ui, error); } finally { diff --git a/packages/core/src/behaviors/anonymous-upgrade.ts b/packages/core/src/behaviors/anonymous-upgrade.ts index e43ef429..a8be5cd2 100644 --- a/packages/core/src/behaviors/anonymous-upgrade.ts +++ b/packages/core/src/behaviors/anonymous-upgrade.ts @@ -1,11 +1,11 @@ import { AuthCredential, AuthProvider, linkWithCredential, linkWithRedirect } from "firebase/auth"; import { FirebaseUIConfiguration } from "~/config"; import { RedirectHandler } from "./utils"; +import { getBehavior } from "~/behaviors"; export const autoUpgradeAnonymousCredentialHandler = async (ui: FirebaseUIConfiguration, credential: AuthCredential) => { const currentUser = ui.auth.currentUser; - // Check if the user is anonymous. If not, we can't upgrade them. if (!currentUser?.isAnonymous) { return; } @@ -24,11 +24,7 @@ export const autoUpgradeAnonymousProviderHandler = async (ui: FirebaseUIConfigur return; } - ui.setState("pending"); - // TODO... this should use redirect OR popup - await 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. + return getBehavior(ui, "providerLinkStrategy")(ui, currentUser, provider); }; export const autoUpgradeAnonymousUserRedirectHandler: RedirectHandler = async () => { diff --git a/packages/core/src/behaviors/index.ts b/packages/core/src/behaviors/index.ts index 0ec2341c..aae859a7 100644 --- a/packages/core/src/behaviors/index.ts +++ b/packages/core/src/behaviors/index.ts @@ -3,6 +3,7 @@ import type { RecaptchaVerifier } from "firebase/auth"; import * as anonymousUpgradeHandlers from "./anonymous-upgrade"; import * as autoAnonymousLoginHandlers from "./auto-anonymous-login"; import * as recaptchaHandlers from "./recaptcha"; +import * as providerStrategyHandlers from "./provider-strategy"; import * as oneTapSignInHandlers from "./one-tap"; import { callableBehavior, @@ -23,6 +24,8 @@ type Registry = { typeof anonymousUpgradeHandlers.autoUpgradeAnonymousUserRedirectHandler >; recaptchaVerification: CallableBehavior<(ui: FirebaseUIConfiguration, element: HTMLElement) => RecaptchaVerifier>; + providerSignInStrategy: CallableBehavior; + providerLinkStrategy: CallableBehavior; oneTapSignIn: InitBehavior<(ui: FirebaseUIConfiguration) => ReturnType>; }; @@ -57,6 +60,20 @@ export function recaptchaVerification(options?: RecaptchaVerificationOptions): B }; } +export function providerRedirectStrategy(): Behavior<"providerSignInStrategy" | "providerLinkStrategy"> { + return { + providerSignInStrategy: callableBehavior(providerStrategyHandlers.signInWithRediectHandler), + providerLinkStrategy: callableBehavior(providerStrategyHandlers.linkWithRedirectHandler), + }; +} + +export function providerPopupStrategy(): Behavior<"providerSignInStrategy" | "providerLinkStrategy"> { + return { + providerSignInStrategy: callableBehavior(providerStrategyHandlers.signInWithPopupHandler), + providerLinkStrategy: callableBehavior(providerStrategyHandlers.linkWithPopupHandler), + }; +} + export type OneTapSignInOptions = oneTapSignInHandlers.OneTapSignInOptions; export function oneTapSignIn(options: OneTapSignInOptions): Behavior<"oneTapSignIn"> { @@ -79,4 +96,5 @@ export function getBehavior(ui: FirebaseUIConfiguratio export const defaultBehaviors: Behavior<"recaptchaVerification"> = { ...recaptchaVerification(), + ...providerRedirectStrategy(), }; diff --git a/packages/core/src/behaviors/provider-strategy.test.ts b/packages/core/src/behaviors/provider-strategy.test.ts new file mode 100644 index 00000000..06ad282d --- /dev/null +++ b/packages/core/src/behaviors/provider-strategy.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Auth, AuthProvider, linkWithPopup, linkWithRedirect, signInWithPopup, signInWithRedirect, User, UserCredential } from "firebase/auth"; +import { + signInWithRediectHandler, + signInWithPopupHandler, + linkWithRedirectHandler, + linkWithPopupHandler +} from "./provider-strategy"; +import { createMockUI } from "~/tests/utils"; + +vi.mock("firebase/auth", () => ({ + signInWithRedirect: vi.fn(), + signInWithPopup: vi.fn(), + linkWithRedirect: vi.fn(), + linkWithPopup: vi.fn(), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("signInWithRediectHandler", () => { + it("should set state to pending and call signInWithRedirect", async () => { + const mockAuth = {} as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockProvider = { providerId: "google.com" } as AuthProvider; + + vi.mocked(signInWithRedirect).mockResolvedValue({} as never); + + await signInWithRediectHandler(mockUI, mockProvider); + + expect(mockUI.setState).toHaveBeenCalledWith("pending"); + expect(signInWithRedirect).toHaveBeenCalledWith(mockAuth, mockProvider); + }); +}); + +describe("signInWithPopupHandler", () => { + it("should set state to pending, call signInWithPopup, set state to idle, and return result", async () => { + const mockAuth = {} as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockProvider = { providerId: "google.com" } as AuthProvider; + const mockResult = { user: { uid: "test-user" } } as UserCredential; + + vi.mocked(signInWithPopup).mockResolvedValue(mockResult); + + const result = await signInWithPopupHandler(mockUI, mockProvider); + + expect(mockUI.setState).toHaveBeenCalledWith("pending"); + expect(signInWithPopup).toHaveBeenCalledWith(mockAuth, mockProvider); + expect(mockUI.setState).toHaveBeenCalledWith("idle"); + expect(result).toBe(mockResult); + }); + + it("should not set state to idle when signInWithPopup fails", async () => { + const mockAuth = {} as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockProvider = { providerId: "google.com" } as AuthProvider; + const mockError = new Error("Popup sign in failed"); + + vi.mocked(signInWithPopup).mockRejectedValue(mockError); + + await expect(signInWithPopupHandler(mockUI, mockProvider)).rejects.toThrow("Popup sign in failed"); + expect(mockUI.setState).toHaveBeenCalledWith("pending"); + expect(mockUI.setState).not.toHaveBeenCalledWith("idle"); + }); +}); + +describe("linkWithRedirectHandler", () => { + it("should set state to pending and call linkWithRedirect", async () => { + const mockAuth = {} as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockUser = { uid: "test-user" } as User; + const mockProvider = { providerId: "google.com" } as AuthProvider; + + vi.mocked(linkWithRedirect).mockResolvedValue({} as never); + + await linkWithRedirectHandler(mockUI, mockUser, mockProvider); + + expect(mockUI.setState).toHaveBeenCalledWith("pending"); + expect(linkWithRedirect).toHaveBeenCalledWith(mockUser, mockProvider); + }); +}); + +describe("linkWithPopupHandler", () => { + it("should set state to pending, call linkWithPopup, set state to idle, and return result", async () => { + const mockAuth = {} as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockUser = { uid: "test-user" } as User; + const mockProvider = { providerId: "google.com" } as AuthProvider; + const mockResult = { user: { uid: "linked-user" } } as UserCredential; + + vi.mocked(linkWithPopup).mockResolvedValue(mockResult); + + const result = await linkWithPopupHandler(mockUI, mockUser, mockProvider); + + expect(mockUI.setState).toHaveBeenCalledWith("pending"); + expect(linkWithPopup).toHaveBeenCalledWith(mockUser, mockProvider); + expect(mockUI.setState).toHaveBeenCalledWith("idle"); + expect(result).toBe(mockResult); + }); + + it("should not set state to idle when linkWithPopup fails", async () => { + const mockAuth = {} as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockUser = { uid: "test-user" } as User; + const mockProvider = { providerId: "google.com" } as AuthProvider; + const mockError = new Error("Popup link failed"); + + vi.mocked(linkWithPopup).mockRejectedValue(mockError); + + await expect(linkWithPopupHandler(mockUI, mockUser, mockProvider)).rejects.toThrow("Popup link failed"); + expect(mockUI.setState).toHaveBeenCalledWith("pending"); + expect(mockUI.setState).not.toHaveBeenCalledWith("idle"); + }); +}); + diff --git a/packages/core/src/behaviors/provider-strategy.ts b/packages/core/src/behaviors/provider-strategy.ts new file mode 100644 index 00000000..e83e2af3 --- /dev/null +++ b/packages/core/src/behaviors/provider-strategy.ts @@ -0,0 +1,29 @@ +import { AuthProvider, linkWithPopup, linkWithRedirect, signInWithPopup, signInWithRedirect, User, UserCredential } from "firebase/auth"; +import { FirebaseUIConfiguration } from "~/config"; + +export type ProviderSignInStrategyHandler = (ui: FirebaseUIConfiguration, provider: AuthProvider) => Promise +export type ProviderLinkStrategyHandler = (ui: FirebaseUIConfiguration, user: User, provider: AuthProvider) => Promise; + +export const signInWithRediectHandler: ProviderSignInStrategyHandler = async (ui, provider) => { + ui.setState("pending"); + return signInWithRedirect(ui.auth, provider); +}; + +export const signInWithPopupHandler: ProviderSignInStrategyHandler = async (ui, provider) => { + ui.setState("pending"); + const result = await signInWithPopup(ui.auth, provider); + ui.setState("idle"); + return result; +}; + +export const linkWithRedirectHandler: ProviderLinkStrategyHandler = async (ui, user, provider) => { + ui.setState("pending"); + return linkWithRedirect(user, provider); +}; + +export const linkWithPopupHandler: ProviderLinkStrategyHandler = async (ui, user, provider) => { + ui.setState("pending"); + const result = await linkWithPopup(user, provider); + ui.setState("idle"); + return result; +};