From d54c381818aac29f96fb0413a95166981229a68b Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Sun, 26 Jan 2025 20:11:45 +0300 Subject: [PATCH 1/3] feat(react/auth): add useApplyActionCodeMutation --- .../auth/useApplyActionCodeMutation.test.tsx | 118 ++++++++++++++++++ .../src/auth/useApplyActionCodeMutation.ts | 20 +++ packages/react/src/auth/utils.ts | 87 ++++++++----- 3 files changed, 197 insertions(+), 28 deletions(-) create mode 100644 packages/react/src/auth/useApplyActionCodeMutation.test.tsx create mode 100644 packages/react/src/auth/useApplyActionCodeMutation.ts diff --git a/packages/react/src/auth/useApplyActionCodeMutation.test.tsx b/packages/react/src/auth/useApplyActionCodeMutation.test.tsx new file mode 100644 index 0000000..664b081 --- /dev/null +++ b/packages/react/src/auth/useApplyActionCodeMutation.test.tsx @@ -0,0 +1,118 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { + createUserWithEmailAndPassword, + sendEmailVerification, +} from "firebase/auth"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { auth, expectFirebaseError, wipeAuth } from "~/testing-utils"; +import { useApplyActionCodeMutation } from "./useApplyActionCodeMutation"; +import { waitForVerificationCode } from "./utils"; +import { queryClient, wrapper } from "../../utils"; + +describe("useApplyActionCodeMutation", () => { + const email = "tqf@invertase.io"; + const password = "TanstackQueryFirebase#123"; + + beforeEach(async () => { + queryClient.clear(); + await wipeAuth(); + await createUserWithEmailAndPassword(auth, email, password); + }); + + afterEach(async () => { + vi.clearAllMocks(); + await auth.signOut(); + }); + + test("successfully applies email verification action code", async () => { + await sendEmailVerification(auth.currentUser!); + const oobCode = await waitForVerificationCode(email); + + const { result } = renderHook(() => useApplyActionCodeMutation(auth), { + wrapper, + }); + + await act(async () => { + await result.current.mutateAsync(oobCode!); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + }); + + test("handles invalid action code", async () => { + const invalidCode = "invalid-action-code"; + + const { result } = renderHook(() => useApplyActionCodeMutation(auth), { + wrapper, + }); + + await act(async () => { + try { + await result.current.mutateAsync(invalidCode); + } catch (error) { + expectFirebaseError(error, "auth/invalid-action-code"); + } + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBeDefined(); + expectFirebaseError(result.current.error, "auth/invalid-action-code"); + }); + + test("handles empty action code", async () => { + const { result } = renderHook(() => useApplyActionCodeMutation(auth), { + wrapper, + }); + + await act(async () => { + try { + await result.current.mutateAsync(""); + } catch (error) { + expectFirebaseError(error, "auth/invalid-req-type"); + } + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBeDefined(); + expectFirebaseError(result.current.error, "auth/invalid-req-type"); + }); + + test("executes onSuccess callback", async () => { + await sendEmailVerification(auth.currentUser!); + const oobCode = await waitForVerificationCode(email); + const onSuccess = vi.fn(); + + const { result } = renderHook( + () => useApplyActionCodeMutation(auth, { onSuccess }), + { wrapper } + ); + + await act(async () => { + await result.current.mutateAsync(oobCode!); + }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalled()); + }); + + test("executes onError callback", async () => { + const invalidCode = "invalid-action-code"; + const onError = vi.fn(); + + const { result } = renderHook( + () => useApplyActionCodeMutation(auth, { onError }), + { wrapper } + ); + + await act(async () => { + try { + await result.current.mutateAsync(invalidCode); + } catch (error) { + expectFirebaseError(error, "auth/invalid-action-code"); + } + }); + + await waitFor(() => expect(onError).toHaveBeenCalled()); + expect(onError.mock.calls[0][0]).toBeDefined(); + expectFirebaseError(result.current.error, "auth/invalid-action-code"); + }); +}); diff --git a/packages/react/src/auth/useApplyActionCodeMutation.ts b/packages/react/src/auth/useApplyActionCodeMutation.ts new file mode 100644 index 0000000..5bb3c95 --- /dev/null +++ b/packages/react/src/auth/useApplyActionCodeMutation.ts @@ -0,0 +1,20 @@ +import { type UseMutationOptions, useMutation } from "@tanstack/react-query"; +import { type Auth, type AuthError, applyActionCode } from "firebase/auth"; + +type AuthUseMutationOptions< + TData = unknown, + TError = Error, + TVariables = void +> = Omit, "mutationFn">; + +export function useApplyActionCodeMutation( + auth: Auth, + options?: AuthUseMutationOptions +) { + return useMutation({ + ...options, + mutationFn: (oobCode) => { + return applyActionCode(auth, oobCode); + }, + }); +} diff --git a/packages/react/src/auth/utils.ts b/packages/react/src/auth/utils.ts index 802171f..99f5ef4 100644 --- a/packages/react/src/auth/utils.ts +++ b/packages/react/src/auth/utils.ts @@ -2,36 +2,30 @@ import fs from "node:fs"; import path from "node:path"; /** - * Reads the Firebase emulator debug log and extracts the password reset code - * @param email The email address for which the password reset was requested - * @returns The password reset code (oobCode) or null if not found + * Reads the Firebase emulator debug log and extracts a specific code from the logs. + * @param email The email address for which the code was requested. + * @param logPattern A regular expression pattern to match the log entry. + * @param extractCodeFn A function to extract the code from the relevant log line. + * @returns The extracted code or null if not found. */ -async function getPasswordResetCodeFromLogs( +async function getCodeFromLogs( email: string, + logPattern: RegExp, + extractCodeFn: (line: string) => string | null ): Promise { try { // Read the firebase-debug.log file const logPath = path.join(process.cwd(), "firebase-debug.log"); const logContent = await fs.promises.readFile(logPath, "utf8"); - // Find the most recent password reset link for the given email + // Reverse lines to start with the most recent logs const lines = logContent.split("\n").reverse(); - const resetLinkPattern = new RegExp( - `To reset the password for ${email.replace( - ".", - "\\.", - )}.*?http://127\\.0\\.0\\.1:9099.*`, - "i", - ); for (const line of lines) { - const match = line.match(resetLinkPattern); - if (match) { - // Extract oobCode from the reset link - const url = match[0].match(/http:\/\/127\.0\.0\.1:9099\/.*?$/)?.[0]; - if (url) { - const oobCode = new URL(url).searchParams.get("oobCode"); - return oobCode; + if (logPattern.test(line)) { + const code = extractCodeFn(line); + if (code) { + return code; } } } @@ -44,21 +38,25 @@ async function getPasswordResetCodeFromLogs( } /** - * Waits for the password reset code to appear in the logs - * @param email The email address for which the password reset was requested - * @param timeout Maximum time to wait in milliseconds - * @param interval Interval between checks in milliseconds - * @returns The password reset code or null if timeout is reached + * Waits for a specific code to appear in the logs. + * @param email The email address for which the code was requested. + * @param logPattern A regular expression pattern to match the log entry. + * @param extractCodeFn A function to extract the code from the relevant log line. + * @param timeout Maximum time to wait in milliseconds. + * @param interval Interval between checks in milliseconds. + * @returns The extracted code or null if timeout is reached. */ -async function waitForPasswordResetCode( +async function waitForCode( email: string, + logPattern: RegExp, + extractCodeFn: (line: string) => string | null, timeout = 5000, - interval = 100, + interval = 100 ): Promise { const startTime = Date.now(); while (Date.now() - startTime < timeout) { - const code = await getPasswordResetCodeFromLogs(email); + const code = await getCodeFromLogs(email, logPattern, extractCodeFn); if (code) { return code; } @@ -68,4 +66,37 @@ async function waitForPasswordResetCode( return null; } -export { getPasswordResetCodeFromLogs, waitForPasswordResetCode }; +/** + * Extracts the oobCode from a log line. + * @param line The log line containing the oobCode link. + * @returns The oobCode or null if not found. + */ +function extractOobCode(line: string): string | null { + const url = line.match(/http:\/\/127\.0\.0\.1:9099\/emulator\/action\?.*?$/)?.[0]; + return url ? new URL(url).searchParams.get("oobCode") : null; +} + +// Exported helper functions for different use cases +export async function waitForPasswordResetCode( + email: string, + timeout = 5000, + interval = 100 +): Promise { + const logPattern = new RegExp( + `To reset the password for ${email.replace(".", "\\.")}.*?http://127\\.0\\.0\\.1:9099.*`, + "i" + ); + return waitForCode(email, logPattern, extractOobCode, timeout, interval); +} + +export async function waitForVerificationCode( + email: string, + timeout = 5000, + interval = 100 +): Promise { + const logPattern = new RegExp( + `To verify the email address ${email.replace(".", "\\.")}.*?http://127\\.0\\.0\\.1:9099.*`, + "i" + ); + return waitForCode(email, logPattern, extractOobCode, timeout, interval); +} From 981d456165828ccfad09aab417b8cea48847394b Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Sun, 26 Jan 2025 20:14:16 +0300 Subject: [PATCH 2/3] _ --- packages/react/src/auth/utils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react/src/auth/utils.ts b/packages/react/src/auth/utils.ts index 99f5ef4..d410c5d 100644 --- a/packages/react/src/auth/utils.ts +++ b/packages/react/src/auth/utils.ts @@ -76,7 +76,6 @@ function extractOobCode(line: string): string | null { return url ? new URL(url).searchParams.get("oobCode") : null; } -// Exported helper functions for different use cases export async function waitForPasswordResetCode( email: string, timeout = 5000, From 4e649535571609a4117505b414606f2981b45866 Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Sun, 26 Jan 2025 20:18:25 +0300 Subject: [PATCH 3/3] chore: export useApplyActionCodeMutation --- packages/react/src/auth/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/auth/index.ts b/packages/react/src/auth/index.ts index 024d9a3..fde77a5 100644 --- a/packages/react/src/auth/index.ts +++ b/packages/react/src/auth/index.ts @@ -9,7 +9,7 @@ // useMultiFactorUserUnenrollMutation (MultiFactorUser) // useMultiFactorUserGetSessionMutation (MultiFactorUser) // useMultiFactorResolverResolveSignInMutation (MultiFactorResolver) -// useApplyActionCodeMutation +export { useApplyActionCodeMutation } from "./useApplyActionCodeMutation"; // useCheckActionCodeMutation // useConfirmPasswordResetMutation // useCreateUserWithEmailAndPasswordMutation