285 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|