rename web/ to admin/, along with the web-named build and cli identifiers
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import { fireEvent, render, screen, waitFor, 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";
|
||||
|
||||
const RESPONSES: Record<string, unknown> = {
|
||||
"/api/rules": {
|
||||
rules: [
|
||||
{
|
||||
id: 1,
|
||||
group_id: 1,
|
||||
group: "Default",
|
||||
pattern: "ads.example.com",
|
||||
kind: "exact",
|
||||
action: "block",
|
||||
created_at: 1700000000,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
group_id: 2,
|
||||
group: "Kids",
|
||||
pattern: "*.cdn.example.com",
|
||||
kind: "wildcard",
|
||||
action: "allow",
|
||||
created_at: 1700000100,
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
};
|
||||
|
||||
// The API orders groups by name, so the id-1 default is not always first.
|
||||
let groups: { id: number; name: string; safe_search: boolean }[];
|
||||
let deleted: string[];
|
||||
let posted: { pattern: string; kind: string }[];
|
||||
|
||||
function deleteCalls(): string[] {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
groups = [
|
||||
{ id: 1, name: "Default", safe_search: false },
|
||||
{ id: 2, name: "Kids", safe_search: true },
|
||||
];
|
||||
deleted = [];
|
||||
posted = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (init?.method === "DELETE") {
|
||||
deleted.push(url);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (url === "/api/rules" && init?.method === "POST") {
|
||||
posted.push(JSON.parse(String(init.body)) as { pattern: string; kind: string });
|
||||
return new Response(JSON.stringify({ error: "rate limited" }), {
|
||||
status: 429,
|
||||
headers: { "content-type": "application/json", "Retry-After": "5" },
|
||||
});
|
||||
}
|
||||
const payload = url === "/api/groups" ? { groups } : RESPONSES[url];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderRulesRoute() {
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/rules"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A RAC Select names its trigger with the current value and then the label, so
|
||||
* the label alone is a suffix match. Opening it is the only way to read the
|
||||
* options: there is no `<select>` carrying them any more.
|
||||
*/
|
||||
function trigger(label: string): HTMLElement {
|
||||
return screen.getByRole("button", { name: new RegExp(`${label}$`) });
|
||||
}
|
||||
|
||||
async function optionsOf(label: string): Promise<(string | null)[]> {
|
||||
fireEvent.click(trigger(label));
|
||||
const options = await screen.findAllByRole("option");
|
||||
const labels = options.map((option) => option.textContent);
|
||||
// Re-picking the current value closes the listbox and changes nothing.
|
||||
fireEvent.click(options.find((option) => option.getAttribute("aria-selected") === "true") ?? options[0]!);
|
||||
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
||||
return labels;
|
||||
}
|
||||
|
||||
test("renders the rule table and the create form with contract enums", async () => {
|
||||
renderRulesRoute();
|
||||
await screen.findByRole("heading", { name: "Rules" });
|
||||
|
||||
const table = within(screen.getByRole("table"));
|
||||
expect(table.getByText("ads.example.com")).toBeTruthy();
|
||||
expect(table.getByText("*.cdn.example.com")).toBeTruthy();
|
||||
expect(table.getByText("block")).toBeTruthy();
|
||||
expect(table.getByText("allow")).toBeTruthy();
|
||||
expect(table.getByText("Kids")).toBeTruthy();
|
||||
expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2);
|
||||
|
||||
expect(await optionsOf("Kind")).toEqual(["exact", "wildcard", "regex"]);
|
||||
expect(await optionsOf("Action")).toEqual(["allow", "block"]);
|
||||
expect(await optionsOf("Group")).toEqual(["Default", "Kids"]);
|
||||
});
|
||||
|
||||
test("the kind selector can select the regex option, not only list it", async () => {
|
||||
renderRulesRoute();
|
||||
await screen.findByRole("heading", { name: "Rules" });
|
||||
|
||||
fireEvent.click(trigger("Kind"));
|
||||
const options = await screen.findAllByRole("option");
|
||||
const regex = options.find((option) => option.textContent === "regex");
|
||||
expect(regex).toBeTruthy();
|
||||
fireEvent.click(regex!);
|
||||
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
||||
|
||||
expect(trigger("Kind").textContent).toContain("regex");
|
||||
});
|
||||
|
||||
async function selectKind(label: string): Promise<void> {
|
||||
fireEvent.click(trigger("Kind"));
|
||||
const options = await screen.findAllByRole("option");
|
||||
fireEvent.click(options.find((option) => option.textContent === label)!);
|
||||
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
||||
}
|
||||
|
||||
// A regex is stored and matched byte for byte, so whitespace inside it is data,
|
||||
// not slop the UI may drop. Exact and wildcard are normalized server-side.
|
||||
test("a regex pattern is posted untrimmed, an exact pattern is trimmed", async () => {
|
||||
renderRulesRoute();
|
||||
await screen.findByRole("heading", { name: "Rules" });
|
||||
|
||||
await selectKind("regex");
|
||||
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " foo|bar " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
|
||||
await waitFor(() => expect(posted).toHaveLength(1));
|
||||
expect(posted[0]).toMatchObject({ pattern: " foo|bar ", kind: "regex" });
|
||||
|
||||
await selectKind("exact");
|
||||
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " ads.example.net " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
|
||||
await waitFor(() => expect(posted).toHaveLength(2));
|
||||
expect(posted[1]).toMatchObject({ pattern: "ads.example.net", kind: "exact" });
|
||||
});
|
||||
|
||||
test("the pattern field opts out of mobile autocapitalize and autocorrect", async () => {
|
||||
renderRulesRoute();
|
||||
await screen.findByRole("heading", { name: "Rules" });
|
||||
|
||||
const input = screen.getByLabelText("Pattern");
|
||||
expect(input.getAttribute("autocapitalize")).toBe("none");
|
||||
expect(input.getAttribute("autocorrect")).toBe("off");
|
||||
expect(input.getAttribute("spellcheck")).toBe("false");
|
||||
});
|
||||
|
||||
test("rule create shows a countdown when rate limited with Retry-After", async () => {
|
||||
renderRulesRoute();
|
||||
await screen.findByRole("heading", { name: "Rules" });
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "ads.example.net" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 5s.");
|
||||
});
|
||||
|
||||
test("the group select preselects the id-1 default, not the alphabetically first group", async () => {
|
||||
groups = [
|
||||
{ id: 5, name: "Attic", safe_search: false },
|
||||
{ id: 1, name: "Default", safe_search: false },
|
||||
];
|
||||
renderRulesRoute();
|
||||
await screen.findByRole("heading", { name: "Rules" });
|
||||
|
||||
expect(await optionsOf("Group")).toEqual(["Attic", "Default"]);
|
||||
expect(trigger("Group").textContent).toContain("Default");
|
||||
});
|
||||
|
||||
test("the group select falls back to the first group when the default is absent", async () => {
|
||||
groups = [
|
||||
{ id: 5, name: "Attic", safe_search: false },
|
||||
{ id: 7, name: "Basement", safe_search: false },
|
||||
];
|
||||
renderRulesRoute();
|
||||
await screen.findByRole("heading", { name: "Rules" });
|
||||
|
||||
expect(trigger("Group").textContent).toContain("Attic");
|
||||
});
|
||||
|
||||
test("delete asks for confirmation, and cancelling sends no request", async () => {
|
||||
renderRulesRoute();
|
||||
await screen.findByRole("heading", { name: "Rules" });
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog.textContent).toContain('Delete the block rule for "ads.example.com"?');
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
expect(deleteCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
test("confirming the delete dialog issues the DELETE for that rule", async () => {
|
||||
renderRulesRoute();
|
||||
await screen.findByRole("heading", { name: "Rules" });
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
|
||||
await screen.findByRole("alertdialog");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => expect(deleteCalls()).toEqual(["/api/rules/2"]));
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries";
|
||||
import type { Rule, RuleAction, RuleKind } from "@/lib/types";
|
||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
{ value: "exact", label: "exact" },
|
||||
{ value: "wildcard", label: "wildcard" },
|
||||
{ value: "regex", label: "regex" },
|
||||
];
|
||||
|
||||
const ACTION_OPTIONS = [
|
||||
{ value: "allow", label: "allow" },
|
||||
{ value: "block", label: "block" },
|
||||
];
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
empty: {
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
minWidth: "max-content",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
pattern: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
allow: {
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
block: {
|
||||
color: colors.danger,
|
||||
},
|
||||
form: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
marginTop: "1.5rem",
|
||||
maxWidth: "36rem",
|
||||
},
|
||||
formHeading: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
fieldLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
/** One column on a phone, three from the `sm` breakpoint, as before. */
|
||||
fieldGrid: {
|
||||
display: "grid",
|
||||
gap: "0.75rem",
|
||||
gridTemplateColumns: {
|
||||
default: "repeat(1, minmax(0, 1fr))",
|
||||
"@media (min-width: 640px)": "repeat(3, minmax(0, 1fr))",
|
||||
},
|
||||
},
|
||||
submitRow: {
|
||||
display: "flex",
|
||||
},
|
||||
});
|
||||
|
||||
export default function RulesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: rules } = useSuspenseQuery(rulesQuery());
|
||||
const { data: groups } = useSuspenseQuery(groupsQuery());
|
||||
|
||||
const create = useMutation(ruleCreateMutation(queryClient));
|
||||
const remove = useMutation(ruleDeleteMutation(queryClient));
|
||||
|
||||
const [pattern, setPattern] = useState("");
|
||||
const [kind, setKind] = useState<RuleKind>("exact");
|
||||
const [action, setAction] = useState<RuleAction>("block");
|
||||
const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
|
||||
const [pendingDelete, setPendingDelete] = useState<Rule | null>(null);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
// A regex pattern is stored and matched byte for byte, so the UI must not
|
||||
// edit it: trimming here would make a UI-created rule differ from the same
|
||||
// bytes posted to /api/rules. Name-shaped kinds are normalized server-side,
|
||||
// so trimming them only spares a pasted space a 400.
|
||||
const sent = kind === "regex" ? pattern : pattern.trim();
|
||||
create.mutate({ group_id: groupId, pattern: sent, kind, action }, { onSuccess: () => setPattern("") });
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (pendingDelete === null) return;
|
||||
remove.mutate(pendingDelete.id);
|
||||
setPendingDelete(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Rules</h1>
|
||||
|
||||
{rules.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>No allow or block rules yet. Create one below.</p>
|
||||
) : (
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th {...stylex.props(shared.th)}>Pattern</th>
|
||||
<th {...stylex.props(shared.th)}>Kind</th>
|
||||
<th {...stylex.props(shared.th)}>Action</th>
|
||||
<th {...stylex.props(shared.th)}>Group</th>
|
||||
<th {...stylex.props(shared.th)}>Created</th>
|
||||
<th {...stylex.props(shared.th)}>
|
||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map((rule) => (
|
||||
<tr key={rule.id}>
|
||||
<td {...stylex.props(shared.td, styles.pattern)}>{rule.pattern}</td>
|
||||
<td {...stylex.props(shared.td)}>{rule.kind}</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<span {...stylex.props(rule.action === "allow" ? styles.allow : styles.block)}>
|
||||
{rule.action}
|
||||
</span>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>{rule.group}</td>
|
||||
<td {...stylex.props(shared.td)}>{formatTime(rule.created_at)}</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDelete(rule)}
|
||||
disabled={remove.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<InlineError error={remove.error} />
|
||||
|
||||
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
|
||||
<h2 {...stylex.props(styles.formHeading)}>Create rule</h2>
|
||||
<div>
|
||||
<label htmlFor="rule-pattern" {...stylex.props(styles.fieldLabel)}>
|
||||
Pattern
|
||||
</label>
|
||||
<input
|
||||
id="rule-pattern"
|
||||
type="text"
|
||||
required
|
||||
value={pattern}
|
||||
onChange={(event) => setPattern(event.target.value)}
|
||||
placeholder="ads.example.com, *.example.com or ^ad[0-9]+-"
|
||||
// A phone keyboard capitalizing the first letter is silent for
|
||||
// exact and wildcard (normalized server-side) but fatal for a
|
||||
// regex, which matches the lowercase query name byte for byte.
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div {...stylex.props(styles.fieldGrid)}>
|
||||
<Select
|
||||
label="Kind"
|
||||
value={kind}
|
||||
onChange={(value) => setKind(value as RuleKind)}
|
||||
options={KIND_OPTIONS}
|
||||
/>
|
||||
<Select
|
||||
label="Action"
|
||||
value={action}
|
||||
onChange={(value) => setAction(value as RuleAction)}
|
||||
options={ACTION_OPTIONS}
|
||||
/>
|
||||
<Select
|
||||
label="Group"
|
||||
value={String(groupId)}
|
||||
onChange={(value) => setGroupId(Number(value))}
|
||||
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
|
||||
/>
|
||||
</div>
|
||||
<div {...stylex.props(styles.submitRow)}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={create.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||
>
|
||||
{create.isPending ? "Creating…" : "Create rule"}
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={create.error} />
|
||||
</form>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={pendingDelete !== null}
|
||||
title="Delete rule"
|
||||
message={
|
||||
pendingDelete === null
|
||||
? ""
|
||||
: `Delete the ${pendingDelete.action} rule for "${pendingDelete.pattern}"?`
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user