//! Milestone-8 W10: the web layer end to end, over loopback sockets.
//!
//! The heart of the file is the contract table (ruling 23): one entry per
//! route in `routes.zig`, carrying the request that exercises it, the status
//! it must answer, and the response shape that `std.json.parseFromSlice` must
//! accept with `.ignore_unknown_fields = false`. The auth and rate-limit
//! markers are asserted against the served table rather than re-typed, so the
//! contract and the router cannot disagree about policy.
//!
//! Every test drives the real stack: `server.Server` accepting on 127.0.0.1,
//! the shipped route table, real `Sessions`, a real `ApiLimiter`, a real
//! `Manager` reloading real snapshots out of an in-memory config database,
//! and a seeded in-memory query log. The only stand-ins are the upstream
//! pool's transport (never exchanged with) and the blocklist fetcher (never
//! fetched from — the one `POST /api/blocklists/update` in the walk runs
//! before any source row exists, so the suite stays hermetic).
//!
//! Compiled by every `zig build test`; skips at run time without
//! `-Dintegration`, like the other loopback suites.

const std = @import("std");
const build_options = @import("build_options");
const contract_samples = @import("contract_samples");
const http = std.http;
const net = std.Io.net;
const Allocator = std.mem.Allocator;

const address = @import("../platform/address.zig");
const api_limiter = @import("api_limiter.zig");
const auth = @import("auth.zig");
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const db = @import("../storage/db.zig");
const dns_handler = @import("../server/handler.zig");
const fetcher = @import("../filter/fetcher.zig");
const groups_repo = @import("../storage/repositories/groups_repo.zig");
const header = @import("../dns/header.zig");
const http_util = @import("http_util.zig");
const local_repo = @import("../storage/repositories/local_repo.zig");
const local_tables_mod = @import("../server/local_tables.zig");
const manager_mod = @import("../filter/manager.zig");
const migrations = @import("../storage/migrations.zig");
const name = @import("../dns/name.zig");
const openapi = @import("openapi.zig");
const packet = @import("../dns/packet.zig");
const pause_mod = @import("../server/pause.zig");
const pool_mod = @import("../upstream/pool.zig");
const queries_repo = @import("../storage/repositories/queries_repo.zig");
const querylog_schema = @import("../storage/querylog_schema.zig");
const question = @import("../dns/question.zig");
const router = @import("router.zig");
const server = @import("server.zig");
const sources_repo = @import("../storage/repositories/sources_repo.zig");
const sse = @import("sse.zig");
const static = @import("static.zig");
const transport = @import("../upstream/transport.zig");
const types = @import("../dns/types.zig");
const upstreams_repo = @import("../storage/repositories/upstreams_repo.zig");

const handlers_blocklists = @import("handlers/blocklists.zig");
const handlers_certs = @import("handlers/certs.zig");
const handlers_health = @import("handlers/health.zig");
const handlers_live = @import("handlers/live.zig");
const handlers_lookup = @import("handlers/lookup.zig");
const handlers_pause = @import("handlers/pause.zig");
const handlers_queries = @import("handlers/queries.zig");
const handlers_settings = @import("handlers/settings.zig");
const handlers_stats = @import("handlers/stats.zig");
const handlers_upstream_health = @import("handlers/upstream_health.zig");
const handlers_version = @import("handlers/version.zig");

const testing = std.testing;

/// Enough for every plain request/response round trip on loopback.
const default_budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake };

/// The SSE test must outwait one real 15 s heartbeat interval.
const sse_budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(40), .clock = .awake };

// ---------------------------------------------------------------------------
// bounded client runner (a server that never answers fails, never hangs)
// ---------------------------------------------------------------------------

const Outcome = union(enum) {
    work: anyerror!void,
    expiry: std.Io.Cancelable!void,
};

fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void {
    return duration.sleep(io);
}

fn bounded(
    io: std.Io,
    budget: std.Io.Clock.Duration,
    comptime f: anytype,
    args: std.meta.ArgsTuple(@TypeOf(f)),
) !void {
    var outcomes: [2]Outcome = undefined;
    var race: std.Io.Select(Outcome) = .init(io, &outcomes);
    defer race.cancelDiscard();

    try race.concurrent(.work, f, args);
    try race.concurrent(.expiry, expire, .{ io, budget });

    switch (try race.await()) {
        .work => |result| return result,
        .expiry => |result| {
            try result;
            return error.TestTimedOut;
        },
    }
}

// ---------------------------------------------------------------------------
// a small HTTP client
// ---------------------------------------------------------------------------

const Conn = struct {
    stream: net.Stream,
    reader: net.Stream.Reader,
    writer: net.Stream.Writer,
    read_buf: [16384]u8 = undefined,
    write_buf: [4096]u8 = undefined,
    /// Header lines are copied here because each `takeDelimiterInclusive`
    /// invalidates the previous line's slice into the read buffer.
    head_buf: [4096]u8 = undefined,

    fn connect(self: *Conn, io: std.Io, addr: net.IpAddress) !void {
        self.stream = try addr.connect(io, .{ .mode = .stream });
        self.reader = self.stream.reader(io, &self.read_buf);
        self.writer = self.stream.writer(io, &self.write_buf);
    }

    fn close(self: *Conn, io: std.Io) void {
        self.stream.close(io);
    }

    fn send(self: *Conn, bytes: []const u8) !void {
        try self.writer.interface.writeAll(bytes);
        try self.writer.interface.flush();
    }

    /// One request with optional body and one optional extra header line
    /// (without its trailing CRLF).
    fn request(
        self: *Conn,
        method: []const u8,
        target: []const u8,
        extra_header: ?[]const u8,
        body: ?[]const u8,
    ) !void {
        // Room for an over-budget cookie header (ruling 7 of milestone 16) and
        // still under the server's 8 KiB maximum request head.
        var buf: [6144]u8 = undefined;
        var w: std.Io.Writer = .fixed(&buf);
        try w.print("{s} {s} HTTP/1.1\r\nhost: t\r\n", .{ method, target });
        if (extra_header) |line| try w.print("{s}\r\n", .{line});
        if (body) |b| try w.print("content-length: {d}\r\n\r\n{s}", .{ b.len, b });
        if (body == null) try w.writeAll("\r\n");
        try self.send(w.buffered());
    }

    /// Reads the head only: status line and headers up to the blank line.
    fn receiveHead(self: *Conn) !Response {
        var head_len: usize = 0;
        while (true) {
            const raw = try self.reader.interface.takeDelimiterInclusive('\n');
            const line = std.mem.trimEnd(u8, raw, "\r\n");
            if (line.len == 0) break;
            if (head_len + line.len + 1 > self.head_buf.len) return error.TestHeadTooLarge;
            @memcpy(self.head_buf[head_len..][0..line.len], line);
            head_len += line.len;
            self.head_buf[head_len] = '\n';
            head_len += 1;
        }
        const head = self.head_buf[0..head_len];
        const status = try parseStatus(head);
        return .{ .status = status, .head = head, .body = &.{} };
    }

    /// Reads one response: head, then exactly `content-length` bytes. A
    /// missing content-length reads as an empty body (204 and 304 answers may
    /// omit it).
    fn receive(self: *Conn, out: []u8) !Response {
        const response = try self.receiveHead();
        const length = contentLength(response.head) orelse return response;
        if (length > out.len) return error.TestResponseTooLarge;
        const body = out[0..length];
        try self.reader.interface.readSliceAll(body);
        return .{ .status = response.status, .head = response.head, .body = body };
    }

    /// De-frames chunked transfer coding into `sink` until `needle` appears in
    /// the accumulated payload. The SSE body writer is unbuffered, so one
    /// frame arrives as several small chunks; only the de-framed text is a
    /// reliable haystack.
    fn readChunkedUntil(self: *Conn, sink: *std.ArrayList(u8), gpa: Allocator, needle: []const u8) !void {
        while (std.mem.indexOf(u8, sink.items, needle) == null) {
            const size_line = try self.reader.interface.takeDelimiterInclusive('\n');
            // A chunk's closing CRLF may arrive eagerly (its own empty line
            // here) or lazily in front of the next size; both read as noise.
            const trimmed = std.mem.trim(u8, size_line, "\r\n");
            if (trimmed.len == 0) continue;
            const size = try std.fmt.parseInt(usize, trimmed, 16);
            if (size == 0) return error.TestStreamEnded;

            var chunk_buf: [4096]u8 = undefined;
            var remaining = size;
            while (remaining != 0) {
                const step = @min(remaining, chunk_buf.len);
                try self.reader.interface.readSliceAll(chunk_buf[0..step]);
                try sink.appendSlice(gpa, chunk_buf[0..step]);
                remaining -= step;
            }
        }
    }
};

const Response = struct {
    status: u16,
    /// Borrows the connection's head buffer; valid until the next receive.
    head: []const u8,
    body: []const u8,

    fn header(self: Response, header_name: []const u8) ?[]const u8 {
        var lines = std.mem.splitScalar(u8, self.head, '\n');
        _ = lines.next();
        while (lines.next()) |line| {
            const colon = std.mem.findScalar(u8, line, ':') orelse continue;
            if (!std.ascii.eqlIgnoreCase(std.mem.trim(u8, line[0..colon], " "), header_name)) continue;
            return std.mem.trim(u8, line[colon + 1 ..], " ");
        }
        return null;
    }
};

fn parseStatus(head: []const u8) !u16 {
    const first_space = std.mem.findScalar(u8, head, ' ') orelse return error.TestBadResponse;
    const rest = head[first_space + 1 ..];
    const second_space = std.mem.findScalar(u8, rest, ' ') orelse rest.len;
    return std.fmt.parseInt(u16, rest[0..second_space], 10) catch error.TestBadResponse;
}

fn contentLength(head: []const u8) ?usize {
    var lines = std.mem.splitScalar(u8, head, '\n');
    while (lines.next()) |line| {
        const colon = std.mem.findScalar(u8, line, ':') orelse continue;
        if (!std.ascii.eqlIgnoreCase(std.mem.trim(u8, line[0..colon], " "), "content-length")) continue;
        return std.fmt.parseInt(usize, std.mem.trim(u8, line[colon + 1 ..], " "), 10) catch null;
    }
    return null;
}

// ---------------------------------------------------------------------------
// the environment: the real web stack over in-memory databases
// ---------------------------------------------------------------------------

const seeded_query_rows = 25;

const EnvOptions = struct {
    password_hash: []const u8 = "",
    rate_per_min: u32 = 100_000,
    localhost_exempt: bool = true,
    sse_max_per_ip: u16 = 3,
    trusted_proxies: []const u8 = "",
    fallback: ?router.HandlerFn = null,
    /// Milestone-20 ruling 7. `.database` is what every pre-existing test
    /// wants; the file-authority tests below name a path.
    authority: server.Authority = .database,
    reconciled_at: ?i64 = null,
};

