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; 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(); } 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 { 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(); });