272 lines
12 KiB
TypeScript
272 lines
12 KiB
TypeScript
import "@testing-library/jest-dom";
|
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
import React from "react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import UserProfileSettings from "./UserProfileSettings";
|
|
|
|
const submitMock = vi.fn();
|
|
let searchParams = new URLSearchParams();
|
|
|
|
vi.mock("react-router", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-router")>();
|
|
return {
|
|
...actual,
|
|
Form: ({ children, ...props }: React.FormHTMLAttributes<HTMLFormElement>) => <form {...props}>{children}</form>,
|
|
useLocation: () => ({ pathname: "/acme/settings/profile" }),
|
|
useMatches: () => [],
|
|
useNavigation: () => ({ state: "idle" }),
|
|
useSearchParams: () => [searchParams],
|
|
useSubmit: () => submitMock,
|
|
};
|
|
});
|
|
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({
|
|
i18n: { language: "en", exists: () => true },
|
|
t: (key: string, options?: Record<string, unknown>) => {
|
|
const translations: Record<string, string> = {
|
|
"settings.profile.mfa.authenticatorApp": "Authenticator app",
|
|
"settings.profile.mfa.setupUri": "Authenticator setup URI",
|
|
"settings.profile.mfa.confirmationCode": "Authenticator code",
|
|
"settings.profile.mfa.confirmAuthenticator": "Confirm authenticator",
|
|
"settings.profile.mfa.recoveryCodes": "Recovery codes",
|
|
"settings.profile.mfa.recoveryCodesCopy": "Recovery codes copy value",
|
|
"settings.profile.mfa.saveRecoveryCodes": "Save your recovery codes",
|
|
"settings.profile.session.title": "Current session",
|
|
"settings.profile.session.description": "Redacted diagnostics for this browser session only.",
|
|
"settings.profile.session.lightDarkPreference": "Light/dark preference",
|
|
"settings.profile.session.cookieConsentChoices": "Cookie consent choices",
|
|
"settings.profile.session.impersonation": "Impersonation",
|
|
"settings.profile.session.adminMfaStepUp": "Admin MFA step-up",
|
|
"settings.profile.session.clearStepUpVerification": "Clear step-up verification for this session",
|
|
};
|
|
let value = translations[key] ?? key;
|
|
for (const [name, replacement] of Object.entries(options ?? {})) {
|
|
value = value.replaceAll(`{{${name}}}`, String(replacement));
|
|
}
|
|
return value;
|
|
},
|
|
}),
|
|
}));
|
|
|
|
vi.mock("~/components/ui/input/InputSelect", () => ({
|
|
default: ({ options, setValue, value }: { options: { name: string; value: string }[]; setValue?: (value: string) => void; value?: string }) => (
|
|
<select aria-label="settings.preferences.language" value={value} onChange={(event) => setValue?.(event.target.value)}>
|
|
{options.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
),
|
|
}));
|
|
|
|
vi.mock("~/components/ui/uploaders/UploadDocument", () => ({
|
|
default: () => <button type="button">Upload avatar</button>,
|
|
}));
|
|
|
|
vi.mock("~/components/ui/uploaders/UploadImage", () => ({
|
|
default: () => <div>Upload image dialog</div>,
|
|
}));
|
|
|
|
vi.mock("~/components/ui/modals/SuccessModal", () => ({
|
|
default: () => null,
|
|
}));
|
|
|
|
vi.mock("~/components/ui/modals/ErrorModal", () => ({
|
|
default: () => null,
|
|
}));
|
|
|
|
vi.mock("~/components/ui/modals/ConfirmModal", () => ({
|
|
default: ({ onYes, placeholder }: { onYes?: (value: string) => void; placeholder?: string }) => (
|
|
<button type="button" aria-label="confirm-account-delete" data-placeholder={placeholder} onClick={() => onYes?.("delete-confirm-password")}>
|
|
Confirm delete modal
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
vi.mock("~/utils/data/useRootData", () => ({
|
|
useRootData: () => ({ csrf: "csrf-token" }),
|
|
}));
|
|
|
|
const baseUser = {
|
|
email: "ada@example.com",
|
|
firstName: "Ada",
|
|
lastName: "Lovelace",
|
|
avatar: null,
|
|
admin: null,
|
|
};
|
|
|
|
const currentSession = {
|
|
locale: "en",
|
|
lightOrDarkMode: "dark",
|
|
theme: "zinc",
|
|
cookieConsentCount: 2,
|
|
impersonationActive: true,
|
|
mfaStepUp: {
|
|
present: true,
|
|
verified: true,
|
|
stale: false,
|
|
method: "totp",
|
|
} as const,
|
|
};
|
|
|
|
function renderSettings(props: Partial<React.ComponentProps<typeof UserProfileSettings>> = {}) {
|
|
return render(<UserProfileSettings user={baseUser} currentSession={currentSession} {...props} />);
|
|
}
|
|
|
|
function textContent() {
|
|
return document.body.textContent ?? "";
|
|
}
|
|
|
|
function expectNoSecretsLeak() {
|
|
const serialized = document.body.innerHTML;
|
|
expect(serialized).not.toContain("encrypted-secret");
|
|
expect(serialized).not.toContain("encrypted-pending");
|
|
expect(serialized).not.toContain("totpSecretEncrypted");
|
|
expect(serialized).not.toContain("hash-1");
|
|
expect(serialized).not.toContain("hash-2");
|
|
expect(serialized).not.toContain("RJ_session");
|
|
expect(serialized).not.toContain("raw-cookie-value");
|
|
expect(serialized).not.toContain("csrf-secret");
|
|
expect(serialized).not.toContain("new-password-value");
|
|
expect(serialized).not.toContain("delete-confirm-password");
|
|
}
|
|
|
|
describe("UserProfileSettings", () => {
|
|
beforeEach(() => {
|
|
submitMock.mockClear();
|
|
searchParams = new URLSearchParams();
|
|
});
|
|
|
|
it("renders profile, preferences, and danger controls with accessible form fields", () => {
|
|
renderSettings();
|
|
|
|
expect(screen.getByText("settings.profile.profileTitle")).toBeInTheDocument();
|
|
expect(screen.getByText("account.shared.email")).toBeInTheDocument();
|
|
expect(document.querySelector('input[name="email"]')).toHaveValue("ada@example.com");
|
|
expect(document.querySelector('input[name="firstName"]')).toHaveValue("Ada");
|
|
expect(document.querySelector('input[name="lastName"]')).toHaveValue("Lovelace");
|
|
expect(screen.getByText("settings.preferences.title")).toBeInTheDocument();
|
|
expect(screen.getByText("settings.preferences.language")).toBeInTheDocument();
|
|
expect(screen.getByLabelText("settings.preferences.language")).toHaveValue("en");
|
|
expect(screen.getByRole("button", { name: "settings.danger.deleteUserAccount" })).toBeInTheDocument();
|
|
expect(document.querySelector('input[name="action"][value="profile"]')).toBeInTheDocument();
|
|
expect(document.querySelector('input[name="action"][value="password"]')).toBeInTheDocument();
|
|
expect(document.querySelector('input[name="action"][value="deleteAccount"]')).toBeInTheDocument();
|
|
expect(document.querySelectorAll('form input[name="csrf"][value="csrf-token"]').length).toBeGreaterThanOrEqual(3);
|
|
expectNoSecretsLeak();
|
|
});
|
|
|
|
it("renders disabled, pending, and enabled MFA controls without secret material", () => {
|
|
const { rerender } = renderSettings({
|
|
mfaStatus: { enabled: false, pending: false, enabledAt: null, pendingCreatedAt: null },
|
|
});
|
|
|
|
expect(screen.getByText("Authenticator app")).toBeInTheDocument();
|
|
expect(screen.getByRole("status")).toHaveTextContent("settings.profile.mfa.status.notEnabled");
|
|
expect(screen.getByRole("button", { name: "settings.profile.mfa.startAuthenticatorSetup" })).toBeInTheDocument();
|
|
expect(document.querySelector('input[name="action"][value="startTotpEnrollment"]')).toBeInTheDocument();
|
|
expect(document.querySelector('[data-ui-id="ui.settings.profile.mfa.panel"]')).toBeInTheDocument();
|
|
const mfaToggleForm = document.querySelector('[data-ui-id="ui.settings.profile.mfa.toggle.form"]');
|
|
expect(mfaToggleForm).toBeInTheDocument();
|
|
expect(within(mfaToggleForm as HTMLElement).getByLabelText("settings.profile.passwordCurrent")).toHaveAttribute("name", "passwordCurrent");
|
|
|
|
rerender(
|
|
<UserProfileSettings
|
|
user={baseUser}
|
|
currentSession={currentSession}
|
|
mfaStatus={{ enabled: false, pending: true, enabledAt: null, pendingCreatedAt: "2026-05-08T12:00:00.000Z" }}
|
|
otpauthUri="otpauth://totp/ToolCTRL:ada@example.com?issuer=ToolCTRL"
|
|
mfaError="Enter the 6-digit authenticator code."
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText("settings.profile.mfa.status.pending")).toBeInTheDocument();
|
|
expect(screen.getByLabelText("Authenticator setup URI")).toHaveTextContent("otpauth://totp/ToolCTRL:ada@example.com?issuer=ToolCTRL");
|
|
expect(screen.getByRole("alert")).toHaveTextContent("Enter the 6-digit authenticator code.");
|
|
expect(screen.getByLabelText("Authenticator code")).toHaveAttribute("name", "code");
|
|
expect(screen.getByRole("button", { name: "Confirm authenticator" })).toBeInTheDocument();
|
|
expect(document.querySelector('input[name="action"][value="confirmTotpEnrollment"]')).toBeInTheDocument();
|
|
|
|
rerender(
|
|
<UserProfileSettings
|
|
user={baseUser}
|
|
currentSession={currentSession}
|
|
mfaStatus={{ enabled: true, pending: false, enabledAt: "2026-05-08T12:05:00.000Z", pendingCreatedAt: null }}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText("settings.profile.mfa.status.enabled")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "settings.profile.mfa.disableAuthenticator" })).toBeInTheDocument();
|
|
expect(document.querySelector('input[name="action"][value="disableTotpEnrollment"]')).toBeInTheDocument();
|
|
expectNoSecretsLeak();
|
|
});
|
|
|
|
it("shows one-time recovery codes but not their persisted hashes", () => {
|
|
renderSettings({
|
|
recoveryCodes: ["ABCD-EFGH-JKLM", "NPQR-STUV-WXYZ"],
|
|
});
|
|
|
|
const recoveryRegion = screen.getByLabelText("Recovery codes");
|
|
expect(within(recoveryRegion).getByText("Save your recovery codes")).toBeInTheDocument();
|
|
expect(within(recoveryRegion).getByText("ABCD-EFGH-JKLM")).toBeInTheDocument();
|
|
expect(within(recoveryRegion).getByText("NPQR-STUV-WXYZ")).toBeInTheDocument();
|
|
expect(screen.getByLabelText("Recovery codes copy value")).toHaveValue("ABCD-EFGH-JKLM\nNPQR-STUV-WXYZ");
|
|
expect(document.body.innerHTML).not.toContain("hash-1");
|
|
expect(document.body.innerHTML).not.toContain("hash-2");
|
|
});
|
|
|
|
it("renders current-session diagnostics from redacted loader facts only", () => {
|
|
renderSettings();
|
|
|
|
expect(screen.getByText("Current session")).toBeInTheDocument();
|
|
expect(screen.getByText("Redacted diagnostics for this browser session only.")).toBeInTheDocument();
|
|
expect(screen.getByText("settings.profile.session.locale")).toBeInTheDocument();
|
|
expect(screen.getByText("Light/dark preference")).toBeInTheDocument();
|
|
expect(screen.getByText("settings.profile.session.theme")).toBeInTheDocument();
|
|
expect(screen.getByText("Cookie consent choices")).toBeInTheDocument();
|
|
expect(screen.getByText("Impersonation")).toBeInTheDocument();
|
|
expect(screen.getByText("Admin MFA step-up")).toBeInTheDocument();
|
|
expect(screen.getByText("shared.dark")).toBeInTheDocument();
|
|
expect(screen.getByText("zinc")).toBeInTheDocument();
|
|
expect(screen.getByText("shared.active")).toBeInTheDocument();
|
|
expect(screen.getByText("settings.profile.session.mfaStepUpVerifiedWith")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Clear step-up verification for this session" })).toBeInTheDocument();
|
|
expect(document.querySelector('input[name="action"][value="clearMfaStepUp"]')).toBeInTheDocument();
|
|
expectNoSecretsLeak();
|
|
});
|
|
|
|
it("submits locale and password-confirmed account deletion without rendering submitted passwords", () => {
|
|
renderSettings();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "settings.danger.deleteUserAccount" }));
|
|
fireEvent.click(screen.getAllByRole("button", { name: "confirm-account-delete" }).at(-1)!);
|
|
|
|
const deleteForm = submitMock.mock.calls.at(-1)?.[0] as FormData;
|
|
expect(deleteForm.get("action")).toBe("deleteAccount");
|
|
expect(deleteForm.get("csrf")).toBe("csrf-token");
|
|
expect(deleteForm.get("passwordCurrent")).toBe("delete-confirm-password");
|
|
expect(textContent()).not.toContain("delete-confirm-password");
|
|
|
|
fireEvent.change(screen.getByLabelText("settings.preferences.language"), { target: { value: "de" } });
|
|
|
|
const localeForm = submitMock.mock.calls.at(-1)?.[0] as FormData;
|
|
expect(localeForm.get("action")).toBe("setLocale");
|
|
expect(localeForm.get("csrf")).toBe("csrf-token");
|
|
expect(localeForm.get("lng")).toBe("de");
|
|
expect(localeForm.get("redirect")).toBe("/acme/settings/profile?lng=de");
|
|
expect(submitMock.mock.calls.at(-1)?.[1]).toMatchObject({ method: "post", action: "/preferences", preventScrollReset: true });
|
|
});
|
|
|
|
it("does not echo typed password values from security forms", () => {
|
|
renderSettings();
|
|
|
|
const passwordInputs = document.querySelectorAll('input[type="password"]');
|
|
passwordInputs.forEach((input) => fireEvent.change(input, { target: { value: "new-password-value" } }));
|
|
|
|
expect(textContent()).not.toContain("new-password-value");
|
|
});
|
|
});
|