/// Heap-allocated because `state` and the listener hold pointers into it.
const Env = struct {
    gpa: Allocator,
    threaded: std.Io.Threaded,
    tmp: testing.TmpDir,
    config_db: db.Db,
    querylog_db: db.Db,
    http_client: std.http.Client,
    transfer_buf: [fetcher.min_transfer_buf]u8,
    redirect_buf: [fetcher.redirect_buffer_len]u8,
    fetch: fetcher.Fetcher,
    mgr: manager_mod.Manager,
    sessions: auth.Sessions,
    limiter: api_limiter.ApiLimiter,
    hub: *sse.Hub,
    pauser: pause_mod.Pause,
    tables: local_tables_mod.LocalTables,
    pool_entries: [1]pool_mod.Entry,
    pool: pool_mod.Pool,
    state: server.WebState,
    web: server.Server,
    group: std.Io.Group,
    addr: net.IpAddress,

    fn create(gpa: Allocator, options: EnvOptions) !*Env {
        const self = try gpa.create(Env);
        errdefer gpa.destroy(self);

        self.gpa = gpa;
        self.threaded = .init(gpa, .{});
        errdefer self.threaded.deinit();
        const ioh = self.threaded.io();

        self.tmp = testing.tmpDir(.{ .iterate = true });
        errdefer self.tmp.cleanup();

        self.config_db = try db.Db.open(":memory:", .{ .mode = .memory });
        errdefer self.config_db.close();
        try db.applyPragmas(&self.config_db, .{});
        _ = try migrations.migrate(&self.config_db);
        try seedConfig(&self.config_db);

        self.querylog_db = try db.Db.open(":memory:", .{ .mode = .memory });
        errdefer self.querylog_db.close();
        try self.querylog_db.exec(querylog_schema.ddl);
        try seedQueryLog(&self.querylog_db);

        // Real fetcher wiring; nothing in this suite downloads (the one
        // refreshAll in the contract walk runs with zero source rows).
        self.http_client = .{ .allocator = gpa, .io = ioh };
        errdefer self.http_client.deinit();
        self.fetch = .{
            .http = &self.http_client,
            .transfer_buf = &self.transfer_buf,
            .redirect_buf = &self.redirect_buf,
        };

        self.mgr = try manager_mod.Manager.init(
            gpa,
            &self.config_db,
            .{ .dir = self.tmp.dir },
            &self.fetch,
            .{},
            default_budget,
        );
        errdefer self.mgr.deinit(ioh);
        try self.mgr.reload(ioh);

        self.sessions = .init(24);
        self.limiter = try api_limiter.ApiLimiter.init(gpa, .{
            .rate_per_min = options.rate_per_min,
            .localhost_exempt = options.localhost_exempt,
            .sse_max_per_ip = options.sse_max_per_ip,
        });
        errdefer self.limiter.deinit();

        // ~900 KiB: never a stack local (W5 rule).
        self.hub = try gpa.create(sse.Hub);
        errdefer gpa.destroy(self.hub);
        self.hub.init();

        self.pauser = .{};
        self.tables = .empty;

        self.pool_entries = .{.{
            .endpoint = transport.Endpoint.parse("https://dns.example/dns-query") catch unreachable,
            // Never exchanged with: the pool feeds /api/upstream/health only.
            .client = .{ .ptr = undefined, .exchangeFn = undefined },
            .priority = 1,
            .enabled = true,
            .health = .init,
        }};
        self.pool = .init(&self.pool_entries, .{}, .{
            .attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake },
            .total = .{ .raw = .fromMilliseconds(100), .clock = .awake },
        }, 1);

        self.state = .{
            .gpa = gpa,
            .web = .{
                .password_hash = options.password_hash,
                .api_rate_limit_per_min = options.rate_per_min,
                .api_localhost_exempt = options.localhost_exempt,
                .sse_max_connections_per_ip = options.sse_max_per_ip,
                .trusted_proxies = options.trusted_proxies,
            },
            .authority = options.authority,
            .reconciled_at = options.reconciled_at,
            .live_hash = .init(options.password_hash),
            .pause = &self.pauser,
            .manager = &self.mgr,
            .pool = &self.pool,
            .local_tables = &self.tables,
            .sessions = &self.sessions,
            .limiter = &self.limiter,
            .hub = self.hub,
            .config_db = &self.config_db,
            .querylog_db = &self.querylog_db,
            .version = "w10-test",
            .started_unix = std.Io.Clock.real.now(ioh).toSeconds(),
            .fallback = options.fallback,
            .reload_fn = realReload,
        };

        const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
        self.web = try server.Server.listen(gpa, ioh, listen_address, &self.state, .{ .max_connections = 8 });
        self.addr = self.web.boundAddress();

        self.group = .init;
        try self.group.concurrent(ioh, server.Server.serve, .{ &self.web, ioh });

        return self;
    }

    fn destroy(self: *Env) void {
        const gpa = self.gpa;
        const ioh = self.threaded.io();

        self.web.deinit(ioh);
        self.group.await(ioh) catch |err| switch (err) {
            error.Canceled => unreachable,
        };

        self.state.live_hash.deinit(gpa);
        self.tables.deinit(gpa);
        gpa.destroy(self.hub);
        self.limiter.deinit();
        self.mgr.deinit(ioh);
        self.http_client.deinit();
        self.querylog_db.close();
        self.config_db.close();
        self.tmp.cleanup();
        self.threaded.deinit();
        gpa.destroy(self);
    }

    fn io(self: *Env) std.Io {
        return self.threaded.io();
    }

    /// The generation of the published snapshot.
    fn generation(self: *Env) !u64 {
        const handle = self.mgr.acquire(self.io()) orelse return error.TestNoSnapshot;
        defer handle.release(self.io());
        return handle.snapshot.generation;
    }
};

/// Ruling 12's seam, wired to the real manager the way app.zig wires it.
fn realReload(state: *server.WebState, io: std.Io) anyerror!void {
    const mgr = state.manager orelse return;
    try mgr.reload(io);
}

/// One upstream (the settings PUT validates the whole stored config, which
/// insists on one) and one client row (clients have no POST, ruling 9).
fn seedConfig(database: *db.Db) !void {
    try database.exec(
        \\INSERT INTO upstreams (url, priority, enabled, tls_name)
        \\VALUES ('https://dns.example/dns-query', 100, 1, '')
    );
    try database.exec(
        \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
        \\VALUES ('192.168.1.50', 'laptop', 1, 0, 1700000000, 1700000000)
    );
}

fn seedQueryLog(database: *db.Db) !void {
    var writer = try queries_repo.BatchWriter.init(database);
    defer writer.deinit();

    var domain_buf: [32]u8 = undefined;
    var index: usize = 0;
    while (index < seeded_query_rows) : (index += 1) {
        const domain = std.fmt.bufPrint(&domain_buf, "d{d}.example", .{index}) catch unreachable;
        const blocked = index % 5 == 0;
        try writer.writeBatch(&.{.{
            .timestamp = 1_700_000_000 + @as(i64, @intCast(index)),
            .domain = domain,
            .client_ip = "192.0.2.10",
            .qtype = 1,
            .blocked = blocked,
            .block_reason = if (blocked) "blocklist_domain" else null,
            .response_time_us = 250,
            .cache_hit = if (blocked) null else (index % 2 == 0),
            .upstream = if (blocked) null else "https://dns.example/dns-query",
        }});
    }
}

// ---------------------------------------------------------------------------
// the contract table (ruling 23)
// ---------------------------------------------------------------------------

fn jsonShape(comptime T: type) *const fn (Allocator, []const u8) anyerror!void {
    return &struct {
        fn check(arena: Allocator, bytes: []const u8) anyerror!void {
            _ = try std.json.parseFromSliceLeaky(T, arena, bytes, .{ .ignore_unknown_fields = false });
        }
    }.check;
}

// Response shapes the handlers keep private are restated here; the public
// ones are referenced directly so they cannot drift.
const LoginView = struct { authenticated: bool, auth_required: bool };
const LogoutView = struct { authenticated: bool };
const StatusList = struct { sources: []const handlers_blocklists.StatusView };
const GroupsList = struct { groups: []const groups_repo.GroupRow };
const GroupEcho = struct { id: i64, name: []const u8, safe_search: bool };
const SourceIds = struct { source_ids: []const i64 };
const SourcesList = struct { blocklists: []const sources_repo.SourceRow };
const SourceEcho = struct { id: i64, url: []const u8, name: []const u8, enabled: bool, is_suggested: bool };
const RuleShape = struct {
    id: i64,
    group_id: i64,
    group: []const u8,
    pattern: []const u8,
    kind: []const u8,
    action: []const u8,
    created_at: i64,
};
const RulesList = struct { rules: []const RuleShape };
const RuleEcho = struct { id: i64, group_id: i64, pattern: []const u8, kind: []const u8, action: []const u8 };
const RecordShape = struct { id: i64, name: []const u8, rtype: []const u8, value: []const u8, ttl: u32 };
const RecordsList = struct { local_records: []const RecordShape };
const ZonesList = struct { forward_zones: []const local_repo.ForwardZoneRow };
const ClientsList = struct { clients: []const clients_repo.ClientRow };
const PrefixesList = struct { client_prefixes: []const clients_repo.ClientPrefixRow };
const UpstreamsList = struct { upstreams: []const upstreams_repo.UpstreamRow };
const UpstreamEcho = struct {
    id: i64,
    url: []const u8,
    priority: i32,
    enabled: bool,
    tls_name: []const u8,
    restart_required: bool,
};

const TlsEndpointView = struct {
    enabled: bool,
    bind: []const u8,
    port: u16,
    cert_path: []const u8,
    key_path: []const u8,
};

/// `GET /api/settings` in full. Parsed strictly, so this also proves the
/// response never carries `web.password` or `web.password_hash` (ruling 16).
const SettingsView = struct {
    settings: struct {
        upstream: struct { attempt_timeout_ms: u32, read_timeout_ms: u32, total_timeout_ms: u32 },
        dns: struct {
            bind_ipv4: []const u8,
            bind_ipv6: []const u8,
            port: u16,
            rate_limit: u32,
            rate_window_seconds: u32,
        },
        blocking: struct { response: []const u8, ttl: u32 },
        cache: struct { size: u32, negative_ttl_max: u32 },
        web: struct {
            enabled: bool,
            bind: []const u8,
            port: u16,
            session_ttl_hours: u16,
            api_rate_limit_per_min: u32,
            api_localhost_exempt: bool,
            sse_max_connections_per_ip: u16,
            trusted_proxies: []const u8,
            auth_enabled: bool,
        },
        doh_server: TlsEndpointView,
        dot_server: TlsEndpointView,
        edns: struct { ecs_mode: []const u8 },
        logging: struct {
            level: []const u8,
            retention_days: u16,
            query_log_buffer_max: u32,
            hide_domains: bool,
            hide_client_ips: bool,
            output: []const u8,
            file_path: []const u8,
            max_size_mb: u32,
            max_files: u8,
        },
        disk: struct { min_free_mb: u32, warn_free_mb: u32 },
        blocklist_update: struct { enabled: bool, interval_hours: u16 },
    },
    restart_required: []const []const u8,
    authority: struct { mode: []const u8, path: ?[]const u8, reconciled_at: ?i64 },
};

const Contract = struct {
    method: http.Method,
    /// Must equal a `routes.zig` pattern; the coverage test enforces it.
    pattern: []const u8,
    auth: router.Auth,
    /// Milestone-20 ruling 7's class, restated here so the coverage test can
    /// hold the served table to it. No default, like the route table.
    policy: router.Policy,
    rate_limit: router.RateLimit = .counted,
    /// The concrete request target the walk sends.
    target: []const u8,
    body: ?[]const u8 = null,
    status: u16,
    kind: enum { json, raw, none, sse } = .json,
    /// `.json`: the strict response shape.
    check: ?*const fn (Allocator, []const u8) anyerror!void = null,
    /// `.raw`: a substring the body must contain.
    needle: []const u8 = "",
};

