milestone 33: contract closure — samples, file-authority enumeration, dead code, bundle ceiling
Gates / frontend (push) Successful in 1m34s
Gates / test (push) Successful in 2m3s
Gates / test-aarch64 (push) Failing after 3h13m33s
Gates / package (push) Successful in 5m20s
Gates / container (push) Successful in 15s
CI / gates (push) Failing after 6h30m45s

This commit is contained in:
2026-08-22 23:31:37 +02:00
parent 5da4652e89
commit cc23c97218
49 changed files with 397 additions and 113 deletions
+1 -1
View File
@@ -320,7 +320,7 @@ fn refilled(bucket: Bucket, now: std.Io.Timestamp, capacity: u64) Refill {
/// 127.0.0.0/8 and ::1, the addresses a request from the box itself carries.
/// An IPv4-mapped loopback address has already normalized to `.ip4` by the time
/// a `NetAddress` exists (`address.zig:51`).
pub fn isLoopback(addr: address.NetAddress) bool {
fn isLoopback(addr: address.NetAddress) bool {
return switch (addr) {
.ip4 => |b| b[0] == 127,
.ip6 => |b| std.mem.eql(u8, &b, &[_]u8{0} ** 15 ++ [_]u8{1}),
+1 -1
View File
@@ -91,7 +91,7 @@ fn confirmLogin(
/// Ends the session the cookie names. An unknown cookie is not an error: the
/// point of logging out is to end up logged out, which is where it already is.
pub fn applyLogout(state: *server.WebState, io: std.Io, cookie_header: []const u8) bool {
fn applyLogout(state: *server.WebState, io: std.Io, cookie_header: []const u8) bool {
const sessions = state.sessions orelse return false;
const value = http_util.cookieValue(cookie_header, auth.cookie_name) orelse return false;
return sessions.logout(io, value);
+4 -4
View File
@@ -85,7 +85,7 @@ pub const StatusView = struct {
// decisions
// ---------------------------------------------------------------------------
pub fn applyCreate(
fn applyCreate(
state: *server.WebState,
io: std.Io,
arena: Allocator,
@@ -103,7 +103,7 @@ pub fn applyCreate(
return .{ .id = id };
}
pub fn applyUpdate(
fn applyUpdate(
state: *server.WebState,
io: std.Io,
arena: Allocator,
@@ -121,7 +121,7 @@ pub fn applyUpdate(
return mutations.reload(state, io);
}
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
@@ -158,7 +158,7 @@ fn pruneFiles(state: *server.WebState, io: std.Io) void {
/// `refreshAll` already ends in the manager's own reload; the seam is called
/// too, because it is how the composition root learns that a change landed and
/// the only reload a test can observe.
pub fn applyRefresh(state: *server.WebState, io: std.Io, out: []manager_mod.SourceStatus) union(enum) {
fn applyRefresh(state: *server.WebState, io: std.Io, out: []manager_mod.SourceStatus) union(enum) {
statuses: usize,
fail: Failure,
} {
+3 -3
View File
@@ -56,7 +56,7 @@ const PrefixesBody = struct {
// decisions
// ---------------------------------------------------------------------------
pub fn applyUpdate(
fn applyUpdate(
state: *server.WebState,
io: std.Io,
id: i64,
@@ -72,7 +72,7 @@ pub fn applyUpdate(
return mutations.reload(state, io);
}
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
@@ -83,7 +83,7 @@ pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
return mutations.reload(state, io);
}
pub fn applyReplacePrefixes(
fn applyReplacePrefixes(
state: *server.WebState,
io: std.Io,
arena: Allocator,
+4 -4
View File
@@ -48,7 +48,7 @@ const Created = union(enum) { id: i64, fail: Failure };
// decisions
// ---------------------------------------------------------------------------
pub fn applyCreate(
fn applyCreate(
state: *server.WebState,
io: std.Io,
arena: Allocator,
@@ -68,7 +68,7 @@ pub fn applyCreate(
return .{ .id = id };
}
pub fn applyUpdate(
fn applyUpdate(
state: *server.WebState,
io: std.Io,
arena: Allocator,
@@ -105,7 +105,7 @@ fn updateLocked(database: *db.Db, arena: Allocator, id: i64, item: model.Group)
return null;
}
pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
@@ -133,7 +133,7 @@ fn deleteLocked(database: *db.Db, arena: Allocator, id: i64) ?Failure {
return null;
}
pub fn applySetSources(
fn applySetSources(
state: *server.WebState,
io: std.Io,
id: i64,
+3 -3
View File
@@ -150,7 +150,7 @@ pub fn protection(input: Input) Protection {
};
}
pub fn queryHistoryState(input: Input) []const u8 {
fn queryHistoryState(input: Input) []const u8 {
if (input.writer_failed) return query_history_failed;
if (input.gate_episode == .losing) return query_history_losing;
return query_history_recording;
@@ -159,13 +159,13 @@ pub fn queryHistoryState(input: Input) []const u8 {
/// The operational log is not recording — either the store never opened or its
/// writes are failing. Both mean the same thing to an operator: the record of
/// what went wrong is not being kept.
pub fn diagnosticsUnavailable(input: Input) bool {
fn diagnosticsUnavailable(input: Input) bool {
return !input.diagnostics_present or input.diagnostics_write_failed;
}
/// `warn` reads as a log level rather than as a quantity of disk. The monitor
/// keeps its own name; the wire says what an operator sees on the page.
pub fn diskState(state: disk_monitor.State) []const u8 {
fn diskState(state: disk_monitor.State) []const u8 {
return switch (state) {
.ok => disk_ok,
.warn => disk_low,
+1 -1
View File
@@ -47,7 +47,7 @@ pub fn view(entry: *const sse.Entry) EventView {
/// One `event: query` frame. JSON never contains a raw newline, so the whole
/// payload is a single `data:` line.
pub fn writeEvent(w: *std.Io.Writer, entry: *const sse.Entry) std.Io.Writer.Error!void {
fn writeEvent(w: *std.Io.Writer, entry: *const sse.Entry) std.Io.Writer.Error!void {
try w.writeAll("event: query\ndata: ");
var stringify: std.json.Stringify = .{ .writer = w };
try stringify.write(view(entry));
+6 -6
View File
@@ -88,7 +88,7 @@ fn publish(state: *server.WebState, io: std.Io, arena: Allocator, database: *@im
return mutations.reload(state, io);
}
pub fn applyCreateRecord(
fn applyCreateRecord(
state: *server.WebState,
io: std.Io,
arena: Allocator,
@@ -106,7 +106,7 @@ pub fn applyCreateRecord(
return .{ .id = id };
}
pub fn applyUpdateRecord(
fn applyUpdateRecord(
state: *server.WebState,
io: std.Io,
arena: Allocator,
@@ -124,7 +124,7 @@ pub fn applyUpdateRecord(
return publish(state, io, arena, database);
}
pub fn applyDeleteRecord(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
fn applyDeleteRecord(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
@@ -139,7 +139,7 @@ pub fn applyDeleteRecord(state: *server.WebState, io: std.Io, arena: Allocator,
// forward zones: decisions
// ---------------------------------------------------------------------------
pub fn applyCreateZone(
fn applyCreateZone(
state: *server.WebState,
io: std.Io,
arena: Allocator,
@@ -157,7 +157,7 @@ pub fn applyCreateZone(
return .{ .id = id };
}
pub fn applyUpdateZone(
fn applyUpdateZone(
state: *server.WebState,
io: std.Io,
arena: Allocator,
@@ -175,7 +175,7 @@ pub fn applyUpdateZone(
return publish(state, io, arena, database);
}
pub fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
-7
View File
@@ -390,12 +390,6 @@ pub fn checkSource(arena: Allocator, source: model.BlocklistSource) error{OutOfM
return firstProblem(arena, cfg);
}
pub fn checkClientIp(arena: Allocator, ip: []const u8) error{OutOfMemory}!?[]const u8 {
var cfg = skeleton(&default_groups);
cfg.clients = &.{.{ .ip = ip, .group = skeleton_group }};
return firstProblem(arena, cfg);
}
pub fn checkClientPrefix(arena: Allocator, prefix: []const u8, priority: i32) error{OutOfMemory}!?[]const u8 {
var cfg = skeleton(&default_groups);
cfg.client_prefixes = &.{.{ .prefix = prefix, .group = skeleton_group, .priority = priority }};
@@ -625,7 +619,6 @@ test "a valid candidate row reports no problem" {
try checkForwardZone(arena, .{ .zone = "lan", .resolver = "udp://10.0.0.1:53" }),
);
try testing.expectEqual(@as(?[]const u8, null), try checkRule(arena, "*.ads.example", .wildcard));
try testing.expectEqual(@as(?[]const u8, null), try checkClientIp(arena, "192.168.1.10"));
try testing.expectEqual(@as(?[]const u8, null), try checkClientPrefix(arena, "192.168.1.0/24", 100));
try testing.expectEqual(@as(?[]const u8, null), try checkGroupName(arena, "kids"));
try testing.expectEqual(@as(?[]const u8, null), try checkGroupName(arena, "default"));
+1 -1
View File
@@ -43,7 +43,7 @@ pub fn view(pause: *const pause_mod.Pause, now_s: i64) View {
return .{ .paused = true, .until = until };
}
pub fn apply(
fn apply(
state: *server.WebState,
io: std.Io,
body: Body,
+3 -3
View File
@@ -54,7 +54,7 @@ fn toInput(body: Body) union(enum) { input: rules_repo.RuleInput, fail: Failure
// decisions
// ---------------------------------------------------------------------------
pub fn applyCreate(
fn applyCreate(
state: *server.WebState,
io: std.Io,
arena: Allocator,
@@ -74,7 +74,7 @@ pub fn applyCreate(
return .{ .id = id };
}
pub fn applyUpdate(
fn applyUpdate(
state: *server.WebState,
io: std.Io,
arena: Allocator,
@@ -94,7 +94,7 @@ pub fn applyUpdate(
return mutations.reload(state, io);
}
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
+1 -1
View File
@@ -309,7 +309,7 @@ pub fn view(cfg: model.Config) View {
/// Reads, merges, validates, writes, and — when the password changed — ends
/// every session. Returns the configuration as it now stands.
pub fn applyPut(
fn applyPut(
state: *server.WebState,
io: std.Io,
arena: Allocator,
+1 -1
View File
@@ -385,7 +385,7 @@ pub const PeriodError = error{BadPeriod};
/// An absent `period` is the default; anything else it cannot read is a 400,
/// never a silent fallback — a typo must not return a window nobody asked for.
pub fn periodParam(query: []const u8) PeriodError!Period {
fn periodParam(query: []const u8) PeriodError!Period {
var buf: [8]u8 = undefined;
const found = http_util.queryValue(query, "period", &buf) catch return error.BadPeriod;
const text = found orelse return default_period;
+3 -3
View File
@@ -38,7 +38,7 @@ const Created = union(enum) { id: i64, fail: Failure };
// decisions
// ---------------------------------------------------------------------------
pub fn applyCreate(
fn applyCreate(
state: *server.WebState,
io: std.Io,
arena: Allocator,
@@ -59,7 +59,7 @@ pub fn applyCreate(
return .{ .id = id };
}
pub fn applyUpdate(
fn applyUpdate(
state: *server.WebState,
io: std.Io,
arena: Allocator,
@@ -94,7 +94,7 @@ pub fn applyUpdate(
/// The last enabled upstream cannot go: a resolver with nowhere to forward to
/// answers nothing, and `validate.validate` refuses that configuration at
/// startup — so allowing it here would only produce a box that will not boot.
pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
+9 -25
View File
@@ -266,9 +266,12 @@ paths:
summary: Live query stream (server-sent events)
description: |
`text/event-stream`. The stream opens with `retry: 3000`, then sends
one `event: query` frame per resolved query whose `data:` line is a
`Provenance` object — the body of `/api/queries/{id}` without its `id`,
which does not exist yet because the entry precedes its own insert.
one `event: query` frame per resolved query. The `data:` line is one
query fully explained, in the order a query meets the pipeline: the six
objects `QueryDetail` documents — `request`, `group`, `policy`,
`rewrites`, `route`, `response` — all of them required, and without
that schema's `id`, which does not exist yet because the entry precedes
its own insert.
A `: ping` comment goes out every 15 seconds.
A client that falls more than 64 events behind is disconnected and
should re-sync via `/api/queries` after reconnecting. Connections
@@ -2166,31 +2169,12 @@ components:
type: integer
nullable: true
Provenance:
type: object
description: |
One query, fully explained, in the order a query meets the pipeline. The
`data:` payload of a live-stream `event: query` frame is exactly this.
required: [request, group, policy, rewrites, route, response]
properties:
request:
$ref: "#/components/schemas/ProvenanceRequest"
group:
$ref: "#/components/schemas/ProvenanceGroup"
policy:
$ref: "#/components/schemas/ProvenancePolicy"
rewrites:
$ref: "#/components/schemas/ProvenanceRewrites"
route:
$ref: "#/components/schemas/ProvenanceRoute"
response:
$ref: "#/components/schemas/ProvenanceResponse"
QueryDetail:
type: object
description: |
`Provenance` plus the row id. Written out rather than composed with
`allOf` so the drift guard reads one property list per schema.
One query, fully explained, in the order a query meets the pipeline,
plus the row id. Written out rather than composed with `allOf` so the
drift guard reads one property list per schema.
required: [id, request, group, policy, rewrites, route, response]
properties:
id: { type: integer }
+1 -1
View File
@@ -113,7 +113,7 @@ fn matchPattern(pattern: []const u8, segments: []const []const u8) ??i64 {
/// Fills `buf` with the `Allow` header value for a path that matched under
/// other methods. The returned slice borrows `buf`.
pub fn formatAllow(table: []const RouteInfo, segments: []const []const u8, buf: []u8) []const u8 {
fn formatAllow(table: []const RouteInfo, segments: []const []const u8, buf: []u8) []const u8 {
var writer: std.Io.Writer = .fixed(buf);
var first = true;
for (table) |*route| {
+2 -2
View File
@@ -301,7 +301,7 @@ pub const QuerylogRead = struct {
/// one, so a password set through the API locks the routes without a restart.
/// With it set but no session store wired, every session route is refused: the
/// failure mode of a half-wired server must be locked, not open.
pub fn sessionAuth(state: *WebState, io: std.Io, request: *const http_util.Request) bool {
fn sessionAuth(state: *WebState, io: std.Io, request: *const http_util.Request) bool {
if (!state.live_hash.enabled(io)) return true;
const sessions = state.sessions orelse return false;
const cookie = http_util.cookieValue(request.cookie, auth.cookie_name) orelse return false;
@@ -313,7 +313,7 @@ pub fn sessionAuth(state: *WebState, io: std.Io, request: *const http_util.Reque
///
/// Keyed on `client_addr`, not on the socket peer: behind a trusted proxy every
/// peer is the proxy, and one bucket for every remote user is no limiter at all.
pub fn bucketLimit(state: *WebState, io: std.Io, request: *const http_util.Request) LimitVerdict {
fn bucketLimit(state: *WebState, io: std.Io, request: *const http_util.Request) LimitVerdict {
const limiter = state.limiter orelse return .ok;
const now = std.Io.Clock.awake.now(io);
return limiter.check(io, now, address.NetAddress.fromIp(request.client_addr));
+3 -3
View File
@@ -77,7 +77,7 @@ fn find(files: []const File, path: []const u8) ?*const File {
/// parameters fall outside the grammar is unusable and refuses. An empty
/// header (or one the connection budget dropped) reads as identity-only,
/// which degrades to the uncompressed entry.
pub fn acceptsGzip(header: []const u8) bool {
fn acceptsGzip(header: []const u8) bool {
var gzip_entry: ?bool = null;
var wildcard_entry: ?bool = null;
var tokens = std.mem.splitScalar(u8, header, ',');
@@ -122,7 +122,7 @@ fn qualityAccepts(value: []const u8) bool {
/// Whether an `if-none-match` header names `etag` (which carries its quotes).
/// Weak validators compare by content: a `W/` prefix on the wire still matches,
/// because the bytes behind a content hash are the content.
pub fn etagMatches(header: []const u8, etag: []const u8) bool {
fn etagMatches(header: []const u8, etag: []const u8) bool {
var tokens = std.mem.splitScalar(u8, header, ',');
while (tokens.next()) |token| {
var candidate = std.mem.trim(u8, token, " \t");
@@ -179,7 +179,7 @@ fn respondAsset(request: *http_util.Request, selection: Selection) http_util.Han
/// any segment could escape the root. Segments were split before percent
/// decoding, so a decoded segment may contain `/` — that and `..` are the two
/// traversal shapes, and both are refused rather than normalized.
pub fn diskRelativePath(buf: []u8, segments: []const []const u8) ?[]const u8 {
fn diskRelativePath(buf: []u8, segments: []const []const u8) ?[]const u8 {
if (segments.len == 0) return index_path[1..];
var writer: std.Io.Writer = .fixed(buf);
for (segments, 0..) |segment, index| {
+43 -13
View File
@@ -1174,7 +1174,7 @@ const managed_body = "{\"error\":\"configuration is managed by " ++ managed_path
/// do produce paths like this, and the old code answered them in `text/plain`.
const long_managed_path = "/mnt/" ++ ("deeply-nested-bind-mount/" ** 24) ++ "config.zon";
fn fileModeClasses(io: std.Io, env: *Env) anyerror!void {
fn fileModeConfigWrites(io: std.Io, env: *Env) anyerror!void {
var body_buf: [8192]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
@@ -1185,21 +1185,34 @@ fn fileModeClasses(io: std.Io, env: *Env) anyerror!void {
var response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
// Every class of configuration write answers the one envelope.
const writes = [_]struct { method: []const u8, target: []const u8, body: ?[]const u8 }{
.{ .method = "POST", .target = "/api/groups", .body = "{\"name\":\"kids\"}" },
.{ .method = "PUT", .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}" },
.{ .method = "PUT", .target = "/api/clients/1", .body = "{\"name\":\"x\",\"group_id\":1}" },
.{ .method = "DELETE", .target = "/api/upstreams/1", .body = null },
};
for (writes) |write| {
try conn.request(write.method, write.target, null, write.body);
// Every configuration write answers the one envelope — enumerated, not
// sampled. The cases come from the contract table because each entry
// carries a target and a body the handler would accept: a request the
// handler would reject anyway could answer 400 and still look like a pass.
var enumerated: usize = 0;
for (contract) |entry| {
if (entry.policy != .config_write) continue;
enumerated += 1;
try conn.request(@tagName(entry.method), entry.target, null, entry.body);
response = try conn.receive(&body_buf);
errdefer std.debug.print(
"{t} {s}: {d} {s}\n",
.{ entry.method, entry.target, response.status, response.body },
);
try testing.expectEqual(@as(u16, 403), response.status);
try testing.expectEqualStrings(managed_body, response.body);
try testing.expectEqualStrings("application/json", response.header("content-type").?);
}
// The served table is the authority on what a configuration write is, so a
// route added there cannot ship without a case here.
var served: usize = 0;
for (router.routes) |route| {
if (route.policy == .config_write) served += 1;
}
try testing.expectEqual(served, enumerated);
// Rejected before the handler, not after it: the group was never created.
try conn.request("GET", "/api/groups", null, null);
response = try conn.receive(&body_buf);
@@ -1377,7 +1390,7 @@ test "W10 milestone 20: file authority rejects configuration writes and spares t
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = managed_path } });
defer env.destroy();
try bounded(env.io(), default_budget, fileModeClasses, .{ env.io(), env });
try bounded(env.io(), default_budget, fileModeConfigWrites, .{ env.io(), env });
}
fn fileModeClientDelete(io: std.Io, env: *Env) anyerror!void {
@@ -3610,8 +3623,10 @@ test "drift guard c: the stats schemas match the structs that serialize them" {
test "drift guard c: the query-log schemas match the structs that serialize them" {
const gpa = testing.allocator;
try expectSchemaMatches(gpa, queries_repo.QueryRow, "QueryRow");
// `Provenance` has no schema of its own: it is `QueryDetail` without the
// row id, and the live stream documents it in prose rather than a `$ref`
// no path could honestly point at.
try expectSchemaMatches(gpa, provenance_view.QueryDetail, "QueryDetail");
try expectSchemaMatches(gpa, provenance_view.Provenance, "Provenance");
try expectSchemaMatches(gpa, coverage_mod.Coverage, "Coverage");
}
@@ -3641,6 +3656,7 @@ test "drift guard c bites: a renamed, retyped or newly optional field fails it"
// The same drift behind a `$ref`, where the property carries no `type:` of
// its own: a nested object the server may now omit.
const NullableObject = struct {
id: i64,
request: provenance_view.Request,
group: ?provenance_view.Group,
policy: provenance_view.Policy,
@@ -3648,7 +3664,7 @@ test "drift guard c bites: a renamed, retyped or newly optional field fails it"
route: provenance_view.Route,
response: provenance_view.Response,
};
try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, NullableObject, "Provenance"));
try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, NullableObject, "QueryDetail"));
// And behind a `$ref` to an enum, whose values would still line up.
const NullableEnum = struct {
@@ -3704,6 +3720,13 @@ const ContractSample = struct {
/// route runs after the create that gave it a row (an empty array witnesses no
/// field at all), and `POST /api/blocklists/update` runs while the only source
/// row is disabled, so the pass syncs its status without fetching anything.
///
/// Every successful `.json` route is sampled except three, which carry no JSON
/// contract this file could pin: `/metrics` answers Prometheus text,
/// `/api/openapi.yaml` is served verbatim from the repo and guarded by the
/// drift tests below, and `/api/queries/live` is an open SSE stream rather than
/// one byte-comparable body — its frame payload is `QueryDetail` without the
/// id, already sampled through `get_query_detail`.
const contract_sample_walk = [_]ContractSample{
.{ .name = "get_health", .ts_type = "Health", .method = "GET", .target = "/api/health", .status = 200 },
.{ .name = "get_version", .ts_type = "Version", .method = "GET", .target = "/api/version", .status = 200 },
@@ -3722,6 +3745,7 @@ const contract_sample_walk = [_]ContractSample{
// Blocklists. The row is created disabled so the refresh below has a status
// to report and still downloads nothing.
.{ .name = "create_blocklist", .ts_type = "BlocklistEcho", .method = "POST", .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\",\"enabled\":false}", .status = 201 },
.{ .name = "get_blocklist", .ts_type = "Blocklist", .method = "GET", .target = "/api/blocklists/1", .status = 200 },
.{ .name = "list_blocklists", .ts_type = "{ blocklists: Blocklist[] }", .method = "GET", .target = "/api/blocklists", .status = 200 },
.{ .name = "update_blocklist", .ts_type = "BlocklistEcho", .method = "PUT", .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200 },
.{ .name = "update_blocklists_now", .ts_type = "{ sources: SourceStatus[] }", .method = "POST", .target = "/api/blocklists/update", .body = "{}", .status = 202 },
@@ -3729,28 +3753,33 @@ const contract_sample_walk = [_]ContractSample{
// Groups. The migrated schema seeds `default` as id 1; the POST creates 2.
.{ .name = "list_groups", .ts_type = "{ groups: Group[] }", .method = "GET", .target = "/api/groups", .status = 200 },
.{ .name = "create_group", .ts_type = "Group", .method = "POST", .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201 },
.{ .name = "get_group", .ts_type = "Group", .method = "GET", .target = "/api/groups/2", .status = 200 },
.{ .name = "update_group", .ts_type = "Group", .method = "PUT", .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200 },
.{ .name = "put_group_sources", .ts_type = "{ source_ids: number[] }", .method = "PUT", .target = "/api/groups/1/sources", .body = "{\"source_ids\":[1]}", .status = 200 },
.{ .name = "get_group_sources", .ts_type = "{ source_ids: number[] }", .method = "GET", .target = "/api/groups/1/sources", .status = 200 },
// Rules, then the lookup that the rule makes answer `blocked`.
.{ .name = "create_rule", .ts_type = "RuleEcho", .method = "POST", .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201 },
.{ .name = "get_rule", .ts_type = "Rule", .method = "GET", .target = "/api/rules/1", .status = 200 },
.{ .name = "list_rules", .ts_type = "{ rules: Rule[] }", .method = "GET", .target = "/api/rules", .status = 200 },
.{ .name = "update_rule", .ts_type = "RuleEcho", .method = "PUT", .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"*.ads.example\",\"kind\":\"wildcard\",\"action\":\"block\"}", .status = 200 },
.{ .name = "get_lookup", .ts_type = "LookupResult", .method = "GET", .target = "/api/lookup?domain=sub.ads.example", .status = 200 },
// Local records.
.{ .name = "create_local_record", .ts_type = "LocalRecord", .method = "POST", .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201 },
.{ .name = "get_local_record", .ts_type = "LocalRecord", .method = "GET", .target = "/api/local-records/1", .status = 200 },
.{ .name = "list_local_records", .ts_type = "{ local_records: LocalRecord[] }", .method = "GET", .target = "/api/local-records", .status = 200 },
.{ .name = "update_local_record", .ts_type = "LocalRecord", .method = "PUT", .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200 },
// Forward zones.
.{ .name = "create_forward_zone", .ts_type = "ForwardZone", .method = "POST", .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201 },
.{ .name = "get_forward_zone", .ts_type = "ForwardZone", .method = "GET", .target = "/api/forward-zones/1", .status = 200 },
.{ .name = "list_forward_zones", .ts_type = "{ forward_zones: ForwardZone[] }", .method = "GET", .target = "/api/forward-zones", .status = 200 },
.{ .name = "update_forward_zone", .ts_type = "ForwardZone", .method = "PUT", .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200 },
// Clients (row id 1 is seeded — clients have no POST, ruling 9).
.{ .name = "list_clients", .ts_type = "{ clients: Client[] }", .method = "GET", .target = "/api/clients", .status = 200 },
.{ .name = "get_client", .ts_type = "Client", .method = "GET", .target = "/api/clients/1", .status = 200 },
.{ .name = "update_client", .ts_type = "Client", .method = "PUT", .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200 },
.{ .name = "put_client_prefixes", .ts_type = "{ client_prefixes: ClientPrefix[] }", .method = "PUT", .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200 },
.{ .name = "list_client_prefixes", .ts_type = "{ client_prefixes: ClientPrefix[] }", .method = "GET", .target = "/api/client-prefixes", .status = 200 },
@@ -3759,6 +3788,7 @@ const contract_sample_walk = [_]ContractSample{
// conflict sample below can collide with it.
.{ .name = "list_upstreams", .ts_type = "{ upstreams: Upstream[] }", .method = "GET", .target = "/api/upstreams", .status = 200 },
.{ .name = "create_upstream", .ts_type = "UpstreamEcho", .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201 },
.{ .name = "get_upstream", .ts_type = "Upstream", .method = "GET", .target = "/api/upstreams/1", .status = 200 },
.{ .name = "update_upstream", .ts_type = "UpstreamEcho", .method = "PUT", .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200 },
// Query log and stats. `limit=5` reaches seeded row 21, the blocked one, so