Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,18 @@ export default function App() {
let cleanup: (() => void) | undefined;

FirebaseAuthentication.addListener("authStateChange", async (change) => {
// Refresh user data and token to get latest claims (e.g., email_verified)
// This must happen before any API calls that depend on token claims
if (change.user) {
try {
await FirebaseAuthentication.reload();
await FirebaseAuthentication.getIdToken({ forceRefresh: true });
} catch (e) {
// Token refresh failed - continue with possibly stale token
logger.debug("Token refresh on auth state change failed:", e);
}
}

setLoggedInUser(change.user);

// Sync RevenueCat identity with Firebase user (skip on web)
Expand Down Expand Up @@ -274,12 +286,6 @@ export default function App() {
});
}
}

if (change.user) {
FirebaseAuthentication.getIdToken().catch(() => {
// Token refresh failed - will retry on next auth state change
});
}
}).then((handle) => {
cleanup = () => handle.remove();
});
Expand Down
71 changes: 70 additions & 1 deletion src/__tests__/unit/App.firebase-auth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import React from "react";
const mockSetLoggedInUser = vi.fn();
const mockSetLauncherAccess = vi.fn();
const mockGetIdToken = vi.fn();
const mockReload = vi.fn();
const mockAddListener = vi.fn();
const mockRemove = vi.fn();