/// Execution order is the table order: `POST /api/blocklists/update` runs
/// while no source row exists (hermetic), reads of `{id}` routes follow the
/// create that made the row, and deletes come last for their resource.
const contract = [_]Contract{
    // Monitoring and contract.
    .{ .method = .GET, .pattern = "/metrics", .auth = .open, .policy = .read, .rate_limit = .exempt, .target = "/metrics", .status = 200, .kind = .raw, .needle = "nxdns_up 1" },
    .{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .rate_limit = .exempt, .target = "/api/health", .status = 200, .check = jsonShape(handlers_health.Body) },
    .{ .method = .GET, .pattern = "/api/version", .auth = .open, .policy = .read, .target = "/api/version", .status = 200, .check = jsonShape(handlers_version.Body) },
    .{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .policy = .read, .target = "/api/openapi.yaml", .status = 200, .kind = .raw, .needle = "openapi: 3.0.3" },

    // Authentication (auth is disabled in the walk's environment; the on/off
    // matrix has its own test).
    .{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .policy = .runtime_action, .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200, .check = jsonShape(LoginView) },
    .{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .policy = .runtime_action, .target = "/api/auth/logout", .status = 200, .check = jsonShape(LogoutView) },

    // Refresh-all before any source row exists: nothing to fetch, 202 anyway.
    .{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .target = "/api/blocklists/update", .status = 202, .check = jsonShape(StatusList) },

    // Query log, stats, live stream, upstream health.
    .{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) },
    .{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse },
    .{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) },
    .{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) },
    .{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .target = "/api/upstream/health", .status = 200, .check = jsonShape(handlers_upstream_health.Body) },

    // Groups. The migrated schema seeds `default` as id 1; the POST creates
    // id 2, which the delete at the end of the walk removes.
    .{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .target = "/api/groups", .status = 200, .check = jsonShape(GroupsList) },
    .{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201, .check = jsonShape(GroupEcho) },
    .{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .target = "/api/groups/2", .status = 200, .check = jsonShape(groups_repo.GroupRow) },
    .{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200, .check = jsonShape(GroupEcho) },
    .{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .read, .target = "/api/groups/1/sources", .status = 200, .check = jsonShape(SourceIds) },
    .{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .target = "/api/groups/1/sources", .body = "{\"source_ids\":[]}", .status = 200, .check = jsonShape(SourceIds) },

    // Blocklist sources. The POST runs after the refresh above, so the created
    // row's url is never fetched.
    .{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .policy = .read, .target = "/api/blocklists", .status = 200, .check = jsonShape(SourcesList) },
    .{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .policy = .config_write, .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\"}", .status = 201, .check = jsonShape(SourceEcho) },
    .{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .read, .target = "/api/blocklists/1", .status = 200, .check = jsonShape(sources_repo.SourceRow) },
    .{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200, .check = jsonShape(SourceEcho) },
    .{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .target = "/api/blocklists/1", .status = 204, .kind = .none },

    // Rules. The lookup below wants the blocking rule still in place, so the
    // rule's delete follows it.
    .{ .method = .GET, .pattern = "/api/rules", .auth = .session, .policy = .read, .target = "/api/rules", .status = 200, .check = jsonShape(RulesList) },
    .{ .method = .POST, .pattern = "/api/rules", .auth = .session, .policy = .config_write, .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201, .check = jsonShape(RuleEcho) },
    .{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .policy = .read, .target = "/api/rules/1", .status = 200, .check = jsonShape(RuleShape) },
    .{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 200, .check = jsonShape(RuleEcho) },
    .{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .target = "/api/lookup?domain=ads.example", .status = 200, .check = jsonShape(handlers_lookup.Body) },
    .{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .target = "/api/rules/1", .status = 204, .kind = .none },

    // Local records.
    .{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .policy = .read, .target = "/api/local-records", .status = 200, .check = jsonShape(RecordsList) },
    .{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .policy = .config_write, .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201, .check = jsonShape(RecordShape) },
    .{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .read, .target = "/api/local-records/1", .status = 200, .check = jsonShape(RecordShape) },
    .{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200, .check = jsonShape(RecordShape) },
    .{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .target = "/api/local-records/1", .status = 204, .kind = .none },

    // Forward zones.
    .{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .policy = .read, .target = "/api/forward-zones", .status = 200, .check = jsonShape(ZonesList) },
    .{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .policy = .config_write, .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201, .check = jsonShape(local_repo.ForwardZoneRow) },
    .{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .read, .target = "/api/forward-zones/1", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
    .{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
    .{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .target = "/api/forward-zones/1", .status = 204, .kind = .none },

    // Clients (row id 1 is seeded — clients have no POST, ruling 9).
    .{ .method = .GET, .pattern = "/api/clients", .auth = .session, .policy = .read, .target = "/api/clients", .status = 200, .check = jsonShape(ClientsList) },
    .{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .policy = .read, .target = "/api/clients/1", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
    .{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .policy = .config_write, .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
    .{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .policy = .runtime_action, .target = "/api/clients/1", .status = 204, .kind = .none },
    .{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .policy = .read, .target = "/api/client-prefixes", .status = 200, .check = jsonShape(PrefixesList) },
    .{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .policy = .config_write, .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200, .check = jsonShape(PrefixesList) },

    // Upstreams. Row id 1 is seeded; the POST creates id 2, whose delete
    // cannot collide with the last-enabled-upstream guard.
    .{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .policy = .read, .target = "/api/upstreams", .status = 200, .check = jsonShape(UpstreamsList) },
    .{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .policy = .config_write, .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201, .check = jsonShape(UpstreamEcho) },
    .{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .read, .target = "/api/upstreams/1", .status = 200, .check = jsonShape(upstreams_repo.UpstreamRow) },
    .{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200, .check = jsonShape(UpstreamEcho) },
    .{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .target = "/api/upstreams/2", .status = 204, .kind = .none },

    // Pause and settings. The pause POST leaves filtering running; the
    // settings PUT is a real change, echoed by the same response shape.
    .{ .method = .GET, .pattern = "/api/pause", .auth = .session, .policy = .read, .target = "/api/pause", .status = 200, .check = jsonShape(handlers_pause.View) },
    .{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .target = "/api/pause", .body = "{\"paused\":false}", .status = 200, .check = jsonShape(handlers_pause.View) },
    .{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .target = "/api/settings", .status = 200, .check = jsonShape(SettingsView) },
    .{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .policy = .config_write, .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200, .check = jsonShape(SettingsView) },

    // Certificates. The walk's environment wires no cert store, so both
    // endpoints report disabled — and the reload still answers 200 (m10
    // ruling 8: the outcome is the payload).
    .{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .target = "/api/certs/reload", .status = 200, .check = jsonShape(handlers_certs.View) },

    // The walk's last delete returns the groups table to its seeded shape.
    .{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .target = "/api/groups/2", .status = 204, .kind = .none },
};

// Drift guard: the contract table covers the served route table exactly —
// every entry matches one route, no route is missed, and the policy columns
// agree with the table the router dispatches from (not a re-typed copy).
// Pure bookkeeping, so it runs in every suite.
test "the contract table covers every served route with the served policy" {
    var covered = [_]bool{false} ** 64;
    try testing.expect(router.routes.len <= covered.len);
    try testing.expectEqual(router.routes.len, contract.len);

    for (contract) |entry| {
        var found = false;
        for (router.routes, 0..) |route, index| {
            if (route.method != entry.method) continue;
            if (!std.mem.eql(u8, route.pattern, entry.pattern)) continue;
            try testing.expect(!covered[index]);
            covered[index] = true;
            try testing.expectEqual(route.auth, entry.auth);
            try testing.expectEqual(route.rate_limit, entry.rate_limit);
            try testing.expectEqual(route.policy, entry.policy);
            found = true;
            break;
        }
        if (!found) {
            std.debug.print("contract entry has no route: {t} {s}\n", .{ entry.method, entry.pattern });
            return error.TestUnexpectedResult;
        }
    }
    for (covered[0..router.routes.len], 0..) |seen, index| {
        if (!seen) {
            std.debug.print("route has no contract entry: {s}\n", .{router.routes[index].pattern});
            return error.TestUnexpectedResult;
        }
    }
}

fn contractWalk(io: std.Io, env: *Env) anyerror!void {
    var arena_state: std.heap.ArenaAllocator = .init(env.gpa);
    defer arena_state.deinit();

    var body_buf: [128 * 1024]u8 = undefined;
    for (contract, 0..) |entry, index| {
        _ = arena_state.reset(.retain_capacity);
        const arena = arena_state.allocator();

        var conn: Conn = undefined;
        try conn.connect(io, env.addr);
        defer conn.close(io);

        try conn.request(@tagName(entry.method), entry.target, null, entry.body);

        if (entry.kind == .sse) {
            const head = try conn.receiveHead();
            try testing.expectEqual(entry.status, head.status);
            try testing.expectEqualStrings("text/event-stream", head.header("content-type").?);
            var seen: std.ArrayList(u8) = .empty;
            defer seen.deinit(env.gpa);
            try conn.readChunkedUntil(&seen, env.gpa, "retry: 3000");
            continue;
        }

        const response = conn.receive(&body_buf) catch |err| {
            std.debug.print("contract[{d}] {t} {s}: no response ({t})\n", .{ index, entry.method, entry.target, err });
            return err;
        };
        if (response.status != entry.status) {
            std.debug.print(
                "contract[{d}] {t} {s}: expected {d}, got {d} body {s}\n",
                .{ index, entry.method, entry.target, entry.status, response.status, response.body },
            );
            return error.TestUnexpectedResult;
        }

        switch (entry.kind) {
            .json => {
                const check = entry.check orelse return error.TestBadContractEntry;
                check(arena, response.body) catch |err| {
                    std.debug.print(
                        "contract[{d}] {t} {s}: shape rejected ({t}) body {s}\n",
                        .{ index, entry.method, entry.target, err, response.body },
                    );
                    return err;
                };
            },
            .raw => try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, entry.needle)),
            .none => try testing.expectEqual(@as(usize, 0), response.body.len),
            .sse => unreachable,
        }
    }
}

test "W10 contract: every route answers its documented status and shape" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{});
    defer env.destroy();

    try bounded(env.io(), default_budget, contractWalk, .{ env.io(), env });
}

// The wire shape of the certs reload payload, restated so the handler's own
// `View` cannot vouch for itself. Runs in every suite: it needs no socket.
const CertOutcomeShape = struct { enabled: bool, reloaded: bool, @"error": ?[]const u8 };
const CertsReloadShape = struct { doh: CertOutcomeShape, dot: CertOutcomeShape };

test "the certs reload payload with both endpoints disabled parses strictly" {
    const gpa = testing.allocator;

    var state: server.WebState = .{ .gpa = gpa };
    const view = handlers_certs.applyReload(&state, undefined);

    var out: std.Io.Writer.Allocating = .init(gpa);
    defer out.deinit();
    try std.json.Stringify.value(view, .{}, &out.writer);

    var arena_state: std.heap.ArenaAllocator = .init(gpa);
    defer arena_state.deinit();
    const parsed = try std.json.parseFromSliceLeaky(
        CertsReloadShape,
        arena_state.allocator(),
        out.written(),
        .{ .ignore_unknown_fields = false },
    );
    for ([_]CertOutcomeShape{ parsed.doh, parsed.dot }) |per_endpoint| {
        try testing.expect(!per_endpoint.enabled);
        try testing.expect(!per_endpoint.reloaded);
        try testing.expectEqual(@as(?[]const u8, null), per_endpoint.@"error");
    }
}

// ---------------------------------------------------------------------------
// auth on/off matrix (rulings 17, 18)
// ---------------------------------------------------------------------------

const test_password = "correct horse battery staple";

/// Hashes on an `Io` of its own, before the environment exists, so nothing
/// mutates a `WebState` the server tasks are already reading.
fn hashTestPassword(gpa: Allocator, buf: []u8) ![]const u8 {
    var hash_threaded: std.Io.Threaded = .init(gpa, .{});
    defer hash_threaded.deinit();
    return std.crypto.pwhash.argon2.strHash(test_password, .{
        .allocator = gpa,
        .params = .owasp_2id,
        .mode = .argon2id,
        .encoding = .phc,
    }, buf, hash_threaded.io());
}

