250 lines
14 KiB
TypeScript
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();
|
|
});
|