Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/users/components/UserProfileSettings.test.tsx
T

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");
});
});