Files
kandev/apps/web/components/settings/account/security-settings.test.tsx
T
Clément Fiere 15c0c35df6 feat(auth): resolve session hostnames (#2741)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-09-04 13:06:33 +00:00

623 lines
24 KiB
TypeScript

import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDefaultUserSettings } from "@/lib/ssr/user-settings";
import type { UserSettingsState } from "@/lib/state/slices/settings/types";
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
import { SettingsTargetProvider } from "@/components/settings/settings-target-provider";
import { emitSettingsTargetRequest } from "@/lib/settings-discovery/target";
import { formatDateTime } from "@/lib/i18n/formats";
import { ApiError } from "@/lib/api/client";
import type { AuthSession } from "@/lib/api/domains/auth-api";
import { registerUsersHandlers } from "@/lib/ws/handlers/users";
import type { BackendMessageMap } from "@/lib/types/backend";
import { SecuritySettings } from "./security-settings";
import { SettingsSaveProvider } from "@/components/settings/settings-save-provider";
const authApiMocks = vi.hoisted(() => ({
listSessions: vi.fn(),
revokeSession: vi.fn(),
changePassword: vi.fn(),
}));
const settingsApiMocks = vi.hoisted(() => ({ updateUserSettings: vi.fn() }));
const toastMocks = vi.hoisted(() => ({ error: vi.fn() }));
vi.mock("@/lib/api/domains/auth-api", () => ({
listSessions: (...args: unknown[]) => authApiMocks.listSessions(...args),
revokeSession: (...args: unknown[]) => authApiMocks.revokeSession(...args),
changePassword: (...args: unknown[]) => authApiMocks.changePassword(...args),
}));
vi.mock("@/lib/api/domains/settings-api", () => ({
updateUserSettings: (...args: unknown[]) => settingsApiMocks.updateUserSettings(...args),
}));
vi.mock("@/lib/toast/sonner", () => ({
toast: { error: (...args: unknown[]) => toastMocks.error(...args) },
}));
const LAST_SEEN_AT = "2026-08-15T15:42:00Z";
const RELATIVE_TEST_ID = "last-seen-relative";
const SELECT_TEST_ID = "last-seen-display-select";
const EMPTY_TEST_ID = "last-seen-empty";
const SESSIONS_CARD_TEST_ID = "account-sessions-card";
const DIRTY_ATTRIBUTE = "data-settings-dirty";
const DIRTY_TRUE = "true";
const DIRTY_FALSE = "false";
const RELATIVE_OPTION = "Relative time";
const ABSOLUTE_OPTION = "Absolute time";
const SESSION: AuthSession = {
id: "sess-1",
created_at: "2026-01-01T00:00:00Z",
last_seen_at: LAST_SEEN_AT,
user_agent: "Chrome on macOS",
ip: "203.0.113.7",
current: true,
hostname: "",
hostname_resolved_at: null,
};
/** Returns default user settings merged with the given overrides. */
function makeUserSettings(overrides: Partial<UserSettingsState> = {}): UserSettingsState {
return { ...createDefaultUserSettings(), ...overrides };
}
type StoreHolder = { current: ReturnType<typeof useAppStoreApi> | null };
/** Stores the app store API in the holder and renders nothing. */
function StoreCapture({ holder }: { holder: StoreHolder }) {
holder.current = useAppStoreApi();
return null;
}
/** Renders SecuritySettings inside a store provider, optionally capturing the store API. */
function renderSecurity(overrides: Partial<UserSettingsState> = {}, holder?: StoreHolder) {
return render(
<StateProvider initialState={{ userSettings: makeUserSettings({ revision: 1, ...overrides }) }}>
<SettingsSaveProvider>
<TooltipProvider>
{holder ? <StoreCapture holder={holder} /> : null}
<SecuritySettings />
</TooltipProvider>
</SettingsSaveProvider>
</StateProvider>,
);
}
/** Renders SecuritySettings and waits for the last-seen display select to appear. */
async function renderLoaded(overrides: Partial<UserSettingsState> = {}, holder?: StoreHolder) {
renderSecurity(overrides, holder);
await screen.findByTestId(SELECT_TEST_ID);
}
// RemountHarness keeps one StateProvider (and thus one store) mounted while
// toggling only SecuritySettings, so a remounted component reads the same
// converged store the pre-unmount component mutated.
function RemountHarness({ holder, mounted }: { holder: StoreHolder; mounted: boolean }) {
return (
<StateProvider initialState={{ userSettings: makeUserSettings({ revision: 1 }) }}>
<SettingsSaveProvider>
<TooltipProvider>
<StoreCapture holder={holder} />
{mounted ? <SecuritySettings /> : null}
</TooltipProvider>
</SettingsSaveProvider>
</StateProvider>
);
}
// renderRelativeWithFakeTime mounts the card in relative mode under fake
// timers at a fixed clock, optionally overriding the session's last_seen_at.
async function renderRelativeWithFakeTime(lastSeenAt?: string) {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-15T15:45:00Z"));
if (lastSeenAt) {
authApiMocks.listSessions.mockResolvedValue({
sessions: [{ ...SESSION, last_seen_at: lastSeenAt }],
});
}
renderSecurity({ lastSeenDisplay: "relative" });
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
}
/** Opens the last-seen display select and clicks the option with the given name. */
async function chooseDisplay(name: "Absolute time" | "Relative time") {
fireEvent.click(screen.getByTestId(SELECT_TEST_ID));
fireEvent.click(await screen.findByRole("option", { name }));
}
/** Runs the shared settings save action for the current draft. */
async function saveChanges() {
fireEvent.click(await screen.findByRole("button", { name: "Save changes" }));
}
type PatchResponse = {
settings: {
user_id: string;
workspace_id: string;
repository_ids: string[];
updated_at: string;
revision: number;
[key: string]: unknown;
};
};
/** Returns a default settings PATCH response merged with the given overrides. */
function patchResponse(overrides: Record<string, unknown> = {}): PatchResponse {
return {
settings: {
user_id: "default-user",
workspace_id: "workspace-1",
repository_ids: [],
updated_at: "2026-08-15T15:43:00Z",
last_seen_display: "relative",
revision: 2,
...overrides,
},
};
}
/** Builds a user.settings.updated WebSocket message from the given payload. */
function settingsUpdatedMessage(
payload: Partial<BackendMessageMap["user.settings.updated"]["payload"]>,
): BackendMessageMap["user.settings.updated"] {
return {
type: "notification",
action: "user.settings.updated",
payload: {
user_id: "default-user",
workspace_id: "workspace-1",
repository_ids: [],
...payload,
},
};
}
/** Feeds a user.settings.updated snapshot through the registered WS handler. */
function dispatchUserSettingsSnapshot(
holder: StoreHolder,
payload: Partial<BackendMessageMap["user.settings.updated"]["payload"]>,
) {
registerUsersHandlers(holder.current!)["user.settings.updated"]?.(
settingsUpdatedMessage(payload),
);
}
/** Creates a PATCH promise with externally callable resolve and reject functions. */
function deferredPatch(): {
promise: Promise<PatchResponse>;
resolve: (value: PatchResponse) => void;
reject: (error: ApiError) => void;
} {
let resolve!: (value: PatchResponse) => void;
let reject!: (error: ApiError) => void;
const promise = new Promise<PatchResponse>((complete, fail) => {
resolve = complete;
reject = fail;
});
return { promise, resolve, reject };
}
beforeEach(() => {
authApiMocks.listSessions.mockReset().mockResolvedValue({ sessions: [SESSION] });
authApiMocks.revokeSession.mockReset().mockResolvedValue(undefined);
authApiMocks.changePassword.mockReset().mockResolvedValue(undefined);
settingsApiMocks.updateUserSettings.mockReset().mockResolvedValue(patchResponse());
toastMocks.error.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe("Last seen display rendering", () => {
it("renders absolute time by default", async () => {
await renderLoaded();
expect(screen.queryByTestId(RELATIVE_TEST_ID)).toBeNull();
expect(screen.queryByTestId(EMPTY_TEST_ID)).toBeNull();
expect(screen.getByText(formatDateTime(new Date(LAST_SEEN_AT)))).toBeTruthy();
});
it("renders a relative label when the store baseline is relative", async () => {
await renderLoaded({ lastSeenDisplay: "relative" });
const trigger = screen.getByTestId(RELATIVE_TEST_ID);
expect(trigger.textContent).not.toBe("");
});
it("exposes the absolute timestamp as accessible name and native title", async () => {
await renderLoaded({ lastSeenDisplay: "relative" });
const trigger = screen.getByTestId(RELATIVE_TEST_ID);
const absolute = formatDateTime(new Date(LAST_SEEN_AT));
// The trigger is a semantic, focusable time element, not a bare span.
expect(trigger.tagName).toBe("TIME");
expect(trigger.getAttribute("dateTime")).toBe(new Date(LAST_SEEN_AT).toISOString());
expect(trigger.getAttribute("aria-label")).toBe(absolute);
expect(trigger.getAttribute("title")).toBe(absolute);
});
it("opens the absolute-time tooltip on keyboard focus of the relative trigger", async () => {
await renderLoaded({ lastSeenDisplay: "relative" });
const trigger = screen.getByTestId(RELATIVE_TEST_ID);
fireEvent.focus(trigger);
await waitFor(() => expect(screen.getByRole("tooltip")).toBeTruthy());
expect(screen.getByRole("tooltip").textContent).toBe(formatDateTime(new Date(LAST_SEEN_AT)));
});
it("renders an empty cell for an unparseable last_seen_at", async () => {
authApiMocks.listSessions.mockResolvedValue({
sessions: [{ ...SESSION, last_seen_at: "not-a-date" }],
});
await renderLoaded({ lastSeenDisplay: "relative" });
expect(screen.getByTestId(EMPTY_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(RELATIVE_TEST_ID)).toBeNull();
});
it("keeps the Last seen select accessible when no sessions are returned", async () => {
authApiMocks.listSessions.mockResolvedValue({ sessions: [] });
await renderLoaded({ lastSeenDisplay: "absolute" });
// The display option lives on the card independent of table rows.
expect(screen.getByTestId(SELECT_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId("account-sessions-table")).toBeNull();
});
it("keeps the Last seen select reachable when loading sessions fails", async () => {
authApiMocks.listSessions.mockRejectedValue(new Error("boom"));
await renderLoaded({ lastSeenDisplay: "absolute" });
await screen.findByTestId("account-sessions-error");
// A transient sessions API failure must not hide the persisted preference.
expect(screen.getByTestId(SELECT_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId("account-sessions-table")).toBeNull();
});
});
describe("Session hostname reconciliation", () => {
const older = "2026-08-18T12:00:00.000000001Z";
const newer = "2026-08-18T12:00:00.000000002Z";
it.each([
{
name: "keeps a newer HTTP result over an older event",
responseHostname: "http-new.example.test",
responseResolvedAt: newer,
streamedHostname: "event-old.example.test",
streamedResolvedAt: older,
expected: "http-new.example.test",
},
{
name: "keeps a newer event over an older HTTP result",
responseHostname: "http-old.example.test",
responseResolvedAt: older,
streamedHostname: "event-new.example.test",
streamedResolvedAt: newer,
expected: "event-new.example.test",
},
{
name: "keeps a durable HTTP result over a transient event",
responseHostname: "http-durable.example.test",
responseResolvedAt: newer,
streamedHostname: "",
streamedResolvedAt: null,
expected: "http-durable.example.test",
},
{
name: "uses the non-empty result when timestamps match",
responseHostname: "",
responseResolvedAt: newer,
streamedHostname: "event-named.example.test",
streamedResolvedAt: newer,
expected: "event-named.example.test",
},
])("$name", async (scenario) => {
authApiMocks.listSessions.mockResolvedValue({
sessions: [
{
...SESSION,
hostname: scenario.responseHostname,
hostname_resolved_at: scenario.responseResolvedAt,
},
],
});
const holder: StoreHolder = { current: null };
await renderLoaded({ resolveSessionHostnames: true }, holder);
act(() => {
holder
.current!.getState()
.setSessionHostname(SESSION.ip, scenario.streamedHostname, scenario.streamedResolvedAt);
});
expect(screen.getByTestId("account-session-hostname").textContent).toBe(scenario.expected);
});
});
describe("Last seen display persistence", () => {
it("keeps a selection local until the shared save action runs", async () => {
await renderLoaded();
await chooseDisplay(RELATIVE_OPTION);
expect(settingsApiMocks.updateUserSettings).not.toHaveBeenCalled();
expect(screen.getByTestId(SESSIONS_CARD_TEST_ID).getAttribute(DIRTY_ATTRIBUTE)).toBe(
DIRTY_TRUE,
);
fireEvent.click(await screen.findByRole("button", { name: "Save changes" }));
await waitFor(() =>
expect(settingsApiMocks.updateUserSettings).toHaveBeenCalledWith({
last_seen_display: "relative",
}),
);
});
it("discards a local selection through the shared reset action", async () => {
await renderLoaded();
await chooseDisplay(RELATIVE_OPTION);
fireEvent.click(await screen.findByRole("button", { name: "Reset" }));
expect(settingsApiMocks.updateUserSettings).not.toHaveBeenCalled();
expect(screen.getByTestId(SELECT_TEST_ID).textContent).toContain(ABSOLUTE_OPTION);
expect(screen.getByTestId(SESSIONS_CARD_TEST_ID).getAttribute(DIRTY_ATTRIBUTE)).toBe(
DIRTY_FALSE,
);
});
it("persists a relative selection through the shared save action", async () => {
await renderLoaded();
await chooseDisplay(RELATIVE_OPTION);
await saveChanges();
await waitFor(() =>
expect(settingsApiMocks.updateUserSettings).toHaveBeenCalledWith({
last_seen_display: "relative",
}),
);
});
it("surfaces a revoke failure in the sessions card", async () => {
authApiMocks.revokeSession.mockRejectedValue(new ApiError("boom", 400, null));
authApiMocks.listSessions.mockResolvedValue({
sessions: [{ ...SESSION, current: false, id: "sess-2" }],
});
await renderLoaded();
fireEvent.click(screen.getByTestId("account-sessions-revoke"));
await waitFor(() =>
expect(screen.getByTestId("account-sessions-error").textContent).toContain("boom"),
);
});
it("shows the error toast and keeps the draft for a failed save", async () => {
settingsApiMocks.updateUserSettings.mockRejectedValue(new Error("boom"));
await renderLoaded();
await chooseDisplay(RELATIVE_OPTION);
await saveChanges();
// The draft stays visible while the write is in flight.
expect(screen.getByTestId(RELATIVE_TEST_ID)).toBeTruthy();
await waitFor(() => expect(toastMocks.error).toHaveBeenCalled());
// The failed draft remains available for a retry through Save changes.
expect(screen.getByTestId(RELATIVE_TEST_ID)).toBeTruthy();
expect(screen.getByTestId(SESSIONS_CARD_TEST_ID).getAttribute(DIRTY_ATTRIBUTE)).toBe(
DIRTY_TRUE,
);
});
it("keeps the optimistic value while a write is in flight even when the store re-asserts the baseline", async () => {
const { promise: pending, resolve: resolvePatch } = deferredPatch();
settingsApiMocks.updateUserSettings.mockReturnValue(pending);
const holder: StoreHolder = { current: null };
await renderLoaded({}, holder);
await chooseDisplay(RELATIVE_OPTION);
await saveChanges();
await waitFor(() => expect(screen.getByTestId(RELATIVE_TEST_ID)).toBeTruthy());
// An unrelated full snapshot re-asserts the baseline value while the write
// is still in flight; the optimistic override must survive and still render.
act(() => {
dispatchUserSettingsSnapshot(holder, { last_seen_display: "absolute", revision: 3 });
});
expect(screen.getByTestId(RELATIVE_TEST_ID)).toBeTruthy();
// The write settles: the confirmed relative value renders.
await act(async () => {
resolvePatch(patchResponse({ last_seen_display: "relative", revision: 4 }));
});
await waitFor(() => expect(screen.getByTestId(RELATIVE_TEST_ID)).toBeTruthy());
expect(holder.current!.getState().userSettings.lastSeenDisplay).toBe("relative");
});
});
describe("Last seen display queued write ordering", () => {
it("keeps a newer draft while an earlier save is in flight", async () => {
const { promise: pendingA, resolve: resolveA } = deferredPatch();
settingsApiMocks.updateUserSettings
.mockReturnValueOnce(pendingA)
.mockResolvedValueOnce(patchResponse({ last_seen_display: "absolute", revision: 3 }));
const holder: StoreHolder = { current: null };
await renderLoaded({}, holder);
await chooseDisplay(RELATIVE_OPTION);
await saveChanges();
expect(screen.getByTestId(RELATIVE_TEST_ID)).toBeTruthy();
await chooseDisplay(ABSOLUTE_OPTION);
expect(screen.queryByTestId(RELATIVE_TEST_ID)).toBeNull();
// A settles first. The newer local draft remains visible and dirty.
await act(async () => {
resolveA(patchResponse({ last_seen_display: "relative", revision: 2 }));
});
expect(screen.queryByTestId(RELATIVE_TEST_ID)).toBeNull();
expect(screen.getByTestId(SESSIONS_CARD_TEST_ID).getAttribute(DIRTY_ATTRIBUTE)).toBe(
DIRTY_TRUE,
);
await saveChanges();
await waitFor(() =>
expect(holder.current!.getState().userSettings.lastSeenDisplay).toBe("absolute"),
);
});
});
describe("Last seen display write and lifecycle races", () => {
it("discards a deferred PATCH response older than a newer WS snapshot", async () => {
const { promise: pending, resolve: resolvePatch } = deferredPatch();
settingsApiMocks.updateUserSettings.mockReturnValue(pending);
const holder: StoreHolder = { current: null };
await renderLoaded({}, holder);
await chooseDisplay(RELATIVE_OPTION);
await saveChanges();
// A newer WS snapshot (foreign change at rev 5) lands first through the
// real user.settings.updated handler.
act(() => {
dispatchUserSettingsSnapshot(holder, { last_seen_display: "absolute", revision: 5 });
});
// The deferred PATCH response (rev 2) is discarded by the revision guard;
// the cell renders the newer foreign value.
await act(async () => {
resolvePatch(patchResponse({ last_seen_display: "relative", revision: 2 }));
});
await waitFor(() => expect(screen.queryByTestId(RELATIVE_TEST_ID)).toBeNull());
expect(screen.getByText(formatDateTime(new Date(LAST_SEEN_AT)))).toBeTruthy();
expect(holder.current!.getState().userSettings.revision).toBe(5);
expect(holder.current!.getState().userSettings.lastSeenDisplay).toBe("absolute");
});
it("converges the store when the component unmounts mid-write and remounts", async () => {
const { promise: pending, resolve: resolvePatch } = deferredPatch();
settingsApiMocks.updateUserSettings.mockReturnValue(pending);
const holder: StoreHolder = { current: null };
const view = render(<RemountHarness holder={holder} mounted />);
await screen.findByTestId(SELECT_TEST_ID);
await chooseDisplay(RELATIVE_OPTION);
await saveChanges();
await waitFor(() => expect(screen.getByTestId(RELATIVE_TEST_ID)).toBeTruthy());
// Unmount only the component; the provider and store stay mounted. A newer
// WS snapshot lands after unmount through the real handler, then the PATCH
// resolves (its older revision is discarded), so the store converges.
view.rerender(<RemountHarness holder={holder} mounted={false} />);
act(() => {
dispatchUserSettingsSnapshot(holder, { last_seen_display: "absolute", revision: 5 });
});
await act(async () => {
resolvePatch(patchResponse({ last_seen_display: "relative", revision: 2 }));
});
await waitFor(() =>
expect(holder.current!.getState().userSettings.lastSeenDisplay).toBe("absolute"),
);
expect(holder.current!.getState().userSettings.revision).toBe(5);
// Re-mount the component under the SAME store: it must render the converged
// store value, not a seeded baseline.
view.rerender(<RemountHarness holder={holder} mounted />);
await screen.findByTestId(SELECT_TEST_ID);
expect(screen.queryByTestId(RELATIVE_TEST_ID)).toBeNull();
expect(screen.getByText(formatDateTime(new Date(LAST_SEEN_AT)))).toBeTruthy();
view.unmount();
});
it("handles a rejected write after unmount and remounts without a stale override", async () => {
const { promise: pending, reject: rejectPatch } = deferredPatch();
settingsApiMocks.updateUserSettings.mockReturnValue(pending);
const holder: StoreHolder = { current: null };
const view = render(<RemountHarness holder={holder} mounted />);
await screen.findByTestId(SELECT_TEST_ID);
await chooseDisplay(RELATIVE_OPTION);
await saveChanges();
await waitFor(() => expect(screen.getByTestId(RELATIVE_TEST_ID)).toBeTruthy());
// Unmount only the component; the provider and store stay mounted. A newer
// WS snapshot lands after unmount through the real handler, then the PATCH
// is rejected: the toast fires, the store keeps the WS value.
view.rerender(<RemountHarness holder={holder} mounted={false} />);
act(() => {
dispatchUserSettingsSnapshot(holder, { last_seen_display: "relative", revision: 3 });
});
await act(async () => {
rejectPatch(new ApiError("boom", 400, null));
});
await waitFor(() => expect(toastMocks.error).toHaveBeenCalled());
await waitFor(() =>
expect(holder.current!.getState().userSettings.lastSeenDisplay).toBe("relative"),
);
// Re-mount the component under the SAME store: it renders the converged WS
// value, with no stale optimistic override.
view.rerender(<RemountHarness holder={holder} mounted />);
await screen.findByTestId(SELECT_TEST_ID);
expect(screen.getByTestId(RELATIVE_TEST_ID)).toBeTruthy();
view.unmount();
});
it("ticks every second while the timestamp is under a minute old", async () => {
// last_seen 55s before now: "55 seconds ago", well past the just-now
// threshold so a one-second advance changes the label.
await renderRelativeWithFakeTime("2026-08-15T15:44:05Z");
const before = screen.getByTestId(RELATIVE_TEST_ID).textContent;
act(() => {
vi.advanceTimersByTime(1_000);
});
expect(screen.getByTestId(RELATIVE_TEST_ID).textContent).not.toBe(before);
});
it("ticks every minute once the timestamp is a minute or more old", async () => {
await renderRelativeWithFakeTime();
const before = screen.getByTestId(RELATIVE_TEST_ID).textContent;
act(() => {
vi.advanceTimersByTime(60_000);
});
expect(screen.getByTestId(RELATIVE_TEST_ID).textContent).not.toBe(before);
});
});
describe("Last seen display discovery", () => {
it("registers the sessions and last-seen-display targets", async () => {
const reveal = vi.fn();
authApiMocks.listSessions.mockResolvedValue({ sessions: [SESSION] });
render(
<StateProvider initialState={{ userSettings: makeUserSettings({ revision: 1 }) }}>
<SettingsSaveProvider>
<SettingsTargetProvider revealTarget={reveal}>
<TooltipProvider>
<SecuritySettings />
</TooltipProvider>
</SettingsTargetProvider>
</SettingsSaveProvider>
</StateProvider>,
);
await screen.findByTestId(SELECT_TEST_ID);
emitSettingsTargetRequest("setting-account-sessions");
await waitFor(() => expect(reveal).toHaveBeenCalledTimes(1));
emitSettingsTargetRequest("setting-account-last-seen-display");
await waitFor(() => expect(reveal).toHaveBeenCalledTimes(2));
});
});