import React from "react"; import "@testing-library/jest-dom"; jest.unmock("./components/GoogleAuthCard"); jest.unmock("./components/MicrosoftAuthCard"); import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { api } from "./api"; import GoogleAuthCard from "./components/GoogleAuthCard"; import MicrosoftAuthCard from "./components/MicrosoftAuthCard"; import { I18nProvider } from "./i18n/I18nProvider"; import { translations } from "./i18n/translations"; import { ToastProvider } from "./toast"; const mockMsalInitialize = jest.fn(); const mockMsalLoginPopup = jest.fn(); jest.mock("@azure/msal-browser", () => ({ PublicClientApplication: jest.fn().mockImplementation(() => ({ initialize: mockMsalInitialize, loginPopup: mockMsalLoginPopup, })), })); const mockedApi = api as jest.Mocked; const linkedAt = "2026-01-14T12:00:00Z"; const localAccount = { provider: "local", email: "person@example.test", userName: "Person" }; const originalGoogleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID; const originalMicrosoftClientId = process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID; const providers = [ { name: "Google", Card: GoogleAuthCard, title: "googleAccountTitle", hint: "googleSetupHint" }, { name: "Microsoft", Card: MicrosoftAuthCard, title: "microsoftAccountTitle", hint: "microsoftSetupHint" }, ] as const; function mockAccount(me: unknown = localAccount) { mockedApi.get.mockImplementation((url: string) => { if (url === "/auth/me") return Promise.resolve({ data: me } as any); if (url === "/auth/config") return Promise.resolve({ data: { allowRegistration: false } } as any); return Promise.reject(new Error(`Unexpected account read: ${url}`)); }); } function renderAccount(Card: React.ComponentType, language: "en" | "nb" = "en") { localStorage.setItem("uiLanguage", language); return render(); } async function click(element: Element) { await act(async () => { fireEvent.click(element); }); } function installGoogleIdentity() { let callback: ((response: { credential?: string }) => void) | undefined; const initialize = jest.fn((options: { callback: (response: { credential?: string }) => void }) => { callback = options.callback; }); window.google = { accounts: { id: { initialize, renderButton: jest.fn((host: HTMLElement) => { const button = document.createElement("button"); button.textContent = "Mock Google credential action"; button.onclick = () => callback?.({ credential: "unit-google-credential" }); host.appendChild(button); }), } } }; return initialize; } beforeEach(() => { localStorage.clear(); sessionStorage.clear(); localStorage.setItem("authUserKey", "local-account-fixture"); mockedApi.get.mockReset(); mockedApi.post.mockReset(); mockedApi.delete.mockReset(); mockedApi.put.mockClear(); mockedApi.patch.mockClear(); mockMsalInitialize.mockReset().mockResolvedValue(undefined); mockMsalLoginPopup.mockReset(); delete process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID; delete process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID; delete window.google; mockAccount(); }); afterEach(() => { delete window.google; localStorage.clear(); sessionStorage.clear(); }); afterAll(() => { if (originalGoogleClientId === undefined) delete process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID; else process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = originalGoogleClientId; if (originalMicrosoftClientId === undefined) delete process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID; else process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = originalMicrosoftClientId; }); describe("Profile account provider cards", () => { test.each(providers)("[PRES-E02/E09] disabled $name has a named account section without starting OAuth", async ({ Card, title, hint }) => { renderAccount(Card, "nb"); expect(await screen.findByText(translations.nb[hint])).toBeInTheDocument(); expect(screen.queryByText(/NEXT_PUBLIC_/)).not.toBeInTheDocument(); await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/auth/me")); expect(screen.queryByRole("button")).not.toBeInTheDocument(); expect(document.querySelector('script[src^="https://accounts.google.com/"]')).not.toBeInTheDocument(); expect(mockMsalInitialize).not.toHaveBeenCalled(); expect(mockMsalLoginPopup).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled(); expect(screen.getByRole("heading", { level: 2, name: translations.nb[title] })).toBeInTheDocument(); expect(screen.getByRole("region", { name: translations.nb[title] })).toBeInTheDocument(); }); test("[PRES-E02/E09] Google linked metadata follows Bokmål while retaining the stored timestamp", async () => { process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = "unit-google-client"; installGoogleIdentity(); mockAccount({ ...localAccount, googleLink: { linked: true, email: localAccount.email, linkedAt } }); renderAccount(GoogleAuthCard, "nb"); await screen.findByRole("button", { name: translations.nb.unlinkGoogle }); expect(screen.getByText(translations.nb.googleLinkedDate.replace("{date}", new Intl.DateTimeFormat("nb-NO").format(new Date(linkedAt))))).toBeInTheDocument(); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled(); }); test("[PRES-E03/E09] Microsoft linked metadata follows Bokmål while retaining the stored timestamp", async () => { process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = "unit-microsoft-client"; mockAccount({ ...localAccount, microsoftLink: { linked: true, email: localAccount.email, linkedAt } }); renderAccount(MicrosoftAuthCard, "nb"); await screen.findByRole("button", { name: translations.nb.unlinkMicrosoft }); expect(screen.getByText(translations.nb.microsoftLinkedDate.replace("{date}", new Intl.DateTimeFormat("nb-NO").format(new Date(linkedAt))))).toBeInTheDocument(); expect(mockMsalLoginPopup).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled(); }); test("[PRES-E03/E08] Microsoft local linking requires the current password and cancellation keeps it without exchanging credentials", async () => { process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = "unit-microsoft-client"; mockMsalLoginPopup.mockRejectedValue({ errorCode: "user_cancelled", message: "Provider sign-in was cancelled." }); renderAccount(MicrosoftAuthCard); const link = await screen.findByRole("button", { name: translations.en.linkWithMicrosoft }); const password = screen.getByLabelText(translations.en.profileCurrentPassword); expect(link).toBeDisabled(); await click(link); expect(mockMsalLoginPopup).not.toHaveBeenCalled(); fireEvent.change(password, { target: { value: "unit-current-password" } }); expect(link).toBeEnabled(); await click(link); expect(await screen.findByRole("alert")).toHaveTextContent("Provider sign-in was cancelled."); expect(password).toHaveValue("unit-current-password"); expect(link).toBeEnabled(); expect(mockMsalInitialize).toHaveBeenCalledTimes(1); expect(mockMsalLoginPopup).toHaveBeenCalledTimes(1); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled(); expect(localStorage.getItem("authUserKey")).toBe("local-account-fixture"); }); test("[PRES-E03/E08] rejected Microsoft linking retains the password and local account for an explicit retry", async () => { process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = "unit-microsoft-client"; mockMsalLoginPopup.mockResolvedValue({ idToken: "unit-microsoft-credential" }); mockedApi.post.mockRejectedValueOnce({ response: { data: "Current password was not accepted." } }); renderAccount(MicrosoftAuthCard); const link = await screen.findByRole("button", { name: translations.en.linkWithMicrosoft }); const password = screen.getByLabelText(translations.en.profileCurrentPassword); fireEvent.change(password, { target: { value: "unit-current-password" } }); await click(link); expect(await screen.findByRole("alert")).toHaveTextContent("Current password was not accepted."); expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.post).toHaveBeenCalledWith("/auth/microsoft/link", { token: "unit-microsoft-credential", currentPassword: "unit-current-password" }); expect(password).toHaveValue("unit-current-password"); expect(link).toBeEnabled(); expect(localStorage.getItem("authUserKey")).toBe("local-account-fixture"); expect(mockedApi.delete).not.toHaveBeenCalled(); }); test("[PRES-E03/E05] successful Microsoft local linking retains the established forced sign-in contract", async () => { process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = "unit-microsoft-client"; mockMsalLoginPopup.mockResolvedValue({ idToken: "unit-microsoft-credential" }); mockedApi.post.mockImplementation((url: string) => { if (url !== "/auth/microsoft/link") return Promise.reject(new Error(`Unexpected account write: ${url}`)); mockAccount(null); return Promise.resolve({ data: { linked: true } } as any); }); renderAccount(MicrosoftAuthCard); const link = await screen.findByRole("button", { name: translations.en.linkWithMicrosoft }); fireEvent.change(screen.getByLabelText(translations.en.profileCurrentPassword), { target: { value: "unit-current-password" } }); await click(link); expect(await screen.findByRole("alert")).toHaveTextContent(translations.en.microsoftSecurityChangeSignInAgain); expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.post).toHaveBeenCalledWith("/auth/microsoft/link", { token: "unit-microsoft-credential", currentPassword: "unit-current-password" }); expect(localStorage.getItem("authUserKey")).toBeNull(); expect(screen.queryByLabelText(translations.en.profileCurrentPassword)).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: translations.en.continueWithMicrosoft })).toBeEnabled(); expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.patch).not.toHaveBeenCalled(); }); test("[PRES-E03/E05/E08] Microsoft unlink is password-guarded and a failed attempt retains input until a deliberate retry succeeds", async () => { process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = "unit-microsoft-client"; mockAccount({ ...localAccount, microsoftLink: { linked: true, email: localAccount.email, linkedAt } }); mockedApi.delete.mockRejectedValueOnce({ response: { data: "Unlink unavailable." } }).mockImplementationOnce(() => { mockAccount(null); return Promise.resolve({ data: {} } as any); }); renderAccount(MicrosoftAuthCard); const unlink = await screen.findByRole("button", { name: translations.en.unlinkMicrosoft }); const password = screen.getByLabelText(translations.en.profileCurrentPassword); expect(unlink).toBeDisabled(); await click(unlink); expect(mockedApi.delete).not.toHaveBeenCalled(); fireEvent.change(password, { target: { value: "unit-current-password" } }); await click(unlink); expect(await screen.findByRole("alert")).toHaveTextContent("Unlink unavailable."); expect(password).toHaveValue("unit-current-password"); expect(unlink).toBeEnabled(); expect(localStorage.getItem("authUserKey")).toBe("local-account-fixture"); expect(mockedApi.delete).toHaveBeenNthCalledWith(1, "/auth/microsoft/link", { data: { currentPassword: "unit-current-password" } }); await click(unlink); await waitFor(() => expect(screen.queryByRole("button", { name: translations.en.unlinkMicrosoft })).not.toBeInTheDocument()); expect(mockedApi.delete).toHaveBeenNthCalledWith(2, "/auth/microsoft/link", { data: { currentPassword: "unit-current-password" } }); expect(mockedApi.delete).toHaveBeenCalledTimes(2); expect(localStorage.getItem("authUserKey")).toBeNull(); expect(screen.queryByLabelText(translations.en.profileCurrentPassword)).not.toBeInTheDocument(); expect(mockedApi.post).not.toHaveBeenCalled(); }); test.each(["google", "microsoft"] as const)("[PRES-E02/E03] a %s-owned account cannot unlink its only authentication provider", async (provider) => { const google = provider === "google"; process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = "unit-google-client"; process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = "unit-microsoft-client"; installGoogleIdentity(); mockAccount({ ...localAccount, provider, [google ? "googleLink" : "microsoftLink"]: { linked: true, email: localAccount.email, linkedAt } }); renderAccount(google ? GoogleAuthCard : MicrosoftAuthCard); await screen.findByText(google ? translations.en.googleExchangeHint : translations.en.microsoftExchangeHint); expect(screen.queryByRole("button", { name: google ? translations.en.unlinkGoogle : translations.en.unlinkMicrosoft })).not.toBeInTheDocument(); expect(screen.queryByLabelText(translations.en.profileCurrentPassword)).not.toBeInTheDocument(); expect(mockedApi.delete).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("[PRES-E02/E08] Google local linking reports failure and retries the link endpoint without an exchange", async () => { process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = "unit-google-client"; const initialize = installGoogleIdentity(); mockedApi.post.mockRejectedValueOnce({ response: { data: "Link unavailable." } }).mockImplementationOnce(() => { mockAccount({ ...localAccount, googleLink: { linked: true, email: localAccount.email, linkedAt } }); return Promise.resolve({ data: { linked: true } } as any); }); renderAccount(GoogleAuthCard); await screen.findByText(translations.en.googleBindHint); await click(await screen.findByRole("button", { name: "Mock Google credential action" })); expect(await screen.findByRole("alert")).toHaveTextContent("Link unavailable."); expect(localStorage.getItem("authUserKey")).toBe("local-account-fixture"); expect(mockedApi.post).toHaveBeenNthCalledWith(1, "/auth/google/link", { token: "unit-google-credential", rememberMe: true }); await click(screen.getByRole("button", { name: "Mock Google credential action" })); expect(await screen.findByRole("button", { name: translations.en.unlinkGoogle })).toBeEnabled(); expect(mockedApi.post).toHaveBeenNthCalledWith(2, "/auth/google/link", { token: "unit-google-credential", rememberMe: true }); expect(mockedApi.post).toHaveBeenCalledTimes(2); expect(initialize).toHaveBeenCalledTimes(1); expect(document.querySelector('script[src^="https://accounts.google.com/"]')).not.toBeInTheDocument(); expect(mockedApi.delete).not.toHaveBeenCalled(); }); test("[PRES-E02/E08] failed Google unlink preserves the local account and linked state until explicit retry", async () => { process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = "unit-google-client"; installGoogleIdentity(); mockAccount({ ...localAccount, googleLink: { linked: true, email: localAccount.email, linkedAt } }); mockedApi.delete.mockRejectedValueOnce({ response: { data: "Unlink unavailable." } }).mockImplementationOnce(() => { mockAccount({ ...localAccount, googleLink: { linked: false } }); return Promise.resolve({ data: {} } as any); }); renderAccount(GoogleAuthCard); const unlink = await screen.findByRole("button", { name: translations.en.unlinkGoogle }); await click(unlink); expect(await screen.findByRole("alert")).toHaveTextContent("Unlink unavailable."); expect(unlink).toBeEnabled(); expect(localStorage.getItem("authUserKey")).toBe("local-account-fixture"); expect(mockedApi.delete).toHaveBeenNthCalledWith(1, "/auth/google/link"); await click(unlink); await waitFor(() => expect(screen.queryByRole("button", { name: translations.en.unlinkGoogle })).not.toBeInTheDocument()); expect(mockedApi.delete).toHaveBeenNthCalledWith(2, "/auth/google/link"); expect(mockedApi.delete).toHaveBeenCalledTimes(2); expect(localStorage.getItem("authUserKey")).toBe("local-account-fixture"); expect(screen.getByRole("button", { name: "Mock Google credential action" })).toBeInTheDocument(); expect(mockedApi.post).not.toHaveBeenCalled(); }); });