milestone 20: declarative configuration for iac
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { useAuthority } from "./authority";
|
||||
|
||||
/**
|
||||
* File authority is a standing condition, not an event, so this banner has no
|
||||
* dismiss button: it stays up for as long as the process runs from a file.
|
||||
*/
|
||||
export default function ReadOnlyConfigBanner() {
|
||||
const authority = useAuthority();
|
||||
if (authority?.mode !== "managed_file") return null;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"
|
||||
>
|
||||
Configuration is managed by <code className="font-mono">{authority.path}</code>. Edit the file and restart
|
||||
nxdns to change it; the server rejects edits made here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { settingsPutMutation, settingsQuery } from "@/lib/queries";
|
||||
import { buildSettingsPatch } from "@/lib/settingsDiff";
|
||||
import type { Settings, SettingsPatch } from "@/lib/types";
|
||||
import { raiseRestartBanner } from "./restartBanner";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority";
|
||||
import { focusRing } from "@/ui/classes";
|
||||
|
||||
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
|
||||
@@ -242,7 +243,8 @@ export default function SettingsPage() {
|
||||
);
|
||||
});
|
||||
const patch = buildSettingsPatch(baseline, edited, password === "" ? undefined : password);
|
||||
const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending;
|
||||
const readOnly = useReadOnlyConfig();
|
||||
const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending || readOnly;
|
||||
|
||||
function setField(section: keyof Settings, key: string, value: unknown): void {
|
||||
setEdited((prev) => ({
|
||||
@@ -273,7 +275,7 @@ export default function SettingsPage() {
|
||||
Changes are validated as a whole; every setting requires a restart to take effect.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="mt-4 max-w-3xl">
|
||||
<fieldset disabled={mutation.isPending} className="space-y-6">
|
||||
<fieldset disabled={mutation.isPending || readOnly} className="space-y-6">
|
||||
{SECTIONS.map(({ section, title, fields }) => (
|
||||
<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>
|
||||
@@ -339,6 +341,7 @@ export default function SettingsPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saveDisabled}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`rounded bg-blue-600 px-4 py-1.5 text-sm font-medium text-white ${focusRing} disabled:bg-zinc-300 disabled:text-zinc-500 dark:disabled:bg-zinc-800`}
|
||||
>
|
||||
{mutation.isPending ? "Saving…" : "Save"}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import type { Authority, Settings, SettingsEnvelope } from "@/lib/types";
|
||||
|
||||
// One file for the whole file-mode sweep: the settings envelope is the only
|
||||
// discovery mechanism, so every page test needs the same stubbed envelope.
|
||||
|
||||
const CONFIG_PATH = "/etc/nxdns/config.zon";
|
||||
|
||||
const DATABASE: Authority = { mode: "database", path: null, reconciled_at: null };
|
||||
const MANAGED_FILE: Authority = { mode: "managed_file", path: CONFIG_PATH, reconciled_at: 1754899200 };
|
||||
|
||||
function baseSettings(): Settings {
|
||||
return {
|
||||
upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 },
|
||||
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
|
||||
blocking: { response: "zero", ttl: 300 },
|
||||
cache: { size: 10000, negative_ttl_max: 300 },
|
||||
web: {
|
||||
enabled: true,
|
||||
bind: "127.0.0.1",
|
||||
port: 8080,
|
||||
session_ttl_hours: 24,
|
||||
api_rate_limit_per_min: 60,
|
||||
api_localhost_exempt: true,
|
||||
sse_max_connections_per_ip: 2,
|
||||
trusted_proxies: "",
|
||||
auth_enabled: true,
|
||||
},
|
||||
doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
|
||||
dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" },
|
||||
edns: { ecs_mode: "strip" },
|
||||
logging: {
|
||||
level: "info",
|
||||
retention_days: 30,
|
||||
query_log_buffer_max: 10000,
|
||||
hide_domains: false,
|
||||
hide_client_ips: false,
|
||||
output: "stderr",
|
||||
file_path: "",
|
||||
max_size_mb: 50,
|
||||
max_files: 3,
|
||||
},
|
||||
disk: { min_free_mb: 100, warn_free_mb: 500 },
|
||||
blocklist_update: { enabled: true, interval_hours: 24 },
|
||||
};
|
||||
}
|
||||
|
||||
function envelope(authority: Authority): SettingsEnvelope {
|
||||
return { settings: baseSettings(), restart_required: [], authority };
|
||||
}
|
||||
|
||||
const GROUPS = {
|
||||
groups: [
|
||||
{ id: 1, name: "default", safe_search: false },
|
||||
{ id: 2, name: "kids", safe_search: true },
|
||||
],
|
||||
};
|
||||
|
||||
const BLOCKLISTS = {
|
||||
blocklists: [
|
||||
{
|
||||
id: 1,
|
||||
url: "https://example.com/ads.txt",
|
||||
name: "Ads",
|
||||
enabled: true,
|
||||
is_suggested: false,
|
||||
last_updated: null,
|
||||
domain_count: 100,
|
||||
wildcard_count: 0,
|
||||
skipped_regex_count: 0,
|
||||
checksum: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const RULES = {
|
||||
rules: [
|
||||
{
|
||||
id: 1,
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
pattern: "ads.example.com",
|
||||
kind: "exact",
|
||||
action: "block",
|
||||
created_at: 1700000000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const CLIENTS = {
|
||||
clients: [
|
||||
{
|
||||
id: 1,
|
||||
ip: "192.168.1.10",
|
||||
name: "laptop",
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
hand_edited: true,
|
||||
first_seen: 1700000000,
|
||||
last_seen: 1700003600,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
ip: "192.168.1.11",
|
||||
name: "",
|
||||
group_id: 2,
|
||||
group: "kids",
|
||||
hand_edited: false,
|
||||
first_seen: 1700000000,
|
||||
last_seen: 1700007200,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const PREFIXES = {
|
||||
client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }],
|
||||
};
|
||||
|
||||
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||
|
||||
const BASE: Record<string, unknown> = {
|
||||
"GET /api/version": VERSION,
|
||||
"GET /api/groups": GROUPS,
|
||||
"GET /api/blocklists": BLOCKLISTS,
|
||||
"GET /api/rules": RULES,
|
||||
"GET /api/clients": CLIENTS,
|
||||
"GET /api/client-prefixes": PREFIXES,
|
||||
};
|
||||
|
||||
function stubFetch(map: Record<string, unknown>) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const key = `${init?.method ?? "GET"} ${String(input)}`;
|
||||
const payload = map[key];
|
||||
if (payload === undefined) {
|
||||
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
|
||||
}
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function renderAt(route: string, heading: string, authority: Authority) {
|
||||
stubFetch({ ...BASE, "GET /api/settings": envelope(authority) });
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: [route] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
await screen.findByRole("heading", { name: heading });
|
||||
}
|
||||
|
||||
function button(name: string): HTMLButtonElement {
|
||||
return screen.getByRole("button", { name }) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function clientRow(ip: string): HTMLElement {
|
||||
const row = screen.getByText(ip).closest("tr");
|
||||
if (row === null) throw new Error(`no client row for ${ip}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("the banner names the managed file in file mode", async () => {
|
||||
await renderAt("/rules", "Rules", MANAGED_FILE);
|
||||
|
||||
const banner = await screen.findByText(/configuration is managed by/i);
|
||||
expect(banner.textContent).toContain(CONFIG_PATH);
|
||||
expect(banner.textContent).toMatch(/restart/i);
|
||||
expect(banner.closest('[role="status"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the banner is absent in database mode", async () => {
|
||||
await renderAt("/rules", "Rules", DATABASE);
|
||||
|
||||
await screen.findByRole("button", { name: "Create rule" });
|
||||
expect(screen.queryByText(/configuration is managed by/i)).toBeNull();
|
||||
});
|
||||
|
||||
function kidsRow(): HTMLElement {
|
||||
const row = screen.getByText("kids").closest("li");
|
||||
if (row === null) throw new Error("no row for group kids");
|
||||
return row;
|
||||
}
|
||||
|
||||
test("file mode disables the Groups create and delete controls", async () => {
|
||||
await renderAt("/groups", "Groups", MANAGED_FILE);
|
||||
await screen.findByText(/configuration is managed by/i);
|
||||
|
||||
expect(button("Create").disabled).toBe(true);
|
||||
expect((within(kidsRow()).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((within(kidsRow()).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("database mode leaves the Groups create and delete controls enabled", async () => {
|
||||
await renderAt("/groups", "Groups", DATABASE);
|
||||
await screen.findByRole("button", { name: "Create" });
|
||||
|
||||
expect(button("Create").disabled).toBe(false);
|
||||
expect((within(kidsRow()).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((within(kidsRow()).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("file mode disables the Rules create and delete controls", async () => {
|
||||
await renderAt("/rules", "Rules", MANAGED_FILE);
|
||||
await screen.findByText(/configuration is managed by/i);
|
||||
|
||||
expect(button("Create rule").disabled).toBe(true);
|
||||
expect(button("Delete").disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("database mode leaves the Rules create and delete controls enabled", async () => {
|
||||
await renderAt("/rules", "Rules", DATABASE);
|
||||
await screen.findByRole("button", { name: "Create rule" });
|
||||
|
||||
expect(button("Create rule").disabled).toBe(false);
|
||||
expect(button("Delete").disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("file mode keeps delete live for an observed client and blocks it for a declared one", async () => {
|
||||
await renderAt("/clients", "Clients", MANAGED_FILE);
|
||||
await screen.findByText(/configuration is managed by/i);
|
||||
|
||||
const declared = clientRow("192.168.1.10");
|
||||
const observed = clientRow("192.168.1.11");
|
||||
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((within(observed).getByRole("button", { name: "Edit" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("file mode leaves the blocklist refresh button enabled", async () => {
|
||||
await renderAt("/blocklists", "Blocklists", MANAGED_FILE);
|
||||
await screen.findByText(/configuration is managed by/i);
|
||||
|
||||
expect(button("Update now").disabled).toBe(false);
|
||||
expect(button("Add source").disabled).toBe(true);
|
||||
expect((screen.getByRole("checkbox", { name: "Ads enabled" }) as HTMLInputElement).disabled).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { settingsQuery } from "@/lib/queries";
|
||||
import type { Authority } from "@/lib/types";
|
||||
|
||||
/** The one-line explanation on every control file authority takes away. */
|
||||
export const READ_ONLY_HINT = "Configuration is managed by a file; edit the file and restart nxdns.";
|
||||
|
||||
/**
|
||||
* The running server's configuration authority, read from the settings
|
||||
* envelope — the only route that carries it. `undefined` until that query
|
||||
* resolves. Every page may call this: it is the shared `["settings"]` key, so
|
||||
* the shell's own subscription serves them all from cache.
|
||||
*/
|
||||
export function useAuthority(): Authority | undefined {
|
||||
return useQuery(settingsQuery()).data?.authority;
|
||||
}
|
||||
|
||||
/**
|
||||
* True only once the server has said a file owns the configuration. While the
|
||||
* mode is unknown nothing is disabled — the 403 is the enforcement, this is
|
||||
* the courtesy.
|
||||
*/
|
||||
export function useReadOnlyConfig(): boolean {
|
||||
return useAuthority()?.mode === "managed_file";
|
||||
}
|
||||
Reference in New Issue
Block a user