Expand Down Expand Up @@ -69,7 +70,8 @@ vi.mock("@capacitor/status-bar", () => ({
vi.mock("@capacitor-firebase/authentication", () => ({
FirebaseAuthentication: {
addListener: (...args: unknown[]) => mockAddListener(...args),
getIdToken: () => mockGetIdToken(),
getIdToken: (...args: unknown[]) => mockGetIdToken(...args),
reload: () => mockReload(),
},
}));

Expand Down Expand Up @@ -230,6 +232,7 @@ describe("Firebase Auth Integration", () => {
Promise.resolve({ remove: mockRemove }),
);
mockGetIdToken.mockResolvedValue({ token: "mock-token" });
mockReload.mockResolvedValue(undefined);

// Default RevenueCat mocks
mockPurchasesLogIn.mockResolvedValue({
Expand Down Expand Up @@ -352,6 +355,72 @@ describe("Firebase Auth Integration", () => {
});
});

// Regression test: Ensures token is refreshed on auth state change to get
// latest claims (e.g., email_verified). Without this, users who verified
// their email externally would see the requirements modal on app restart
// because the cached token still had email_verified=false.
it("should refresh token with forceRefresh on auth state change to get latest claims", async () => {
mockPlatform = "ios";
const callOrder: string[] = [];

let authCallback: ((change: { user: unknown }) => void) | null = null;
mockAddListener.mockImplementation(
(_event: string, callback: (change: { user: unknown }) => void) => {
authCallback = callback;
return Promise.resolve({ remove: mockRemove });
},
);

mockReload.mockImplementation(() => {
callOrder.push("reload");
return Promise.resolve();
});

mockGetIdToken.mockImplementation(() => {
callOrder.push("getIdToken");
return Promise.resolve({ token: "mock-token" });
});

mockGetSubscriptionStatus.mockImplementation(() => {
callOrder.push("getSubscriptionStatus");
return Promise.resolve({ is_premium: false });
});

const App = (await import("@/App")).default;
render(<App />);

await waitFor(() => {
expect(authCallback).not.toBeNull();
});

// Trigger auth state change with a user
const mockUser = { uid: "123", email: "test@example.com" };
await act(async () => {
authCallback!({ user: mockUser });
});

// Verify reload and getIdToken were called
expect(mockReload).toHaveBeenCalled();
expect(mockGetIdToken).toHaveBeenCalledWith({ forceRefresh: true });

// Wait for the calls to complete
await waitFor(() => {
expect(callOrder.length).toBeGreaterThanOrEqual(2);
});

// Verify the order: reload -> getIdToken -> API calls
const reloadIndex = callOrder.indexOf("reload");
const getIdTokenIndex = callOrder.indexOf("getIdToken");
const apiCallIndex = callOrder.indexOf("getSubscriptionStatus");

expect(callOrder).toContain("reload");
expect(callOrder).toContain("getIdToken");
expect(reloadIndex).toBeLessThan(getIdTokenIndex);
if (apiCallIndex !== -1) {
expect(getIdTokenIndex).toBeLessThan(apiCallIndex);
}
});

describe("RevenueCat sync", () => {
it("should skip RevenueCat calls on web platform", async () => {
mockPlatform = "web";
Expand Down
135 changes: 128 additions & 7 deletions src/__tests__/unit/components/RequirementsModal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { render, screen, fireEvent, waitFor } from "../../../test-utils";
import { describe, it, expect, vi, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { RequirementsModal } from "@/components/RequirementsModal";
import { useRequirementsStore } from "@/hooks/useRequirementsModal";
import type { PendingRequirement } from "@/lib/models";
Expand All @@ -10,6 +11,7 @@ vi.mock("@capacitor-firebase/authentication", () => ({
signOut: vi.fn().mockResolvedValue(undefined),
sendEmailVerification: vi.fn().mockResolvedValue(undefined),
reload: vi.fn().mockResolvedValue(undefined),
getIdToken: vi.fn().mockResolvedValue({ token: "mock-token" }),
getCurrentUser: vi.fn().mockResolvedValue({ user: null }),
},
}));
Expand All @@ -27,13 +29,6 @@ vi.mock("@capacitor/core", () => ({
},
}));

vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));

vi.mock("@/lib/onlineApi", () => ({
updateRequirements: vi.fn().mockResolvedValue({
requirements: {
Expand Down Expand Up @@ -547,5 +542,131 @@ describe("RequirementsModal", () => {
).toBeInTheDocument();
});
});

// Regression test: Modal should close when only email verification was pending,
// even if other requirements (TOS, privacy, age) are false in the API response
it("should close modal when email is verified and it was the only pending requirement", async () => {
const user = userEvent.setup();
const requirements: PendingRequirement[] = [
{
type: "email_verified",
description: "Verify email",
endpoint: "/account/requirements",
},
];

useRequirementsStore.setState({
isOpen: true,
pendingRequirements: requirements,
});

const { FirebaseAuthentication } =
await import("@capacitor-firebase/authentication");
const { getRequirements } = await import("@/lib/onlineApi");

// Mock email as verified
vi.mocked(FirebaseAuthentication.getCurrentUser).mockResolvedValue({
user: { emailVerified: true } as never,
});

// Mock API response where only email_verified is true,
// other requirements are false (the bug scenario)
vi.mocked(getRequirements).mockResolvedValue({
requirements: {
email_verified: true,
tos_accepted: false,
privacy_accepted: false,
age_verified: false,
},
required_versions: { tos: "1.0", privacy: "1.0" },
accepted_versions: { tos: null, privacy: null },
});

render(<RequirementsModal />);

// Send email first
const sendEmailButton = screen.getByRole("button", {
name: /requirements\.sendVerificationEmail/i,
});
await user.click(sendEmailButton);

// Click check email verified
const checkButton = await screen.findByRole("button", {
name: /requirements\.checkEmailVerified/i,
});
await user.click(checkButton);

// Modal should close
await waitFor(() => {
expect(useRequirementsStore.getState().isOpen).toBe(false);
});
});

it("should NOT close modal when email is verified but other pending requirements remain", async () => {
const user = userEvent.setup();
// This tests the case where both email and TOS are pending
const requirements: PendingRequirement[] = [
{
type: "email_verified",
description: "Verify email",
endpoint: "/account/requirements",
},
{
type: "terms_acceptance",
description: "Accept terms",
endpoint: "/account/requirements",
},
];

useRequirementsStore.setState({
isOpen: true,
pendingRequirements: requirements,
});

const { FirebaseAuthentication } =
await import("@capacitor-firebase/authentication");
const { getRequirements } = await import("@/lib/onlineApi");

// Mock email as verified
vi.mocked(FirebaseAuthentication.getCurrentUser).mockResolvedValue({
user: { emailVerified: true } as never,
});

// Email is verified but TOS is not accepted
vi.mocked(getRequirements).mockResolvedValue({
requirements: {
email_verified: true,
tos_accepted: false,
privacy_accepted: false,
age_verified: false,
},
required_versions: { tos: "1.0", privacy: "1.0" },
accepted_versions: { tos: null, privacy: null },
});

render(<RequirementsModal />);

// Send email first
const sendEmailButton = screen.getByRole("button", {
name: /requirements\.sendVerificationEmail/i,
});
await user.click(sendEmailButton);

// Click check email verified
const checkButton = await screen.findByRole("button", {
name: /requirements\.checkEmailVerified/i,
});
await user.click(checkButton);

// Should show email verified message but NOT close modal
await waitFor(() => {
expect(
screen.getByText("requirements.emailVerified"),
).toBeInTheDocument();
});

// Modal should still be open (other requirements still pending)
expect(useRequirementsStore.getState().isOpen).toBe(true);
});
});
});
57 changes: 0 additions & 57 deletions src/__tests__/unit/routes/settings.online.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,21 +265,6 @@ describe("Settings Online Route", () => {
).not.toBeInTheDocument();
});

it("should show email verified indicator for verified password users", () => {
renderComponent();
expect(screen.getByText("online.emailVerified")).toBeInTheDocument();
});

it("should show email not verified indicator with resend button for unverified users", () => {
mockState.loggedInUser = {
...mockState.loggedInUser,
emailVerified: false,
};
renderComponent();
expect(screen.getByText("online.emailNotVerified")).toBeInTheDocument();
expect(screen.getByText("online.resendVerification")).toBeInTheDocument();
});

it("should show Google login indicator for Google users", () => {
mockState.loggedInUser = {
...mockState.loggedInUser,
Expand Down Expand Up @@ -463,48 +448,6 @@ describe("Settings Online Route", () => {
});
});

describe("email verification", () => {
beforeEach(() => {
mockState.loggedInUser = {
email: "test@example.com",
uid: "test-uid",
displayName: "Test User",
emailVerified: false,
providerData: [{ providerId: "password" }],
};
});

it("should send verification email when resend clicked", async () => {
const user = userEvent.setup();
renderComponent();

await user.click(screen.getByText("online.resendVerification"));

await waitFor(() => {
expect(mockFirebaseAuth.sendEmailVerification).toHaveBeenCalled();
});

await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("online.verificationSent");
});
});

it("should handle verification email failure", async () => {
const user = userEvent.setup();
mockFirebaseAuth.sendEmailVerification.mockRejectedValueOnce(
new Error("Failed"),
);

renderComponent();

await user.click(screen.getByText("online.resendVerification"));

await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("online.verificationFailed");
});
});
});

describe("account deletion", () => {
beforeEach(() => {
mockState.loggedInUser = {
Expand Down
Loading