fn authMatrix(io: std.Io, env: *Env) anyerror!void {
    var body_buf: [4096]u8 = undefined;

    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    // Without a session: session routes are 401, ruling 18's open set is not.
    try conn.request("GET", "/api/groups", null, null);
    var response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 401), response.status);
    try testing.expectEqualStrings("{\"error\":\"authentication required\"}", response.body);

    try conn.request("GET", "/api/health", null, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);

    // A wrong password is 401 and mints nothing.
    try conn.request("POST", "/api/auth/login", null, "{\"password\":\"wrong\"}");
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 401), response.status);
    try testing.expectEqual(@as(?[]const u8, null), response.header("set-cookie"));

    // The right password sets the session cookie with ruling 17's attributes.
    try conn.request("POST", "/api/auth/login", null, "{\"password\":\"" ++ test_password ++ "\"}");
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);
    const set_cookie = response.header("set-cookie") orelse return error.TestNoCookie;
    try testing.expect(std.mem.containsAtLeast(u8, set_cookie, 1, "HttpOnly"));
    try testing.expect(std.mem.containsAtLeast(u8, set_cookie, 1, "SameSite=Lax"));
    try testing.expect(!std.mem.containsAtLeast(u8, set_cookie, 1, "Secure"));

    const cookie_end = std.mem.findScalar(u8, set_cookie, ';') orelse set_cookie.len;
    var cookie_buf: [256]u8 = undefined;
    const cookie_line = try std.fmt.bufPrint(&cookie_buf, "cookie: {s}", .{set_cookie[0..cookie_end]});

    // The cookie opens the session routes.
    try conn.request("GET", "/api/groups", cookie_line, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);

    // Logout deletes the cookie and closes the session.
    try conn.request("POST", "/api/auth/logout", cookie_line, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);
    const deleted = response.header("set-cookie") orelse return error.TestNoCookie;
    try testing.expect(std.mem.containsAtLeast(u8, deleted, 1, "Max-Age=0"));

    try conn.request("GET", "/api/groups", cookie_line, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 401), response.status);
}

test "W10 auth on: password-hashed environment enforces the session matrix" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var hash_buf: [256]u8 = undefined;
    const hash = try hashTestPassword(gpa, &hash_buf);

    var env = try Env.create(gpa, .{ .password_hash = hash });
    defer env.destroy();

    try bounded(env.io(), default_budget, authMatrix, .{ env.io(), env });
}

fn authOff(io: std.Io, env: *Env) anyerror!void {
    var body_buf: [4096]u8 = undefined;
    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    // No hash stored: every session route is open (ruling 17).
    try conn.request("GET", "/api/groups", null, null);
    var response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);

    // Login still answers, reporting that no password is required, and mints
    // no cookie.
    try conn.request("POST", "/api/auth/login", null, "{\"password\":\"anything\"}");
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);
    try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "\"auth_required\":false"));
    try testing.expectEqual(@as(?[]const u8, null), response.header("set-cookie"));
}

test "W10 auth off: an empty hash leaves every route open" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{});
    defer env.destroy();

    try bounded(env.io(), default_budget, authOff, .{ env.io(), env });
}

// ---------------------------------------------------------------------------
// file authority (milestone-20 ruling 7)
// ---------------------------------------------------------------------------

const managed_path = "/etc/nxdns/config.zon";
const managed_body = "{\"error\":\"configuration is managed by " ++ managed_path ++
    "; edit the file and restart\"}";

/// Long enough that the envelope could not be built in the 512-byte stack
/// buffer `respondError` used before this milestone. Nested bind mounts really
/// 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 {
    var body_buf: [8192]u8 = undefined;
    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    // A read is untouched.
    try conn.request("GET", "/api/groups", null, null);
    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);
        response = try conn.receive(&body_buf);
        try testing.expectEqual(@as(u16, 403), response.status);
        try testing.expectEqualStrings(managed_body, response.body);
        try testing.expectEqualStrings("application/json", response.header("content-type").?);
    }

    // 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);
    try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "kids"));

    // Runtime actions stay live.
    try conn.request("POST", "/api/pause", null, "{\"paused\":false}");
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);

    try conn.request("POST", "/api/blocklists/update", null, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 202), response.status);

    try conn.request("POST", "/api/certs/reload", null, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);
}

test "W10 milestone 20: file authority rejects configuration writes and spares the rest" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{ .authority = .{ .managed_file = managed_path } });
    defer env.destroy();

    try bounded(env.io(), default_budget, fileModeClasses, .{ env.io(), env });
}

fn fileModeClientDelete(io: std.Io, env: *Env) anyerror!void {
    var body_buf: [4096]u8 = undefined;
    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    // The declared row contradicts the file, so it stays.
    try conn.request("DELETE", "/api/clients/2", null, null);
    var response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 403), response.status);
    try testing.expectEqualStrings(managed_body, response.body);

    // The observed row is runtime state the file never declared; without this
    // a departed device would be immortal, since the file can only promote an
    // address, never forget one.
    try conn.request("DELETE", "/api/clients/1", null, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 204), response.status);

    // An id no client holds is still a 404, not a policy verdict.
    try conn.request("DELETE", "/api/clients/999", null, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 404), response.status);
}

test "W10 milestone 20: file authority deletes an observed client and refuses a declared one" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{ .authority = .{ .managed_file = managed_path } });
    defer env.destroy();

    // Row 1 is seeded observed (`hand_edited = 0`); row 2 is what the file
    // declares.
    try env.config_db.exec(
        \\INSERT INTO clients (id, ip, name, group_id, hand_edited, first_seen, last_seen)
        \\VALUES (2, '192.168.1.51', 'nas', 1, 1, 1700000000, 1700000000)
    );

    try bounded(env.io(), default_budget, fileModeClientDelete, .{ env.io(), env });
}

fn longPathEnvelope(io: std.Io, env: *Env) anyerror!void {
    var body_buf: [8192]u8 = undefined;
    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
    const response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 403), response.status);
    try testing.expect(response.body.len > 512);
    try testing.expectEqualStrings("application/json", response.header("content-type").?);
    try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, long_managed_path));

    // Still the documented envelope, not a truncation and not plain text.
    const parsed = try std.json.parseFromSlice(
        struct { @"error": []const u8 },
        env.gpa,
        response.body,
        .{},
    );
    defer parsed.deinit();
}

test "W10 milestone 20: an error longer than the old 512-byte buffer stays application/json" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{ .authority = .{ .managed_file = long_managed_path } });
    defer env.destroy();

    try bounded(env.io(), default_budget, longPathEnvelope, .{ env.io(), env });
}

fn fileModeUnauthenticated(io: std.Io, env: *Env) anyerror!void {
    var body_buf: [4096]u8 = undefined;
    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    // Policy runs after authentication: a caller with no session learns that
    // it needs one, never that the route exists and is managed by a file whose
    // path the envelope would otherwise disclose.
    try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
    const response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 401), response.status);
    try testing.expectEqualStrings("{\"error\":\"authentication required\"}", response.body);
    try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, managed_path));
}

test "W10 milestone 20: an unauthenticated configuration write is 401, never 403" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var hash_buf: [256]u8 = undefined;
    const hash = try hashTestPassword(gpa, &hash_buf);

    var env = try Env.create(gpa, .{
        .password_hash = hash,
        .authority = .{ .managed_file = managed_path },
    });
    defer env.destroy();

    try bounded(env.io(), default_budget, fileModeUnauthenticated, .{ env.io(), env });
}

fn authorityEnvelope(io: std.Io, env: *Env) anyerror!void {
    var body_buf: [16384]u8 = undefined;
    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    try conn.request("GET", "/api/settings", null, null);
    var response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);

    const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{});
    defer parsed.deinit();
    try testing.expectEqualStrings("managed_file", parsed.value.authority.mode);
    try testing.expectEqualStrings(managed_path, parsed.value.authority.path.?);
    try testing.expectEqual(@as(?i64, 1_700_000_042), parsed.value.authority.reconciled_at);

    // The path is a filesystem path and must not reach the open routes.
    for ([_][]const u8{ "/api/version", "/api/health" }) |target| {
        try conn.request("GET", target, null, null);
        response = try conn.receive(&body_buf);
        try testing.expectEqual(@as(u16, 200), response.status);
        try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, managed_path));
        try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "authority"));
    }
}

test "W10 milestone 20: the settings envelope reports the authority and the open routes do not" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{
        .authority = .{ .managed_file = managed_path },
        .reconciled_at = 1_700_000_042,
    });
    defer env.destroy();

    try bounded(env.io(), default_budget, authorityEnvelope, .{ env.io(), env });
}

fn databaseAuthorityEnvelope(io: std.Io, env: *Env) anyerror!void {
    var body_buf: [16384]u8 = undefined;
    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    try conn.request("GET", "/api/settings", null, null);
    const response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);

    const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{});
    defer parsed.deinit();
    try testing.expectEqualStrings("database", parsed.value.authority.mode);
    try testing.expectEqual(@as(?[]const u8, null), parsed.value.authority.path);
    try testing.expectEqual(@as(?i64, null), parsed.value.authority.reconciled_at);

    // And nothing is rejected.
    try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
    const created = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 201), created.status);
}

test "W10 milestone 20: database authority reports null and writes normally" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{});
    defer env.destroy();

    try bounded(env.io(), default_budget, databaseAuthorityEnvelope, .{ env.io(), env });
}

// ---------------------------------------------------------------------------
// oversized cookie headers (ruling 7 of milestone 16)
// ---------------------------------------------------------------------------

/// Pads `list` past `http_util.max_cookie_len` with foreign cookies, the way a
/// reverse proxy on a shared domain does.
fn padCookies(gpa: Allocator, list: *std.ArrayList(u8)) !void {
    var index: usize = 0;
    while (list.items.len <= http_util.max_cookie_len * 2) : (index += 1) {
        try list.print(gpa, "ad_id_{d}=0123456789abcdef; ", .{index});
    }
}

fn oversizedCookie(io: std.Io, env: *Env) anyerror!void {
    var body_buf: [4096]u8 = undefined;

    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    try conn.request("POST", "/api/auth/login", null, "{\"password\":\"" ++ test_password ++ "\"}");
    var response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);
    const set_cookie = response.header("set-cookie") orelse return error.TestNoCookie;
    const pair_end = std.mem.findScalar(u8, set_cookie, ';') orelse set_cookie.len;

    var session_pair: [256]u8 = undefined;
    @memcpy(session_pair[0..pair_end], set_cookie[0..pair_end]);

    // The session pair buried in the middle of an over-budget header. Before
    // ruling 7 the whole header read as absent and this 401'd.
    var with_session: std.ArrayList(u8) = .empty;
    defer with_session.deinit(env.gpa);
    try with_session.appendSlice(env.gpa, "cookie: ");
    try padCookies(env.gpa, &with_session);
    try with_session.appendSlice(env.gpa, session_pair[0..pair_end]);
    try with_session.appendSlice(env.gpa, "; ");
    try padCookies(env.gpa, &with_session);
    try testing.expect(with_session.items.len > http_util.max_cookie_len);

    try conn.request("GET", "/api/groups", with_session.items, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);

    // The same size of header carrying no session pair stays unauthenticated:
    // the degradation keeps the session, it does not open the door.
    var without_session: std.ArrayList(u8) = .empty;
    defer without_session.deinit(env.gpa);
    try without_session.appendSlice(env.gpa, "cookie: ");
    try padCookies(env.gpa, &without_session);
    try testing.expect(without_session.items.len > http_util.max_cookie_len);

    try conn.request("GET", "/api/groups", without_session.items, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 401), response.status);
    try testing.expectEqualStrings("{\"error\":\"authentication required\"}", response.body);
}

