milestone 19: hygiene sweep - dead ecs surface, single-source constants, tls classification, frontend state hazards, docker smoke network fix
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { Suspense } from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act } from "react";
|
||||
import SettingsPage, { patchRequiresRestart } from "@/features/settings/SettingsPage";
|
||||
import RestartBanner from "@/features/settings/RestartBanner";
|
||||
import { dismissRestartBanner } from "@/features/settings/restartBanner";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { queryKeys } from "@/lib/queries";
|
||||
import type { Settings, SettingsPatch } from "@/lib/types";
|
||||
|
||||
function baseSettings(): Settings {
|
||||
@@ -88,9 +89,10 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function renderPage() {
|
||||
async function renderPage(): Promise<QueryClient> {
|
||||
const queryClient = createQueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RestartBanner />
|
||||
<Suspense fallback={<p>loading</p>}>
|
||||
<SettingsPage />
|
||||
@@ -98,6 +100,7 @@ async function renderPage() {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByRole("heading", { name: "Settings" });
|
||||
return queryClient;
|
||||
}
|
||||
|
||||
function saveButton(): HTMLButtonElement {
|
||||
@@ -232,3 +235,39 @@ test("patchRequiresRestart ignores only a bare web.password", () => {
|
||||
expect(patchRequiresRestart({ dns: { port: 5353 } })).toBe(true);
|
||||
expect(patchRequiresRestart({ web: { password: "x" }, cache: { size: 1 } })).toBe(true);
|
||||
});
|
||||
|
||||
test("a background refetch does not turn out-of-band changes into phantom patch entries", async () => {
|
||||
const queryClient = await renderPage();
|
||||
const dns = screen.getByRole("group", { name: "DNS" });
|
||||
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5353" } });
|
||||
|
||||
// Someone else changes cache.size; a background refetch brings it in. The
|
||||
// derived auth_enabled line is read straight from the query data, so it
|
||||
// witnesses that the refetch reached the component.
|
||||
storedSettings.cache.size = 99999;
|
||||
storedSettings.web.auth_enabled = false;
|
||||
await act(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.settings });
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText(/auth_enabled: false/)).toBeTruthy());
|
||||
const cache = screen.getByRole("group", { name: "Cache" });
|
||||
expect((within(cache).getByLabelText("size") as HTMLInputElement).value).toBe("10000");
|
||||
|
||||
fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||
expect(putBodies[0]).toEqual({ dns: { port: 5353 } });
|
||||
});
|
||||
|
||||
test("saving re-freezes the baseline, so the next diff starts from the server echo", async () => {
|
||||
await renderPage();
|
||||
const dns = screen.getByRole("group", { name: "DNS" });
|
||||
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5353" } });
|
||||
fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||
await waitFor(() => expect(saveButton().disabled).toBe(true));
|
||||
|
||||
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5454" } });
|
||||
fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(putBodies).toHaveLength(2));
|
||||
expect(putBodies[1]).toEqual({ dns: { port: 5454 } });
|
||||
});
|
||||
|
||||
@@ -10,22 +10,40 @@ import { focusRing } from "@/ui/classes";
|
||||
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
|
||||
export function patchRequiresRestart(patch: SettingsPatch): boolean {
|
||||
return Object.entries(patch).some(([section, fields]) =>
|
||||
Object.keys(fields as Record<string, unknown>).some((key) => !(section === "web" && key === "password")),
|
||||
Object.keys(fields ?? {}).some((key) => !(section === "web" && key === "password")),
|
||||
);
|
||||
}
|
||||
|
||||
interface FieldDef {
|
||||
key: string;
|
||||
interface FieldDef<S extends keyof Settings> {
|
||||
key: keyof Settings[S] & string;
|
||||
kind: "number" | "text" | "boolean" | readonly string[];
|
||||
}
|
||||
|
||||
interface SectionDef {
|
||||
section: keyof Settings;
|
||||
interface SectionDef<S extends keyof Settings> {
|
||||
section: S;
|
||||
title: string;
|
||||
fields: readonly FieldDef[];
|
||||
fields: readonly FieldDef<S>[];
|
||||
}
|
||||
|
||||
const TLS_FIELDS: readonly FieldDef[] = [
|
||||
/** Binds each section's field keys to that section's Settings type at definition. */
|
||||
function defineSection<S extends keyof Settings>(def: SectionDef<S>): SectionDef<S> {
|
||||
return def;
|
||||
}
|
||||
|
||||
/** The registry read back as a heterogeneous list, once the per-section binding has been proven. */
|
||||
type AnyFieldDef = { [S in keyof Settings]: FieldDef<S> }[keyof Settings];
|
||||
type AnySectionDef = { [S in keyof Settings]: SectionDef<S> }[keyof Settings];
|
||||
|
||||
/**
|
||||
* A section's values as a string-keyed view. The keys are proven against
|
||||
* `Settings[S]` where each section is defined; iterating the heterogeneous
|
||||
* registry loses that correlation, so consumption widens here in one place.
|
||||
*/
|
||||
function sectionValues(settings: Settings, section: keyof Settings): Record<string, unknown> {
|
||||
return settings[section] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const TLS_FIELDS: readonly FieldDef<"doh_server" | "dot_server">[] = [
|
||||
{ key: "enabled", kind: "boolean" },
|
||||
{ key: "bind", kind: "text" },
|
||||
{ key: "port", kind: "number" },
|
||||
@@ -33,8 +51,8 @@ const TLS_FIELDS: readonly FieldDef[] = [
|
||||
{ key: "key_path", kind: "text" },
|
||||
];
|
||||
|
||||
const SECTIONS: readonly SectionDef[] = [
|
||||
{
|
||||
const SECTIONS: readonly AnySectionDef[] = [
|
||||
defineSection({
|
||||
section: "upstream",
|
||||
title: "Upstream",
|
||||
fields: [
|
||||
@@ -42,8 +60,8 @@ const SECTIONS: readonly SectionDef[] = [
|
||||
{ key: "read_timeout_ms", kind: "number" },
|
||||
{ key: "total_timeout_ms", kind: "number" },
|
||||
],
|
||||
},
|
||||
{
|
||||
}),
|
||||
defineSection({
|
||||
section: "dns",
|
||||
title: "DNS",
|
||||
fields: [
|
||||
@@ -53,24 +71,24 @@ const SECTIONS: readonly SectionDef[] = [
|
||||
{ key: "rate_limit", kind: "number" },
|
||||
{ key: "rate_window_seconds", kind: "number" },
|
||||
],
|
||||
},
|
||||
{
|
||||
}),
|
||||
defineSection({
|
||||
section: "blocking",
|
||||
title: "Blocking",
|
||||
fields: [
|
||||
{ key: "response", kind: ["zero", "nxdomain"] },
|
||||
{ key: "ttl", kind: "number" },
|
||||
],
|
||||
},
|
||||
{
|
||||
}),
|
||||
defineSection({
|
||||
section: "cache",
|
||||
title: "Cache",
|
||||
fields: [
|
||||
{ key: "size", kind: "number" },
|
||||
{ key: "negative_ttl_max", kind: "number" },
|
||||
],
|
||||
},
|
||||
{
|
||||
}),
|
||||
defineSection({
|
||||
section: "web",
|
||||
title: "Web",
|
||||
fields: [
|
||||
@@ -83,11 +101,11 @@ const SECTIONS: readonly SectionDef[] = [
|
||||
{ key: "sse_max_connections_per_ip", kind: "number" },
|
||||
{ key: "trusted_proxies", kind: "text" },
|
||||
],
|
||||
},
|
||||
{ section: "doh_server", title: "DoH Server", fields: TLS_FIELDS },
|
||||
{ section: "dot_server", title: "DoT Server", fields: TLS_FIELDS },
|
||||
{ section: "edns", title: "EDNS", fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }] },
|
||||
{
|
||||
}),
|
||||
defineSection({ section: "doh_server", title: "DoH Server", fields: TLS_FIELDS }),
|
||||
defineSection({ section: "dot_server", title: "DoT Server", fields: TLS_FIELDS }),
|
||||
defineSection({ section: "edns", title: "EDNS", fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }] }),
|
||||
defineSection({
|
||||
section: "logging",
|
||||
title: "Logging",
|
||||
fields: [
|
||||
@@ -101,23 +119,23 @@ const SECTIONS: readonly SectionDef[] = [
|
||||
{ key: "max_size_mb", kind: "number" },
|
||||
{ key: "max_files", kind: "number" },
|
||||
],
|
||||
},
|
||||
{
|
||||
}),
|
||||
defineSection({
|
||||
section: "disk",
|
||||
title: "Disk",
|
||||
fields: [
|
||||
{ key: "min_free_mb", kind: "number" },
|
||||
{ key: "warn_free_mb", kind: "number" },
|
||||
],
|
||||
},
|
||||
{
|
||||
}),
|
||||
defineSection({
|
||||
section: "blocklist_update",
|
||||
title: "Blocklist Update",
|
||||
fields: [
|
||||
{ key: "enabled", kind: "boolean" },
|
||||
{ key: "interval_hours", kind: "number" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const LABEL_CLASS = "text-sm text-zinc-700 dark:text-zinc-300";
|
||||
@@ -130,7 +148,7 @@ function FieldRow({
|
||||
onChange,
|
||||
}: {
|
||||
section: string;
|
||||
def: FieldDef;
|
||||
def: AnyFieldDef;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
@@ -209,23 +227,27 @@ export default function SettingsPage() {
|
||||
const { data } = useSuspenseQuery(settingsQuery());
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation(settingsPutMutation(queryClient));
|
||||
// Frozen at mount and re-frozen on save: diffing against live query data would
|
||||
// turn a background refetch's out-of-band changes into phantom user edits.
|
||||
const [baseline, setBaseline] = useState<Settings>(() => structuredClone(data.settings));
|
||||
const [edited, setEdited] = useState<Settings>(() => structuredClone(data.settings));
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
|
||||
const passwordsMismatch = (password !== "" || confirm !== "") && password !== confirm;
|
||||
const hasInvalidNumber = SECTIONS.some(({ section, fields }) =>
|
||||
fields.some(
|
||||
(field) => field.kind === "number" && Number.isNaN((edited[section] as Record<string, unknown>)[field.key]),
|
||||
),
|
||||
);
|
||||
const patch = buildSettingsPatch(data.settings, edited, password === "" ? undefined : password);
|
||||
const hasInvalidNumber = SECTIONS.some(({ section, fields }) => {
|
||||
const values = sectionValues(edited, section);
|
||||
return (fields as readonly AnyFieldDef[]).some(
|
||||
(field) => field.kind === "number" && Number.isNaN(values[field.key]),
|
||||
);
|
||||
});
|
||||
const patch = buildSettingsPatch(baseline, edited, password === "" ? undefined : password);
|
||||
const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending;
|
||||
|
||||
function setField(section: keyof Settings, key: string, value: unknown): void {
|
||||
setEdited((prev) => ({
|
||||
...prev,
|
||||
[section]: { ...(prev[section] as Record<string, unknown>), [key]: value },
|
||||
[section]: { ...sectionValues(prev, section), [key]: value },
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -235,6 +257,7 @@ export default function SettingsPage() {
|
||||
const restartNeeded = patchRequiresRestart(patch);
|
||||
mutation.mutate(patch, {
|
||||
onSuccess: (envelope) => {
|
||||
setBaseline(structuredClone(envelope.settings));
|
||||
setEdited(structuredClone(envelope.settings));
|
||||
setPassword("");
|
||||
setConfirm("");
|
||||
@@ -255,12 +278,12 @@ export default function SettingsPage() {
|
||||
<fieldset key={section} className="rounded border border-zinc-200 p-4 dark:border-zinc-800">
|
||||
<legend className="px-1 text-sm font-semibold">{title}</legend>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{fields.map((def) => (
|
||||
{(fields as readonly AnyFieldDef[]).map((def) => (
|
||||
<FieldRow
|
||||
key={def.key}
|
||||
section={section}
|
||||
def={def}
|
||||
value={(edited[section] as Record<string, unknown>)[def.key]}
|
||||
value={sectionValues(edited, section)[def.key]}
|
||||
onChange={(value) => setField(section, def.key, value)}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user