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

250 lines
14 KiB
TypeScript

import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations } from "./i18n/translations";
import { ToastProvider } from "./toast";
import SessionsSettingsCard from "./components/SessionsSettingsCard";
import TwoFactorSettingsCard from "./components/TwoFactorSettingsCard";
const mockedApi = api as jest.Mocked<typeof api>;
const enabledStatus = { enabled: true, enabledAtUtc: "2026-02-10T12:00:00Z" };
const currentSession = {
id: "current-session", deviceLabel: "Local current session", isCurrentSession: true,
createdAtUtc: "2026-02-10T12:00:00Z", lastSeenAtUtc: "2026-02-11T12:00:00Z",
expiresAtUtc: "2026-03-10T12:00:00Z",
};
const otherSession = { ...currentSession, id: "other-session", deviceLabel: "Local other session", isCurrentSession: false };
const writeText = jest.fn(() => Promise.resolve());
const createObjectURL = jest.fn((_blob: Blob) => "blob:local-recovery-codes");
const revokeObjectURL = jest.fn();
const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard");
const createUrlDescriptor = Object.getOwnPropertyDescriptor(window.URL, "createObjectURL");
const revokeUrlDescriptor = Object.getOwnPropertyDescriptor(window.URL, "revokeObjectURL");
function restoreProperty(target: object, key: string, descriptor?: PropertyDescriptor) {
if (descriptor) Object.defineProperty(target, key, descriptor);
else Reflect.deleteProperty(target, key);
}
function renderCard(Card: React.ComponentType, language: "en" | "nb" = "en") {
localStorage.setItem("uiLanguage", language);
return render(<ToastProvider><I18nProvider><Card /></I18nProvider></ToastProvider>);
}
function expectNoWrites() {
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.patch).not.toHaveBeenCalled();
}
function readBlob(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsText(blob);
});
}
beforeAll(() => {
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
Object.defineProperty(window.URL, "createObjectURL", { configurable: true, value: createObjectURL });
Object.defineProperty(window.URL, "revokeObjectURL", { configurable: true, value: revokeObjectURL });
});
beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
sessionStorage.clear();
mockedApi.get.mockReset().mockImplementation((url: string) => {
if (url === "/auth/2fa/status") return Promise.resolve({ data: { enabled: false, enabledAtUtc: null } } as any);
if (url === "/auth/2fa/trusted-devices" || url === "/auth/sessions") return Promise.resolve({ data: [] } as any);
return Promise.reject(new Error(`Unexpected read: ${url}`));
});
for (const method of [mockedApi.post, mockedApi.delete, mockedApi.put, mockedApi.patch]) {
method.mockReset().mockRejectedValue(new Error("Unexpected security write"));
}
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
sessionStorage.clear();
});
afterAll(() => {
restoreProperty(navigator, "clipboard", clipboardDescriptor);
restoreProperty(window.URL, "createObjectURL", createUrlDescriptor);
restoreProperty(window.URL, "revokeObjectURL", revokeUrlDescriptor);
});
test("cancelling setup sends no security write and clears the password before reopening", async () => {
renderCard(TwoFactorSettingsCard);
fireEvent.click(await screen.findByRole("button", { name: translations.en.twoFactorEnableButton }));
const dialog = screen.getByRole("dialog", { name: translations.en.twoFactorPasswordPrompt });
fireEvent.change(within(dialog).getByLabelText(translations.en.twoFactorPasswordLabel), { target: { value: "local-cancelled-password" } });
expect(within(dialog).getByRole("button", { name: translations.en.twoFactorContinue })).toBeEnabled();
fireEvent.click(within(dialog).getByRole("button", { name: translations.en.cancel }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expectNoWrites();
fireEvent.click(screen.getByRole("button", { name: translations.en.twoFactorEnableButton }));
const reopened = screen.getByRole("dialog", { name: translations.en.twoFactorPasswordPrompt });
expect(within(reopened).getByLabelText(translations.en.twoFactorPasswordLabel)).toHaveValue("");
expect(within(reopened).getByRole("button", { name: translations.en.twoFactorContinue })).toBeDisabled();
expectNoWrites();
});
test("regenerated recovery codes require acknowledgement, survive Escape, export correctly, and clear after Done", async () => {
const codes = ["local-11111", "local-22222"];
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/auth/2fa/status" ? enabledStatus : [] } as any));
mockedApi.post.mockResolvedValueOnce({ data: { recoveryCodes: codes } } as any);
let downloadedLink: { href: string; filename: string } | undefined;
jest.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) {
downloadedLink = { href: this.href, filename: this.download };
});
renderCard(TwoFactorSettingsCard);
fireEvent.click(await screen.findByRole("button", { name: translations.en.twoFactorRegenerateButton }));
const passwordDialog = screen.getByRole("dialog", { name: translations.en.twoFactorPasswordPrompt });
fireEvent.change(within(passwordDialog).getByLabelText(translations.en.twoFactorPasswordLabel), { target: { value: "local-regeneration-password" } });
fireEvent.click(within(passwordDialog).getByRole("button", { name: translations.en.twoFactorContinue }));
const recoveryDialog = await screen.findByRole("dialog", { name: translations.en.twoFactorRecoveryTitle });
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledWith("/auth/2fa/recovery-codes/regenerate", { currentPassword: "local-regeneration-password" });
for (const code of codes) expect(within(recoveryDialog).getByText(code)).toBeVisible();
const acknowledgement = within(recoveryDialog).getByRole("checkbox", { name: translations.en.twoFactorSavedConfirm });
const done = within(recoveryDialog).getByRole("button", { name: translations.en.twoFactorDone });
expect(acknowledgement).not.toBeChecked();
expect(done).toBeDisabled();
fireEvent.keyDown(recoveryDialog, { key: "Escape", code: "Escape", keyCode: 27 });
fireEvent.click(done);
expect(screen.getByRole("dialog", { name: translations.en.twoFactorRecoveryTitle })).toBeVisible();
fireEvent.click(within(recoveryDialog).getByRole("button", { name: translations.en.twoFactorCopyAll }));
expect(writeText).toHaveBeenCalledWith(codes.join("\n"));
fireEvent.click(within(recoveryDialog).getByRole("button", { name: translations.en.twoFactorDownload }));
expect(createObjectURL).toHaveBeenCalledTimes(1);
const blob = createObjectURL.mock.calls[0][0];
expect(blob.type).toBe("text/plain");
expect(await readBlob(blob)).toBe(`${codes.join("\n")}\n`);
expect(downloadedLink).toEqual({ href: "blob:local-recovery-codes", filename: "jobbjakt-recovery-codes.txt" });
expect(revokeObjectURL).toHaveBeenCalledWith("blob:local-recovery-codes");
expect(document.querySelector('a[download="jobbjakt-recovery-codes.txt"]')).toBeNull();
fireEvent.click(acknowledgement);
expect(done).toBeEnabled();
fireEvent.click(done);
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
for (const code of codes) {
expect(document.body).not.toHaveTextContent(code);
expect(JSON.stringify({ ...localStorage, ...sessionStorage })).not.toContain(code);
}
fireEvent.click(await screen.findByRole("button", { name: translations.en.twoFactorRegenerateButton }));
expect(screen.getByLabelText(translations.en.twoFactorPasswordLabel)).toHaveValue("");
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.delete).not.toHaveBeenCalled();
});
test("Bokmål enabled status and trusted-device dates survive a read-only retry, then revoke the selected device with readback", async () => {
const device = {
id: 17, deviceLabel: "Local trusted device", isCurrentDevice: false,
createdAtUtc: "2026-02-10T12:00:00Z", lastSeenAtUtc: "2026-02-11T12:00:00Z",
expiresAtUtc: "2026-03-10T12:00:00Z",
};
let devices = [device];
let deviceReads = 0;
mockedApi.get.mockImplementation((url: string) => {
if (url === "/auth/2fa/status") return Promise.resolve({ data: enabledStatus } as any);
if (url === "/auth/2fa/trusted-devices") {
if (++deviceReads === 1) return Promise.reject({ response: { status: 503, data: "Trusted devices unavailable" } });
return Promise.resolve({ data: devices } as any);
}
return Promise.reject(new Error(`Unexpected read: ${url}`));
});
mockedApi.delete.mockImplementation((url: string) => {
if (url !== "/auth/2fa/trusted-devices/17") return Promise.reject(new Error(`Unexpected delete: ${url}`));
devices = [];
return Promise.resolve({ data: {} } as any);
});
renderCard(TwoFactorSettingsCard, "nb");
const region = screen.getByRole("region", { name: translations.nb.twoFactorSectionTitle });
expect(await within(region).findByText(translations.nb.twoFactorStatusEnabled.replace("{date}", new Date(enabledStatus.enabledAtUtc).toLocaleDateString("nb-NO")))).toBeVisible();
expect(within(region).getByRole("heading", { level: 3, name: translations.nb.twoFactorTrustedDevicesTitle })).toBeInTheDocument();
const alert = await within(region).findByRole("alert");
expect(alert).toHaveTextContent("Trusted devices unavailable");
expect(within(region).queryByText(translations.nb.twoFactorTrustedDevicesEmpty)).not.toBeInTheDocument();
expectNoWrites();
fireEvent.click(within(alert).getByRole("button", { name: translations.nb.retry }));
expect(await within(region).findByText(device.deviceLabel)).toBeVisible();
const metadata = translations.nb.twoFactorTrustedDeviceMeta
.replace("{lastSeen}", new Date(device.lastSeenAtUtc).toLocaleDateString("nb-NO"))
.replace("{expires}", new Date(device.expiresAtUtc).toLocaleDateString("nb-NO"));
expect(within(region).getByText(metadata)).toBeVisible();
expect(deviceReads).toBe(2);
expectNoWrites();
fireEvent.click(within(region).getByRole("button", { name: translations.nb.twoFactorRevokeDevice }));
expect(await within(region).findByText(translations.nb.twoFactorTrustedDevicesEmpty)).toBeVisible();
expect(within(region).queryByText(device.deviceLabel)).not.toBeInTheDocument();
expect(mockedApi.delete).toHaveBeenCalledTimes(1);
expect(mockedApi.delete).toHaveBeenCalledWith("/auth/2fa/trusted-devices/17");
expect(deviceReads).toBe(3);
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("a failed sessions read offers a read-only retry without inventing an empty session list", async () => {
mockedApi.get.mockRejectedValueOnce({ response: { status: 503, data: "Sessions unavailable" } })
.mockResolvedValueOnce({ data: [currentSession] } as any);
renderCard(SessionsSettingsCard);
const region = screen.getByRole("region", { name: translations.en.sessionsSectionTitle });
const alert = await within(region).findByRole("alert");
expect(alert).toHaveTextContent("Sessions unavailable");
expect(within(region).queryByText(translations.en.sessionsEmpty)).not.toBeInTheDocument();
expectNoWrites();
fireEvent.click(within(alert).getByRole("button", { name: translations.en.retry }));
expect(await within(region).findByText(currentSession.deviceLabel)).toBeVisible();
expect(within(region).queryByRole("alert")).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(2);
expect(mockedApi.get).toHaveBeenNthCalledWith(1, "/auth/sessions");
expect(mockedApi.get).toHaveBeenNthCalledWith(2, "/auth/sessions");
expectNoWrites();
});
test("pending revoke-others disables confirmation and cancellation, blocks duplicate writes, and reads back the surviving session", async () => {
let sessions = [currentSession, otherSession];
let resolveRevoke!: (value: any) => void;
mockedApi.get.mockImplementation((url: string) => url === "/auth/sessions"
? Promise.resolve({ data: sessions } as any)
: Promise.reject(new Error(`Unexpected read: ${url}`)));
mockedApi.post.mockImplementation(() => new Promise(resolve => { resolveRevoke = resolve; }));
renderCard(SessionsSettingsCard);
fireEvent.click(await screen.findByRole("button", { name: translations.en.sessionsRevokeOthers }));
const dialog = screen.getByRole("dialog", { name: translations.en.sessionsRevokeOthersConfirmTitle });
const confirm = within(dialog).getByRole("button", { name: translations.en.sessionsRevokeOthers });
const cancel = within(dialog).getByRole("button", { name: translations.en.cancel });
fireEvent.click(confirm);
expect(confirm).toBeDisabled();
expect(cancel).toBeDisabled();
fireEvent.click(confirm);
fireEvent.click(cancel);
fireEvent.keyDown(dialog, { key: "Escape", code: "Escape", keyCode: 27 });
expect(screen.getByRole("dialog", { name: translations.en.sessionsRevokeOthersConfirmTitle })).toBeVisible();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledWith("/auth/sessions/revoke-others");
expect(mockedApi.delete).not.toHaveBeenCalled();
sessions = [currentSession];
await act(async () => resolveRevoke({ data: {} }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByText(currentSession.deviceLabel)).toBeVisible();
expect(screen.queryByText(otherSession.deviceLabel)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: translations.en.sessionsRevokeOthers })).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(2);
expect(mockedApi.get).toHaveBeenNthCalledWith(2, "/auth/sessions");
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.delete).not.toHaveBeenCalled();
});