test "W10 a 2 KiB cookie header keeps the session and still refuses without one" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var hash_buf: [256]u8 = undefined;
    const hash = try hashTestPassword(gpa, &hash_buf);

    var env = try Env.create(gpa, .{ .password_hash = hash });
    defer env.destroy();

    try bounded(env.io(), default_budget, oversizedCookie, .{ env.io(), env });
}

// ---------------------------------------------------------------------------
// rate limiting (ruling 19)
// ---------------------------------------------------------------------------

fn rateLimited(io: std.Io, env: *Env) anyerror!void {
    // Large enough for the /metrics exposition at the end.
    var body_buf: [64 * 1024]u8 = undefined;
    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    // Capacity 1: the first counted request spends the only token.
    try conn.request("GET", "/api/version", null, null);
    var response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);

    try conn.request("GET", "/api/version", null, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 429), response.status);
    try testing.expectEqualStrings("{\"error\":\"rate limited\"}", response.body);
    const retry_after = response.header("retry-after") orelse return error.TestNoRetryAfter;
    const seconds = try std.fmt.parseInt(u32, retry_after, 10);
    try testing.expect(seconds >= 1);

    // The monitoring endpoints never see 429 (ruling 19).
    try conn.request("GET", "/api/health", null, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);
    try conn.request("GET", "/metrics", null, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);
}

test "W10 a drained bucket answers 429 with Retry-After and spares monitoring" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{ .rate_per_min = 1, .localhost_exempt = false });
    defer env.destroy();

    try bounded(env.io(), default_budget, rateLimited, .{ env.io(), env });
}

fn proxiedRateLimit(io: std.Io, env: *Env) anyerror!void {
    var body_buf: [4096]u8 = undefined;
    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    // The socket peer is loopback and `api_localhost_exempt` is on, so without
    // the forwarded-for header the bucket is never consulted: capacity is 1 and
    // three requests in a row all pass.
    for (0..3) |_| {
        try conn.request("GET", "/api/version", null, null);
        const exempt = try conn.receive(&body_buf);
        try testing.expectEqual(@as(u16, 200), exempt.status);
    }

    // The same connection, now carrying what the trusted proxy appends: the
    // remote client is no longer loopback, so it spends its own token and the
    // second request is refused.
    try conn.request("GET", "/api/version", "x-forwarded-for: 203.0.113.9", null);
    var response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);

    try conn.request("GET", "/api/version", "x-forwarded-for: 203.0.113.9", null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 429), response.status);

    // A second remote client behind the same proxy has its own bucket.
    try conn.request("GET", "/api/version", "x-forwarded-for: 198.51.100.4", null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);

    // A chain whose last entry the proxy did not write is a 400, never a
    // silent fall back to the exempt loopback peer.
    try conn.request("GET", "/api/version", "x-forwarded-for: 203.0.113.9, nonsense", null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 400), response.status);
    try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "x-forwarded-for"));

    // The proxy itself is still exempt: its own unforwarded requests pass
    // after every bucket above was drained.
    try conn.request("GET", "/api/version", null, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);
}

test "W10 milestone 17: a proxied client is rate limited while the proxy stays exempt" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{
        .rate_per_min = 1,
        .localhost_exempt = true,
        .trusted_proxies = "127.0.0.1, ::1",
    });
    defer env.destroy();

    try bounded(env.io(), default_budget, proxiedRateLimit, .{ env.io(), env });
}

fn spoofedForwardedFor(io: std.Io, env: *Env) anyerror!void {
    var body_buf: [4096]u8 = undefined;
    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    // No proxy is trusted, so the header is inert: the loopback peer keeps its
    // exemption and no remote bucket is ever touched.
    for (0..3) |_| {
        try conn.request("GET", "/api/version", "x-forwarded-for: 203.0.113.9", null);
        const response = try conn.receive(&body_buf);
        try testing.expectEqual(@as(u16, 200), response.status);
    }

    // Not even a chain nxdns would refuse from a trusted proxy.
    try conn.request("GET", "/api/version", "x-forwarded-for: nonsense", null);
    const ignored = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), ignored.status);
}

fn proxiedSseBudget(io: std.Io, env: *Env) anyerror!void {
    var seen: std.ArrayList(u8) = .empty;
    defer seen.deinit(env.gpa);

    // One stream per address, and every connection arrives from loopback: keyed
    // on the socket peer these two would be one client and the second would be
    // refused.
    var first: Conn = undefined;
    try first.connect(io, env.addr);
    defer first.close(io);
    try first.request("GET", "/api/queries/live", "x-forwarded-for: 203.0.113.9", null);
    try testing.expectEqual(@as(u16, 200), (try first.receiveHead()).status);
    try first.readChunkedUntil(&seen, env.gpa, "retry: 3000");

    var second: Conn = undefined;
    try second.connect(io, env.addr);
    defer second.close(io);
    try second.request("GET", "/api/queries/live", "x-forwarded-for: 198.51.100.4", null);
    try testing.expectEqual(@as(u16, 200), (try second.receiveHead()).status);
    try second.readChunkedUntil(&seen, env.gpa, "retry: 3000");

    // The first client's own budget is spent, though.
    var again: Conn = undefined;
    try again.connect(io, env.addr);
    defer again.close(io);
    var body_buf: [1024]u8 = undefined;
    try again.request("GET", "/api/queries/live", "x-forwarded-for: 203.0.113.9", null);
    const refused = try again.receive(&body_buf);
    try testing.expectEqual(@as(u16, 429), refused.status);
}

test "W10 milestone 17: each proxied client holds its own SSE budget" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{
        .sse_max_per_ip = 1,
        .trusted_proxies = "127.0.0.1, ::1",
    });
    defer env.destroy();

    try bounded(env.io(), default_budget, proxiedSseBudget, .{ env.io(), env });
}

test "W10 milestone 17: a forwarded-for from an untrusted peer changes nothing" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{ .rate_per_min = 1, .localhost_exempt = true });
    defer env.destroy();

    try bounded(env.io(), default_budget, spoofedForwardedFor, .{ env.io(), env });
}

// ---------------------------------------------------------------------------
// SSE (ruling 20): preamble, event frame, heartbeat, per-address cap
// ---------------------------------------------------------------------------

fn sseStream(io: std.Io, env: *Env) anyerror!void {
    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    var seen: std.ArrayList(u8) = .empty;
    defer seen.deinit(env.gpa);
    errdefer std.debug.print("sse stream so far: {s}\n", .{seen.items});

    try conn.request("GET", "/api/queries/live", null, null);
    const head = try conn.receiveHead();
    try testing.expectEqual(@as(u16, 200), head.status);
    try testing.expectEqualStrings("text/event-stream", head.header("content-type").?);
    try conn.readChunkedUntil(&seen, env.gpa, "retry: 3000");

    // One query on the hot path reaches the open stream as one frame.
    env.hub.publish(io, .init(.{
        .timestamp = 1_700_000_000,
        .domain = "live.example",
        .client_ip = "192.0.2.99",
        .qtype = 1,
        .blocked = true,
        .block_reason = "blocklist_domain",
    }));
    try conn.readChunkedUntil(&seen, env.gpa, "event: query");
    try conn.readChunkedUntil(&seen, env.gpa, "\"domain\":\"live.example\"");
    try conn.readChunkedUntil(&seen, env.gpa, "\"blocked\":true");

    // The cap is per address and the environment allows one stream: a second
    // subscriber from the same address is refused while the first is open.
    var second: Conn = undefined;
    try second.connect(io, env.addr);
    defer second.close(io);
    var body_buf: [1024]u8 = undefined;
    try second.request("GET", "/api/queries/live", null, null);
    const refused = try second.receive(&body_buf);
    try testing.expectEqual(@as(u16, 429), refused.status);
    try testing.expect(std.mem.containsAtLeast(u8, refused.body, 1, "too many live streams"));

    // A quiet stream carries the heartbeat comment after the 15 s interval.
    try conn.readChunkedUntil(&seen, env.gpa, ": ping");
}

test "W10 SSE: retry preamble, query frame, per-address cap and heartbeat" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{ .sse_max_per_ip = 1 });
    defer env.destroy();

    try bounded(env.io(), sse_budget, sseStream, .{ env.io(), env });
}

/// Opens a live stream and reads the preamble, so the subscriber task is in
/// its wait loop by the time this returns.
fn openLiveStream(io: std.Io, env: *Env, conn: *Conn, seen: *std.ArrayList(u8)) anyerror!void {
    try conn.connect(io, env.addr);
    // Stays open on success — the caller closes it. Only a failure here leaves
    // a socket for this to reclaim.
    errdefer conn.close(io);

    try conn.request("GET", "/api/queries/live", null, null);
    const head = try conn.receiveHead();
    try testing.expectEqual(@as(u16, 200), head.status);
    try conn.readChunkedUntil(seen, env.gpa, "retry: 3000");
}

test "W10 shutdown with a live stream open does not wait out a heartbeat" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;

    // The teardown frees the environment's own `Io`, so the clock that times it
    // has to be somebody else's.
    var clock_threaded: std.Io.Threaded = .init(gpa, .{});
    defer clock_threaded.deinit();
    const clock_io = clock_threaded.io();

    var env = try Env.create(gpa, .{});

    var conn: Conn = undefined;
    var seen: std.ArrayList(u8) = .empty;
    defer seen.deinit(gpa);

    // Not a `defer`: the teardown is what this test measures, so it runs below
    // rather than after the assertion.
    bounded(env.io(), default_budget, openLiveStream, .{ env.io(), env, &conn, &seen }) catch |err| {
        env.destroy();
        return err;
    };

    // Closed before the teardown because the teardown frees the `Io` this
    // socket belongs to. It does not weaken the test: the subscriber task is
    // parked on a hub event, not on this socket, so closing the client end
    // does not wake it — only `Hub.close` does (ruling 11 of milestone 16).
    conn.close(env.io());

    const started = std.Io.Clock.awake.now(clock_io);
    env.destroy();
    const elapsed = started.durationTo(std.Io.Clock.awake.now(clock_io));

    // Before ruling 11 the drain waited out the full 15 s heartbeat interval.
    try testing.expect(elapsed.toMilliseconds() < 5_000);
}

// ---------------------------------------------------------------------------
// pagination walk (ruling 11)
// ---------------------------------------------------------------------------

fn paginationWalk(io: std.Io, env: *Env) anyerror!void {
    var arena_state: std.heap.ArenaAllocator = .init(env.gpa);
    defer arena_state.deinit();

    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    var body_buf: [64 * 1024]u8 = undefined;
    var target_buf: [64]u8 = undefined;
    var before: ?i64 = null;
    var total: usize = 0;
    var last_id: i64 = std.math.maxInt(i64);
    var pages: usize = 0;

    while (true) {
        const target = if (before) |cursor|
            try std.fmt.bufPrint(&target_buf, "/api/queries?limit=10&before={d}", .{cursor})
        else
            try std.fmt.bufPrint(&target_buf, "/api/queries?limit=10", .{});

        try conn.request("GET", target, null, null);
        const response = try conn.receive(&body_buf);
        try testing.expectEqual(@as(u16, 200), response.status);

        const page = try std.json.parseFromSliceLeaky(
            handlers_queries.Page,
            arena_state.allocator(),
            response.body,
            .{ .ignore_unknown_fields = false },
        );

        pages += 1;
        total += page.queries.len;
        for (page.queries) |row| {
            try testing.expect(row.id < last_id);
            last_id = row.id;
        }

        before = page.next_before orelse break;
        try testing.expect(pages < 10);
    }

    // 25 seeded rows walk as 10, 10 and 5, with the cursor ending exactly
    // after the third page.
    try testing.expectEqual(@as(usize, seeded_query_rows), total);
    try testing.expectEqual(@as(usize, 3), pages);
}

