Files
jobtrackingapp/job-tracker-ui/src/account-providers.test.tsx
T

285 lines
16 KiB
TypeScript

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<typeof api>;
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(<ToastProvider><I18nProvider><Card /></I18nProvider></ToastProvider>);
}
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();
});
});