test "W10 keyset pagination walks the seeded log exactly once, newest first" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{});
    defer env.destroy();

    try bounded(env.io(), default_budget, paginationWalk, .{ env.io(), env });
}

// ---------------------------------------------------------------------------
// mutation → reload observed (ruling 12)
// ---------------------------------------------------------------------------

fn mutationReloads(io: std.Io, env: *Env) anyerror!void {
    // The environment's setup reload published generation 1.
    try testing.expectEqual(@as(u64, 1), try env.generation());

    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    var body_buf: [4096]u8 = undefined;
    try conn.request(
        "POST",
        "/api/rules",
        null,
        "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}",
    );
    var response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 201), response.status);

    // The write triggered a real `Manager.reload`: the published snapshot's
    // generation bumped and the rule is live in the pipeline the lookup reads.
    try testing.expectEqual(@as(u64, 2), try env.generation());

    try conn.request("GET", "/api/lookup?domain=ads.example", null, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);
    try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "\"blocked\":true"));
    try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "\"reason\":\"rule_block_exact\""));
}

test "W10 a rule mutation reloads the snapshot and the change is live" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{});
    defer env.destroy();

    try bounded(env.io(), default_budget, mutationReloads, .{ env.io(), env });
}

// ---------------------------------------------------------------------------
// deleting a source takes its compiled files with it (m13 ruling F-f)
// ---------------------------------------------------------------------------

/// The id in a `201 Created` body from `/api/blocklists`.
fn createdId(body: []const u8) !i64 {
    const marker = "\"id\":";
    const at = std.mem.indexOf(u8, body, marker) orelse return error.TestNoId;
    const rest = body[at + marker.len ..];
    const end = std.mem.indexOfNone(u8, rest, "0123456789") orelse rest.len;
    return std.fmt.parseInt(i64, rest[0..end], 10);
}

fn writeCompiled(io: std.Io, dir: std.Io.Dir, id: i64, body: []const u8) !void {
    var buf: [64]u8 = undefined;
    try dir.writeFile(io, .{
        .sub_path = try std.fmt.bufPrint(&buf, "{d}.list", .{id}),
        .data = body,
    });
    try dir.writeFile(io, .{
        .sub_path = try std.fmt.bufPrint(&buf, "{d}.wild", .{id}),
        .data = "",
    });
}

fn accessCompiled(io: std.Io, dir: std.Io.Dir, id: i64) !void {
    var buf: [64]u8 = undefined;
    return dir.access(io, try std.fmt.bufPrint(&buf, "{d}.list", .{id}), .{});
}

fn deleteSweepsCompiledFiles(io: std.Io, env: *Env) anyerror!void {
    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    var body_buf: [4096]u8 = undefined;
    try conn.request("POST", "/api/blocklists", null, "{\"url\":\"https://doomed.test/a.txt\",\"name\":\"doomed\"}");
    var response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 201), response.status);
    const doomed = try createdId(response.body);

    try conn.request("POST", "/api/blocklists", null, "{\"url\":\"https://kept.test/b.txt\",\"name\":\"kept\"}");
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 201), response.status);
    const kept = try createdId(response.body);

    // The files a refresh would have produced for each row. Neither row carries
    // a checksum, so the reload the delete runs treats both as never fetched
    // and reads neither — this case is about the directory, not the snapshot.
    _ = try env.tmp.dir.createDirPathStatus(io, "blocklists", .fromMode(0o700));
    var dir = try env.tmp.dir.openDir(io, "blocklists", .{ .iterate = true });
    defer dir.close(io);
    try writeCompiled(io, dir, doomed, "doomed.example\n");
    try writeCompiled(io, dir, kept, "kept.example\n");

    var target_buf: [64]u8 = undefined;
    const target = try std.fmt.bufPrint(&target_buf, "/api/blocklists/{d}", .{doomed});
    try conn.request("DELETE", target, null, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 204), response.status);

    // The row is gone, so its files are orphans; without a sweep on this path
    // they would sit here until a restart or the scheduler's next pass.
    var name_buf: [64]u8 = undefined;
    try testing.expectError(error.FileNotFound, dir.access(
        io,
        try std.fmt.bufPrint(&name_buf, "{d}.list", .{doomed}),
        .{},
    ));
    try testing.expectError(error.FileNotFound, dir.access(
        io,
        try std.fmt.bufPrint(&name_buf, "{d}.wild", .{doomed}),
        .{},
    ));
    try accessCompiled(io, dir, kept);
}

test "W10 deleting a blocklist deletes its compiled files and spares the others" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{});
    defer env.destroy();

    try bounded(env.io(), default_budget, deleteSweepsCompiledFiles, .{ env.io(), env });
}

// ---------------------------------------------------------------------------
// pause via the API changes a real handler decision (ruling 15)
// ---------------------------------------------------------------------------

/// A query for `domain`, RD set, one question, no OPT.
fn queryFor(buf: []u8, id: u16, domain: []const u8, qtype: types.Type) []const u8 {
    var w: std.Io.Writer = .fixed(buf);
    var encoded: [types.header_len]u8 = undefined;
    header.encode(.{
        .id = id,
        .flags = .{
            .rcode = .no_error,
            .z = 0,
            .ra = false,
            .rd = true,
            .tc = false,
            .aa = false,
            .opcode = .query,
            .qr = false,
        },
        .qdcount = 1,
        .ancount = 0,
        .nscount = 0,
        .arcount = 0,
    }, &encoded);
    w.writeAll(&encoded) catch unreachable;
    question.encode(.{
        .name = name.fromText(domain) catch unreachable,
        .qtype = qtype,
        .qclass = .in,
    }, &w) catch unreachable;
    return w.buffered();
}

/// Answers one A record for whatever it is asked; counts the calls so a test
/// can tell whether the filter let the query through.
const FakeUpstream = struct {
    calls: std.atomic.Value(u64) = .init(0),

    fn exchangeFn(
        ptr: *anyopaque,
        io: std.Io,
        query: []const u8,
        response_buf: []u8,
    ) transport.ExchangeError![]u8 {
        _ = io;
        const self: *FakeUpstream = @ptrCast(@alignCast(ptr));
        _ = self.calls.fetchAdd(1, .monotonic);

        const request = packet.parse(query) catch return error.BadResponse;
        const q = packet.firstQuestion(request) orelse return error.BadResponse;

        var b = packet.ResponseBuilder.init(response_buf, request.header, q) catch
            return error.ResponseTooLarge;
        b.addAnswer(q.name, .a, .in, 300, &.{ 93, 184, 216, 34 }) catch
            return error.ResponseTooLarge;
        return b.finish();
    }

    fn client(self: *FakeUpstream) transport.Client {
        return .{ .ptr = self, .exchangeFn = exchangeFn };
    }
};

fn pauseAffectsHandler(io: std.Io, env: *Env, h: *dns_handler.Handler) anyerror!void {
    var query_buf: [512]u8 = undefined;
    var response_buf: [512]u8 = undefined;
    var scratch: dns_handler.Scratch = undefined;
    const from = address.NetAddress.fromIp(.{ .ip4 = .loopback(53000) });
    const query = queryFor(&query_buf, 0x2222, "ads.example", .a);

    // Filtering on: the rule blocks, the upstream is never asked.
    const first = h.handle(io, .udp, from, query, &response_buf, &scratch);
    try testing.expect(first == .reply);
    try testing.expectEqual(@as(u64, 1), h.stats.blocked.load(.monotonic));

    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    var body_buf: [1024]u8 = undefined;
    try conn.request("POST", "/api/pause", null, "{\"paused\":true,\"duration_seconds\":600}");
    var response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);
    try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "\"paused\":true"));

    // The same query on the same handler now passes to the upstream: the API
    // write and the DNS path share one `Pause`.
    const second = h.handle(io, .udp, from, query, &response_buf, &scratch);
    try testing.expect(second == .reply);
    try testing.expectEqual(@as(u64, 1), h.stats.blocked.load(.monotonic));
    try testing.expectEqual(@as(u64, 1), h.stats.paused_queries.load(.monotonic));

    // And unpausing through the API restores the block.
    try conn.request("POST", "/api/pause", null, "{\"paused\":false}");
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);

    const third = h.handle(io, .udp, from, query, &response_buf, &scratch);
    try testing.expect(third == .reply);
    try testing.expectEqual(@as(u64, 2), h.stats.blocked.load(.monotonic));
}

test "W10 pause via the API flips a real handler's blocking decision" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{});
    defer env.destroy();
    const io = env.io();

    // A live blocking rule, through the same write path the UI uses.
    try env.config_db.exec(
        \\INSERT INTO rules (group_id, pattern, kind, action, created_at)
        \\VALUES (1, 'ads.example', 'exact', 'block', 1700000000)
    );
    try env.mgr.reload(io);

    var fake: FakeUpstream = .{};
    var h: dns_handler.Handler = .{
        .upstream = fake.client(),
        .blocking = .{ .mode = .zero, .ttl = 5 },
        .forward_read_timeout = .{ .raw = .fromSeconds(2), .clock = .awake },
        .manager = &env.mgr,
        .pause = &env.pauser,
    };

    try bounded(io, default_budget, pauseAffectsHandler, .{ io, env, &h });
    try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic));
}

// ---------------------------------------------------------------------------
// settings PUT round trip (ruling 16)
// ---------------------------------------------------------------------------

fn settingsRoundTrip(io: std.Io, env: *Env) anyerror!void {
    var arena_state: std.heap.ArenaAllocator = .init(env.gpa);
    defer arena_state.deinit();

    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    var body_buf: [32 * 1024]u8 = undefined;
    try conn.request("PUT", "/api/settings", null, "{\"dns\":{\"port\":5353},\"logging\":{\"level\":\"debug\"}}");
    var response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);

    try conn.request("GET", "/api/settings", null, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);

    const view = try std.json.parseFromSliceLeaky(
        SettingsView,
        arena_state.allocator(),
        response.body,
        .{ .ignore_unknown_fields = false },
    );
    try testing.expectEqual(@as(u16, 5353), view.settings.dns.port);
    try testing.expectEqualStrings("debug", view.settings.logging.level);
    try testing.expect(!view.settings.web.auth_enabled);

    // Every key is restart-required this milestone, the changed one included.
    var found = false;
    for (view.restart_required) |key| found = found or std.mem.eql(u8, key, "dns.port");
    try testing.expect(found);

    // A value the validator refuses changes nothing and answers 400.
    try conn.request("PUT", "/api/settings", null, "{\"logging\":{\"level\":\"chatty\"}}");
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 400), response.status);
}

test "W10 settings written through the API read back changed" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{});
    defer env.destroy();

    try bounded(env.io(), default_budget, settingsRoundTrip, .{ env.io(), env });
}

// ---------------------------------------------------------------------------
// password hashing outside config_lock (ruling 18 of milestone 16)
// ---------------------------------------------------------------------------

fn putNewPassword(io: std.Io, env: *Env) anyerror!void {
    var body_buf: [16 * 1024]u8 = undefined;
    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    try conn.request("PUT", "/api/settings", null, "{\"web\":{\"password\":\"" ++ test_password ++ "\"}}");
    const response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);
}

fn getWhileHashParked(io: std.Io, env: *Env) anyerror!void {
    var put = try io.concurrent(putNewPassword, .{ io, env });
    errdefer {
        handlers_settings.hash_stall_control.release(io);
        put.await(io) catch {};
    }

    handlers_settings.hash_stall_control.waitParked(io);

    // The whole point of the ruling: this read completes while the hash is
    // still held. Before the fix it blocked on `config_lock` until the hash
    // finished, and the seam would deadlock the test rather than answer.
    var body_buf: [16 * 1024]u8 = undefined;
    var reader: Conn = undefined;
    try reader.connect(io, env.addr);
    defer reader.close(io);

    try reader.request("GET", "/api/settings", null, null);
    const response = try reader.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);

    handlers_settings.hash_stall_control.release(io);
    try put.await(io);
}

test "W10 a settings read completes while a password hash is still running" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{});
    defer env.destroy();

    handlers_settings.hash_stall_control.arm();
    try bounded(env.io(), default_budget, getWhileHashParked, .{ env.io(), env });
}

// ---------------------------------------------------------------------------
// static assets: /, SPA fallback, ETag → 304 (ruling 24)
// ---------------------------------------------------------------------------

fn staticFlow(io: std.Io, env: *Env) anyerror!void {
    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    var body_buf: [256 * 1024]u8 = undefined;

    try conn.request("GET", "/", null, null);
    var response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);
    const content_type = response.header("content-type") orelse return error.TestNoContentType;
    try testing.expect(std.mem.startsWith(u8, content_type, "text/html"));
    const etag = response.header("etag") orelse return error.TestNoEtag;
    try testing.expect(std.mem.startsWith(u8, etag, "\""));

    var etag_buf: [256]u8 = undefined;
    const etag_line = try std.fmt.bufPrint(&etag_buf, "if-none-match: {s}", .{etag});

    // The same asset behind its own validator is 304 with no body.
    try conn.request("GET", "/", etag_line, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 304), response.status);
    try testing.expectEqual(@as(usize, 0), response.body.len);

    // An unknown non-/api path is the SPA's and serves index.html (200, not a
    // redirect); an unknown /api path stays a JSON 404.
    try conn.request("GET", "/some/client/route", null, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), response.status);

    try conn.request("GET", "/api/nope", null, null);
    response = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 404), response.status);
    try testing.expectEqualStrings("{\"error\":\"not found\"}", response.body);
}

test "W10 the embedded assets serve /, fall back for the SPA and honor ETag" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{ .fallback = static.fallback });
    defer env.destroy();

    try bounded(env.io(), default_budget, staticFlow, .{ env.io(), env });
}

// ---------------------------------------------------------------------------
// bodyless POST (W9's critical finding)
// ---------------------------------------------------------------------------

fn bodylessPost(io: std.Io, env: *Env) anyerror!void {
    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    // Exactly `curl -X POST`: no content-length, no transfer-encoding. RFC
    // 9110 gives this request an empty body; before the W9 fix it tripped the
    // `discardBody` assert in std (http/Server.zig:631) and took the whole
    // process down, DNS included.
    try conn.send("POST /api/auth/logout HTTP/1.1\r\nhost: t\r\n\r\n");

    var body_buf: [1024]u8 = undefined;
    const response = conn.receive(&body_buf) catch |err| {
        std.debug.print(
            "bodyless-POST regression: the server sent no well-formed response ({t}); " ++
                "the discardBody fix in web/server.zig has not landed\n",
            .{err},
        );
        return err;
    };
    try testing.expectEqual(@as(u16, 200), response.status);

    // The connection survives and the next request is answered.
    try conn.request("GET", "/api/version", null, null);
    const second = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), second.status);
}

test "W10 a POST with no body framing gets a response and keeps the connection" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var env = try Env.create(gpa, .{});
    defer env.destroy();

    try bounded(env.io(), default_budget, bodylessPost, .{ env.io(), env });
}

// ---------------------------------------------------------------------------
// drift guards (ruling 23 a and b)
// ---------------------------------------------------------------------------

// The route's path key must exist and its lowercase method key must sit
// inside that path's own block (from the key line to the next line at two
// spaces of indentation or less), so two documented paths cannot cover for
// each other's methods. Takes the document as a parameter so the negative
// test below can feed it a doctored copy.
fn yamlDocumentsRoute(yaml: []const u8, route: router.RouteInfo) error{ PathMissing, MethodMissing }!void {
    var key_buf: [128]u8 = undefined;
    const key = std.fmt.bufPrint(&key_buf, "\n  {s}:\n", .{route.pattern}) catch
        return error.PathMissing;
    const key_at = std.mem.indexOf(u8, yaml, key) orelse return error.PathMissing;

    var method_buf: [16]u8 = undefined;
    const method = std.fmt.bufPrint(&method_buf, "    {s}:", .{@tagName(route.method)}) catch
        return error.MethodMissing;
    const needle = std.ascii.lowerString(method_buf[0..method.len], method);

    var lines = std.mem.splitScalar(u8, yaml[key_at + key.len ..], '\n');
    while (lines.next()) |line| {
        if (line.len > 0 and !std.mem.startsWith(u8, line, "    ")) break;
        if (std.mem.eql(u8, line, needle)) return;
    }
    return error.MethodMissing;
}

test "drift guard a: every served route appears under its own path in openapi.yaml" {
    for (router.routes) |route| {
        yamlDocumentsRoute(openapi.yaml, route) catch |err| {
            std.debug.print(
                "openapi.yaml drift for {s} {s}: {t}\n",
                .{ @tagName(route.method), route.pattern, err },
            );
            return error.TestUnexpectedResult;
        };
    }
}

test "drift guard a bites: methods swapped between two documented paths fail the guard" {
    const gpa = testing.allocator;

    // Swap the only operation of /metrics (get) with the only operation of
    // /api/auth/logout (post). Both methods still appear somewhere in the
    // document and the operation count is unchanged, so a whole-document
    // substring check and guard b both stay green on this copy.
    const half = try std.mem.replaceOwned(
        u8,
        gpa,
        openapi.yaml,
        "\n  /metrics:\n    get:",
        "\n  /metrics:\n    post:",
    );
    defer gpa.free(half);
    const doctored = try std.mem.replaceOwned(
        u8,
        gpa,
        half,
        "\n  /api/auth/logout:\n    post:",
        "\n  /api/auth/logout:\n    get:",
    );
    defer gpa.free(doctored);
    try testing.expect(std.mem.containsAtLeast(u8, doctored, 1, "\n  /metrics:\n    post:"));
    try testing.expect(std.mem.containsAtLeast(u8, doctored, 1, "\n  /api/auth/logout:\n    get:"));

    var swapped_routes: usize = 0;
    for (router.routes) |route| {
        const swapped = (route.method == .GET and std.mem.eql(u8, route.pattern, "/metrics")) or
            (route.method == .POST and std.mem.eql(u8, route.pattern, "/api/auth/logout"));
        if (swapped) {
            try testing.expectError(error.MethodMissing, yamlDocumentsRoute(doctored, route));
            swapped_routes += 1;
        } else {
            try yamlDocumentsRoute(doctored, route);
        }
    }
    try testing.expectEqual(@as(usize, 2), swapped_routes);
}

// ---------------------------------------------------------------------------
// contract samples: the frontend's consumed shapes against real responses
// (milestone-17 ruling 5)
// ---------------------------------------------------------------------------
//
// The Zig side of the REST contract is already guarded: the table above parses
// every live response strictly, and the openapi guards below cover the routes.
// `web/src/lib/types.ts` was guarded by nothing — every frontend test stubs
// fetch, and types.ts is narrower than the wire in places (literal unions like
// `Health.status`), so re-parsing into Zig structs can never catch an
// out-of-union string.
//
// This walk drives the real `Env` server through every `.json` route the
// frontend reaches through an `api.ts` wrapper — the GETs and the JSON-returning
// writes — and renders the canonicalized bodies into a committed TypeScript
// file. TypeScript object literals get excess-property checking, so a server
// field missing from types.ts, a types.ts field missing from the wire, and an
// out-of-union literal all fail `npm run typecheck`.

/// One captured response. `ts_type` is the type argument `api.ts` hands to its
/// own `request<T>` for this endpoint — derived from that file, never invented.
const ContractSample = struct {
    name: []const u8,
    ts_type: []const u8,
    method: []const u8,
    target: []const u8,
    body: ?[]const u8 = null,
    status: u16,
};

/// Execution order is table order, and it is load-bearing twice over: a list
/// 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.
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 },
    .{ .name = "login", .ts_type = "LoginResponse", .method = "POST", .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200 },
    .{ .name = "logout", .ts_type = "LogoutResponse", .method = "POST", .target = "/api/auth/logout", .body = "{}", .status = 200 },

    // 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 = "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 },

    // 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 = "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 = "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 = "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 = "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 = "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 },

    // Upstreams. Row id 1 is seeded; the PUT leaves its url alone so the
    // 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 = "update_upstream", .ts_type = "UpstreamEcho", .method = "PUT", .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200 },
    .{ .name = "get_upstream_health", .ts_type = "UpstreamHealth", .method = "GET", .target = "/api/upstream/health", .status = 200 },

    // Query log and stats. `limit=5` reaches seeded row 21, the blocked one, so
    // the page carries both the null-bearing and the populated row shape.
    .{ .name = "get_queries", .ts_type = "QueriesPage", .method = "GET", .target = "/api/queries?limit=5", .status = 200 },
    .{ .name = "get_stats", .ts_type = "StatsTotals", .method = "GET", .target = "/api/stats?period=1h", .status = 200 },
    .{ .name = "get_stats_timeseries", .ts_type = "StatsTimeseries", .method = "GET", .target = "/api/stats/timeseries?period=1h", .status = 200 },

    // Pause: the GET before the POST, so one sample carries `until: null` and
    // the other the deadline.
    .{ .name = "get_pause", .ts_type = "PauseState", .method = "GET", .target = "/api/pause", .status = 200 },
    .{ .name = "post_pause", .ts_type = "PauseState", .method = "POST", .target = "/api/pause", .body = "{\"paused\":true,\"duration_seconds\":600}", .status = 200 },

    // Settings: the GET before any write, so `restart_required` is empty there
    // and populated in the PUT's echo.
    .{ .name = "get_settings", .ts_type = "SettingsEnvelope", .method = "GET", .target = "/api/settings", .status = 200 },
    .{ .name = "put_settings", .ts_type = "SettingsEnvelope", .method = "PUT", .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200 },

    // One sample per shared error class this environment can produce. 401 and
    // 429 need their own environments and follow below.
    .{ .name = "error_bad_request", .ts_type = "ErrorEnvelope", .method = "PUT", .target = "/api/settings", .body = "{\"logging\":{\"level\":\"chatty\"}}", .status = 400 },
    .{ .name = "error_conflict", .ts_type = "ErrorEnvelope", .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"https://dns.example/dns-query\"}", .status = 409 },
    .{ .name = "error_not_found", .ts_type = "ErrorEnvelope", .method = "GET", .target = "/api/nope", .status = 404 },
};

/// A session-authenticated environment answers this without a cookie.
const unauthorized_sample: ContractSample = .{
    .name = "error_unauthorized",
    .ts_type = "ErrorEnvelope",
    .method = "GET",
    .target = "/api/groups",
    .status = 401,
};

/// The second request on a one-token bucket.
const rate_limited_sample: ContractSample = .{
    .name = "error_rate_limited",
    .ts_type = "ErrorEnvelope",
    .method = "GET",
    .target = "/api/version",
    .status = 429,
};

/// `prettier` settings from web/package.json: tabs four columns wide, 120
/// columns. The generated file has to be a fixpoint of the repo's formatter or
/// CI's `npm run format:check` fails on it.
const ts_print_width = 120;
const ts_tab_width = 4;

/// Build identity, not contract data: `git_commit` comes from `-Dgit-commit`
/// and `zig_version` from the compiler that built the test, so keeping either
/// verbatim would pin the golden to one machine. Neither name occurs anywhere
/// else in the contract.
const volatile_string_keys = [_][]const u8{ "git_commit", "zig_version" };

fn writeTabs(w: *std.Io.Writer, depth: usize) !void {
    for (0..depth) |_| try w.writeByte('\t');
}

/// True when `prettier` would print this object key without quotes.
fn isTsIdentifier(text: []const u8) bool {
    if (text.len == 0) return false;
    if (!std.ascii.isAlphabetic(text[0]) and text[0] != '_' and text[0] != '$') return false;
    for (text[1..]) |byte| {
        if (!std.ascii.isAlphanumeric(byte) and byte != '_' and byte != '$') return false;
    }
    return true;
}

fn lessThanKey(_: void, a: []const u8, b: []const u8) bool {
    return std.mem.order(u8, a, b) == .lt;
}

/// Object keys sorted, every number 0, strings and booleans verbatim. The
/// canonical form is what makes the golden byte-stable across runs: the seed is
/// fixed, so only the numbers (row ids, timestamps, uptimes) move.
fn writeCanonical(arena: Allocator, w: *std.Io.Writer, value: std.json.Value, depth: usize) anyerror!void {
    switch (value) {
        .null => try w.writeAll("null"),
        .bool => |flag| try w.writeAll(if (flag) "true" else "false"),
        .integer, .float, .number_string => try w.writeAll("0"),
        .string => |text| try std.json.Stringify.value(text, .{}, w),
        .array => |list| try writeCanonicalArray(arena, w, list.items, depth),
        .object => |map| try writeCanonicalObject(arena, w, map, depth),
    }
}

fn writeCanonicalObject(
    arena: Allocator,
    w: *std.Io.Writer,
    map: std.json.ObjectMap,
    depth: usize,
) anyerror!void {
    if (map.count() == 0) return w.writeAll("{}");

    const keys = try arena.dupe([]const u8, map.keys());
    std.mem.sort([]const u8, keys, {}, lessThanKey);

    // An object that starts with a newline stays expanded under `prettier`, so
    // expanding every one of them is a fixpoint without measuring anything.
    try w.writeAll("{\n");
    for (keys) |key| {
        try writeTabs(w, depth + 1);
        if (isTsIdentifier(key)) try w.writeAll(key) else try std.json.Stringify.value(key, .{}, w);
        try w.writeAll(": ");
        var volatile_key = false;
        for (volatile_string_keys) |name_| volatile_key = volatile_key or std.mem.eql(u8, name_, key);
        if (volatile_key) {
            try w.writeAll("\"<build>\"");
        } else {
            try writeCanonical(arena, w, map.get(key).?, depth + 1);
        }
        try w.writeAll(",\n");
    }
    try writeTabs(w, depth);
    try w.writeAll("}");
}

fn writeCanonicalArray(
    arena: Allocator,
    w: *std.Io.Writer,
    items: []const std.json.Value,
    depth: usize,
) anyerror!void {
    if (items.len == 0) return w.writeAll("[]");

    // Elements that canonicalize identically witness the same shape, so only
    // the first of each is kept: a 60-bucket timeseries is 60 copies of one
    // object and would bury everything else in the file.
    var kept: std.ArrayList([]const u8) = .empty;
    var all_primitive = true;
    for (items) |item| {
        switch (item) {
            .array, .object => all_primitive = false,
            else => {},
        }
        var one: std.Io.Writer.Allocating = .init(arena);
        try writeCanonical(arena, &one.writer, item, depth + 1);
        const text = one.written();
        var seen = false;
        for (kept.items) |prior| seen = seen or std.mem.eql(u8, prior, text);
        if (!seen) try kept.append(arena, text);
    }

    if (all_primitive) {
        var width = depth * ts_tab_width + 2;
        for (kept.items, 0..) |text, index| width += text.len + @as(usize, if (index == 0) 0 else 2);
        if (width <= ts_print_width) {
            try w.writeAll("[");
            for (kept.items, 0..) |text, index| {
                if (index != 0) try w.writeAll(", ");
                try w.writeAll(text);
            }
            return w.writeAll("]");
        }
    }

    try w.writeAll("[\n");
    for (kept.items) |text| {
        try writeTabs(w, depth + 1);
        try w.writeAll(text);
        try w.writeAll(",\n");
    }
    try writeTabs(w, depth);
    try w.writeAll("]");
}

/// The pinned regeneration command, quoted verbatim in the file header and in
/// the failure message.
const regen_command =
    "zig build test -Dintegration -Dcontract-samples-out=\"$PWD/" ++ contract_samples.path ++ "\"";

/// Every capitalised identifier in the sample table's type expressions, sorted:
/// exactly the import list the generated file needs.
fn writeSampleImports(arena: Allocator, w: *std.Io.Writer) !void {
    var names: std.ArrayList([]const u8) = .empty;
    for (contract_sample_walk ++ [_]ContractSample{ unauthorized_sample, rate_limited_sample }) |sample| {
        var index: usize = 0;
        while (index < sample.ts_type.len) {
            if (!std.ascii.isUpper(sample.ts_type[index])) {
                index += 1;
                continue;
            }
            var end = index;
            while (end < sample.ts_type.len and std.ascii.isAlphanumeric(sample.ts_type[end])) end += 1;
            const word = sample.ts_type[index..end];
            var seen = false;
            for (names.items) |prior| seen = seen or std.mem.eql(u8, prior, word);
            if (!seen) try names.append(arena, word);
            index = end;
        }
    }
    std.mem.sort([]const u8, names.items, {}, lessThanKey);

    try w.writeAll("import type {\n");
    for (names.items) |word| try w.print("\t{s},\n", .{word});
    try w.writeAll("} from \"@/lib/types\";\n");
}

fn writeSampleHeader(arena: Allocator, w: *std.Io.Writer) !void {
    try w.writeAll(
        \\// Generated file — do not edit by hand.
        \\//
        \\// Every value below is a real response from the web server, captured by the
        \\// contract-sample test in src/web/web_integration_test.zig and canonicalized:
        \\// object keys sorted, every number 0, strings and booleans as the deterministic
        \\// seed produced them, repeated array elements collapsed to the first. The type
        \\// annotations are the ones api.ts hands to its own `request<T>`, so `tsc`
        \\// refuses a field the wire does not send, a wire field types.ts does not
        \\// declare, and a string outside a literal union.
        \\//
        \\// Regenerate with:
        \\//
    );
    try w.print("   {s}\n\n", .{regen_command});
    try writeSampleImports(arena, w);
}

/// Sends one sample's request on `conn` and appends its canonical rendering.
fn captureSample(
    gpa: Allocator,
    conn: *Conn,
    out: *std.Io.Writer,
    sample: ContractSample,
    body_buf: []u8,
) anyerror!void {
    try conn.request(sample.method, sample.target, null, sample.body);
    const response = try conn.receive(body_buf);
    if (response.status != sample.status) {
        std.debug.print(
            "contract sample {s} ({s} {s}): expected {d}, got {d} body {s}\n",
            .{ sample.name, sample.method, sample.target, sample.status, response.status, response.body },
        );
        return error.TestUnexpectedResult;
    }

    var arena_state: std.heap.ArenaAllocator = .init(gpa);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const value = std.json.parseFromSliceLeaky(std.json.Value, arena, response.body, .{}) catch |err| {
        std.debug.print("contract sample {s}: body is not JSON ({t}): {s}\n", .{ sample.name, err, response.body });
        return err;
    };

    try out.print("\nexport const sample_{s}: {s} = ", .{ sample.name, sample.ts_type });
    try writeCanonical(arena, out, value, 0);
    try out.writeAll(";\n");
}

fn sampleWalk(io: std.Io, env: *Env, out: *std.Io.Writer) anyerror!void {
    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    var body_buf: [128 * 1024]u8 = undefined;
    for (contract_sample_walk) |sample| try captureSample(env.gpa, &conn, out, sample, &body_buf);
}

fn sampleUnauthorized(io: std.Io, env: *Env, out: *std.Io.Writer) anyerror!void {
    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    var body_buf: [4096]u8 = undefined;
    try captureSample(env.gpa, &conn, out, unauthorized_sample, &body_buf);
}

fn sampleRateLimited(io: std.Io, env: *Env, out: *std.Io.Writer) anyerror!void {
    var conn: Conn = undefined;
    try conn.connect(io, env.addr);
    defer conn.close(io);

    var body_buf: [4096]u8 = undefined;
    // Capacity 1: the first counted request spends the only token.
    try conn.request("GET", "/api/version", null, null);
    const spent = try conn.receive(&body_buf);
    try testing.expectEqual(@as(u16, 200), spent.status);

    try captureSample(env.gpa, &conn, out, rate_limited_sample, &body_buf);
}

test "W10 milestone 17: the committed contract samples still describe live responses" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;

    var arena_state: std.heap.ArenaAllocator = .init(gpa);
    defer arena_state.deinit();

    var rendered: std.Io.Writer.Allocating = .init(gpa);
    defer rendered.deinit();
    try writeSampleHeader(arena_state.allocator(), &rendered.writer);

    {
        var env = try Env.create(gpa, .{});
        defer env.destroy();
        try bounded(env.io(), default_budget, sampleWalk, .{ env.io(), env, &rendered.writer });
    }
    {
        var hash_buf: [256]u8 = undefined;
        const hash = try hashTestPassword(gpa, &hash_buf);
        var env = try Env.create(gpa, .{ .password_hash = hash });
        defer env.destroy();
        try bounded(env.io(), default_budget, sampleUnauthorized, .{ env.io(), env, &rendered.writer });
    }
    {
        var env = try Env.create(gpa, .{ .rate_per_min = 1, .localhost_exempt = false });
        defer env.destroy();
        try bounded(env.io(), default_budget, sampleRateLimited, .{ env.io(), env, &rendered.writer });
    }

    if (build_options.contract_samples_out.len != 0) {
        var write_threaded: std.Io.Threaded = .init(gpa, .{});
        defer write_threaded.deinit();
        try std.Io.Dir.cwd().writeFile(write_threaded.io(), .{
            .sub_path = build_options.contract_samples_out,
            .data = rendered.written(),
        });
        std.debug.print("wrote {s}\n", .{build_options.contract_samples_out});
        return;
    }

    if (!std.mem.eql(u8, contract_samples.bytes, rendered.written())) {
        std.debug.print(
            "{s} no longer matches the live responses.\n" ++
                "The server and the frontend's types.ts have drifted, or the seed changed.\n" ++
                "Regenerate, then read the diff and `npm run typecheck`:\n  {s}\n",
            .{ contract_samples.path, regen_command },
        );
        return error.TestUnexpectedResult;
    }
}

test "drift guard b: openapi.yaml documents exactly as many operations as the router serves" {
    const yaml = openapi.yaml;
    const paths_start = std.mem.indexOf(u8, yaml, "\npaths:\n") orelse return error.TestUnexpectedResult;
    const paths_end = std.mem.indexOfPos(u8, yaml, paths_start, "\ncomponents:\n") orelse yaml.len;
    const paths = yaml[paths_start..paths_end];

    // Operations sit at exactly four spaces under their path key; nothing
    // else in the paths section occupies that indent with these names.
    var operations: usize = 0;
    var lines = std.mem.splitScalar(u8, paths, '\n');
    while (lines.next()) |line| {
        for ([_][]const u8{ "    get:", "    put:", "    post:", "    delete:" }) |needle| {
            if (std.mem.eql(u8, line, needle)) operations += 1;
        }
    }
    try testing.expectEqual(router.routes.len, operations);
}
