//! 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 cert_store = @import("../server/cert_store.zig"); const dns_cache = @import("../cache/dns_cache.zig"); const disk_monitor = @import("../storage/disk_monitor.zig"); const logging = @import("../platform/logging.zig"); const rate_limiter = @import("../server/rate_limiter.zig"); const retention_mod = @import("../storage/retention.zig"); const test_fixtures = @import("test_fixtures"); 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 events_mod = @import("../storage/events.zig"); const events_repo = @import("../storage/repositories/events_repo.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 logger_controller = @import("../storage/logger_controller.zig"); const logger_mod = @import("../storage/logger.zig"); const manager_mod = @import("../filter/manager.zig"); const migrations = @import("../storage/migrations.zig"); const model = @import("../config/model.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 coverage_mod = @import("coverage.zig"); const pool_mod = @import("../upstream/pool.zig"); const upstream_owner = @import("../upstream/owner.zig"); const provenance = @import("../storage/provenance.zig"); const provenance_view = @import("provenance_view.zig"); const queries_repo = @import("../storage/repositories/queries_repo.zig"); const querylog_schema = @import("../storage/querylog_schema.zig"); const query_sink = @import("../server/query_sink.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_config = @import("handlers/config.zig"); const handlers_diagnostics = @import("handlers/diagnostics.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_version = @import("handlers/version.zig"); const Certificate = std.crypto.Certificate; 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 // --------------------------------------------------------------------------- /// The rows the uniform loop writes, before the two provenance-rich ones the /// detail endpoint and the credential sweep read. const seeded_plain_query_rows = 25; const seeded_query_rows = seeded_plain_query_rows + 2; 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, /// False detaches the query log from the web state, which is the box a /// `logging.query_log = false` operator runs. Every query-log route then /// answers 503 rather than an empty page, which would be a lie. querylog: bool = true, /// Seeds a handful of rows inside the *live* period window, on top of the /// fixed 2023 seed. The stats windows are cut from the real clock, so an /// aggregation over a fixed seed is always an empty window — and an empty /// array witnesses no field at all. Only the tests that need populated /// aggregations ask for it: the rows are newer than every fixed row, so /// they would otherwise move the query-log page out from under its golden. recent_traffic: bool = false, /// Wires the collaborators a configuration write applies to: the DNS /// handler with its own cache and rate-limiter, a real upstream owner and /// the inputs to build a replacement generation, the disk monitor, the /// retention cell, a resizable query-log controller and both certificate /// stores. Off by default because every one of them shows up in `/metrics` /// and `/api/health`, and the goldens are cut from a box without them. live_apply: bool = false, }; /// The cwd-relative paths a `live_apply` environment writes into its tmp dir. /// They must not move after `init`: the certificate store and the query-log /// controller both borrow them. const LivePaths = struct { cert_buf: [192]u8 = undefined, key_buf: [192]u8 = undefined, cert2_buf: [192]u8 = undefined, key2_buf: [192]u8 = undefined, dot_cert_buf: [192]u8 = undefined, dot_key_buf: [192]u8 = undefined, querylog_buf: [192]u8 = undefined, log_buf: [std.Io.Dir.max_path_bytes]u8 = undefined, log2_buf: [std.Io.Dir.max_path_bytes]u8 = undefined, dir_buf: [192]u8 = undefined, abs_buf: [std.Io.Dir.max_path_bytes]u8 = undefined, cert: []const u8 = "", key: []const u8 = "", cert2: []const u8 = "", key2: []const u8 = "", dot_cert: []const u8 = "", dot_key: []const u8 = "", querylog: [:0]const u8 = "", log: []const u8 = "", log2: []const u8 = "", dir: []const u8 = "", /// The tmp directory as an absolute path. abs: []const u8 = "", fn fill(self: *LivePaths, io: std.Io, tmp: *testing.TmpDir) !void { try tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = test_fixtures.cert_pem }); try tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = test_fixtures.key_pem }); try tmp.dir.writeFile(io, .{ .sub_path = "cert2.pem", .data = test_fixtures.cert2_pem }); try tmp.dir.writeFile(io, .{ .sub_path = "key2.pem", .data = test_fixtures.key2_pem }); try tmp.dir.writeFile(io, .{ .sub_path = "dot_cert.pem", .data = test_fixtures.cert_pem }); try tmp.dir.writeFile(io, .{ .sub_path = "dot_key.pem", .data = test_fixtures.key_pem }); self.dir = try std.fmt.bufPrint(&self.dir_buf, ".zig-cache/tmp/{s}", .{tmp.sub_path}); self.cert = try std.fmt.bufPrint(&self.cert_buf, "{s}/cert.pem", .{self.dir}); self.key = try std.fmt.bufPrint(&self.key_buf, "{s}/key.pem", .{self.dir}); self.cert2 = try std.fmt.bufPrint(&self.cert2_buf, "{s}/cert2.pem", .{self.dir}); self.key2 = try std.fmt.bufPrint(&self.key2_buf, "{s}/key2.pem", .{self.dir}); self.dot_cert = try std.fmt.bufPrint(&self.dot_cert_buf, "{s}/dot_cert.pem", .{self.dir}); self.dot_key = try std.fmt.bufPrint(&self.dot_key_buf, "{s}/dot_key.pem", .{self.dir}); self.querylog = try std.fmt.bufPrintZ(&self.querylog_buf, "{s}/querylog-live.db", .{self.dir}); // `logging.file_path` must be absolute (the validator says so), and a // tmp dir is cwd-relative, so the sink's two targets are resolved here. try tmp.dir.createDirPath(io, "logs2"); const abs_len = try tmp.dir.realPath(io, &self.abs_buf); self.abs = self.abs_buf[0..abs_len]; self.log = try std.fmt.bufPrint(&self.log_buf, "{s}/nxdns.log", .{self.abs}); self.log2 = try std.fmt.bufPrint(&self.log2_buf, "{s}/logs2/nxdns.log", .{self.abs}); } }; /// 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, /// The diagnostics store's own connection, as in production: the store /// serializes every access through its mutex and shares it with nobody. events_db: db.Db, events_store: events_mod.Store, 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_slots: [1]pool_mod.Slot, pool_recoveries: std.atomic.Value(u64), pool: pool_mod.Pool, pool_owner: upstream_owner.Borrowed, /// Everything `live_apply` adds. Present in the struct either way — a Zig /// field cannot be conditional — and initialised only when the option asks /// for it, which `live` records. live: bool, paths: LivePaths, bundle: Certificate.Bundle, bundle_lock: std.Io.RwLock, live_owner: upstream_owner.Owner, cache: *dns_cache.DnsCache, dns_limiter: *rate_limiter.RateLimiter, monitor: disk_monitor.Monitor, retention_days: retention_mod.RetentionDays, retention: retention_mod.Retention, log_controller: logger_controller.Controller, sink: query_sink.QuerySink, handler: dns_handler.Handler, doh_store: cert_store.CertStore, dot_store: cert_store.CertStore, 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); if (options.recent_traffic) try seedRecentTraffic(&self.querylog_db, std.Io.Clock.real.now(ioh).toSeconds()); self.events_db = try db.Db.open(":memory:", .{ .mode = .memory }); errdefer self.events_db.close(); try db.applyPragmas(&self.events_db, .{}); _ = try migrations.migrate(&self.events_db); self.events_store = try events_mod.Store.init(ioh, &self.events_db, seeded_now); try seedEvents(ioh, &self.events_store, &self.events_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; // Never exchanged with: the pool feeds `/metrics` and the // `/api/health` upstream condition only. self.pool_slots = .{.{ .client = .{ .ptr = undefined, .exchangeFn = undefined } }}; self.pool_recoveries = .init(0); self.pool_entries = .{.{ .endpoint = transport.Endpoint.parse("https://dns.example/dns-query") catch unreachable, .slots = &self.pool_slots, .priority = 1, .enabled = true, .health = .init, .sem = .{ .permits = self.pool_slots.len }, .reuse_recoveries = &self.pool_recoveries, }}; self.pool_owner = .{}; self.pool = .init(&self.pool_entries, .{}, .{ .attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake }, .total = .{ .raw = .fromMilliseconds(100), .clock = .awake }, }, 1); self.live = options.live_apply; if (options.live_apply) try self.startLive(gpa, ioh); 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, }, .proxies = .init(options.trusted_proxies), .authority = options.authority, .reconciled_at = options.reconciled_at, .live_hash = .init(options.password_hash), .pause = &self.pauser, .manager = &self.mgr, .upstreams = self.pool_owner.pool(&self.pool), .local_tables = &self.tables, .sessions = &self.sessions, .limiter = &self.limiter, .hub = self.hub, .config_db = &self.config_db, .querylog_db = if (options.querylog) &self.querylog_db else null, .events = &self.events_store, .version = "w10-test", .started_unix = std.Io.Clock.real.now(ioh).toSeconds(), .fallback = options.fallback, .reload_fn = realReload, }; if (options.live_apply) { self.state.handler = &self.handler; self.state.upstreams = &self.live_owner; self.state.upstream_build = .{ .http = &self.http_client, .bundle = &self.bundle, .bundle_lock = &self.bundle_lock, }; self.state.monitor = &self.monitor; self.state.logger = &self.log_controller; self.state.retention = &self.retention; self.state.retention_days = &self.retention_days; self.state.sink = &self.sink; self.state.doh_certs = &self.doh_store; self.state.dot_certs = &self.dot_store; } 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; } /// The collaborators a configuration write applies to, wired the way /// `app.zig` wires them: a real upstream generation built from the seeded /// rows, a heap cache and rate-limiter the handler owns, a file-backed /// query-log controller that a resize can reopen, and both certificate /// stores over the fixture PEMs. fn startLive(self: *Env, gpa: Allocator, ioh: std.Io) !void { self.paths = .{}; try self.paths.fill(ioh, &self.tmp); self.bundle = .empty; self.bundle_lock = .init; var rows = try upstreams_repo.listUpstreamRows(&self.config_db, gpa); defer { upstreams_repo.freeUpstreamRows(gpa, rows.items); rows.deinit(gpa); } const servers = try gpa.alloc(model.UpstreamServer, rows.items.len); defer gpa.free(servers); for (rows.items, servers) |row, *slot| slot.* = .{ .url = row.url, .priority = row.priority, .enabled = row.enabled, .tls_name = row.tls_name, }; const built = try upstream_owner.build(.{ .gpa = gpa, .io = ioh, .servers = servers, .http = &self.http_client, .bundle = &self.bundle, .bundle_lock = &self.bundle_lock, .timeouts = .{ .attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake }, .total = .{ .raw = .fromMilliseconds(100), .clock = .awake }, }, .seed = 1, .diagnostics = &self.events_store, }); self.live_owner = .init(built); self.monitor = .init( .{}, self.tmp.dir, try gpa.dupeZ(u8, self.paths.dir), null, ); self.retention_days = .init(30); self.retention = .init(&self.retention_days); const opened = try querylog_schema.open(ioh, .cwd(), self.paths.querylog); try self.log_controller.init(ioh, .{ .gpa = gpa, .database = opened.database, .source = .{ .dir = .cwd(), .path = self.paths.querylog }, .logging = .{}, .monitor = &self.monitor, .diagnostics = &self.events_store, }); self.sink = .init(&self.log_controller, self.hub); self.cache = try gpa.create(dns_cache.DnsCache); self.cache.* = try .init(gpa, .{}); self.dns_limiter = try gpa.create(rate_limiter.RateLimiter); self.dns_limiter.* = try .init(gpa, .{ .limit = 1000, .window_seconds = 60 }); self.handler = .{ .upstream = &self.live_owner, .policy = .{ .blocking = .{ .mode = .zero, .ttl = 5 }, .forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake }, .negative_ttl_max = 3600, }, .manager = &self.mgr, .local_tables = &self.tables, .cache = self.cache, .limiter = self.dns_limiter, .sink = &self.sink, .pause = &self.pauser, }; self.doh_store = try cert_store.CertStore.init(gpa, ioh, self.paths.cert, self.paths.key, null); self.dot_store = try cert_store.CertStore.init(gpa, ioh, self.paths.dot_cert, self.paths.dot_key, null); } fn stopLive(self: *Env, gpa: Allocator, ioh: std.Io) void { self.doh_store.deinit(ioh); self.dot_store.deinit(ioh); self.log_controller.deinit(ioh); if (self.handler.replaceCache(ioh, null)) |live| { live.deinit(); gpa.destroy(live); } if (self.handler.replaceRateLimiter(ioh, null)) |live| { live.deinit(); gpa.destroy(live); } self.live_owner.deinit(ioh); gpa.free(self.monitor.data_path); self.monitor.deinit(ioh); self.bundle.deinit(gpa); } 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, }; if (self.live) self.stopLive(gpa, ioh); self.state.live_hash.deinit(gpa); self.state.proxies.deinit(gpa); self.tables.deinit(gpa); gpa.destroy(self.hub); self.limiter.deinit(); self.mgr.deinit(ioh); self.http_client.deinit(); self.events_db.close(); 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) ); } /// The oldest instant the seeded log is complete for. Pinned rather than taken /// from `unixepoch()`, which the schema's own seed uses: the contract samples /// are byte-compared, so a clock in `coverage.available_since` would make the /// golden a property of the machine that generated it. /// /// It equals the oldest seeded row's timestamp, so a request bounded at exactly /// this instant is complete and one bounded a second earlier is not. const seeded_available_since: i64 = 1_700_000_000; fn seedQueryLog(database: *db.Db) !void { try database.exec( \\UPDATE querylog_meta SET created_at = 1700000000, available_since = 1700000000 WHERE id = 1 ); var writer = try queries_repo.BatchWriter.init(database); defer writer.deinit(); var domain_buf: [32]u8 = undefined; var index: usize = 0; while (index < seeded_plain_query_rows) : (index += 1) { const domain = std.fmt.bufPrint(&domain_buf, "d{d}.example", .{index}) catch unreachable; const blocked = index % 5 == 0; // The three states the handler can actually produce (`Context.cacheHit` // and `route_kind` are set together): a blocked answer consulted no // cache and named no resolver, a cache hit named no resolver, and only // an upstream exchange did both. const from_cache = !blocked and index % 2 == 0; try writer.writeBatch(&.{.{ .timestamp = 1_700_000_000 + @as(i64, @intCast(index)), .domain = domain, .client_ip = "192.0.2.10", .qtype = 1, .qclass = 1, .rcode = 0, .blocked = blocked, .response_time_us = 250, .cache_hit = if (blocked) null else from_cache, .upstream = if (blocked or from_cache) null else "https://dns.example/dns-query", .group_id = 1, .group_name = "default", .policy_action = if (blocked) .block else .allow, .policy_reason = if (blocked) .blocklist_domain else .no_match, .matched = if (blocked) domain else null, .source_id = null, .source_name = null, .cname_target = null, .safe_search_target = null, .route_kind = if (blocked) .blocked else if (from_cache) .cache else .upstream, .forward_zone = null, }}); } // Two rows with provenance the loop above never produces, so the detail // endpoint and its contract sample have a real row to read. They are the // newest rows, so a first page shows them. try writer.writeBatch(&.{.{ .timestamp = 1_700_000_000 + seeded_plain_query_rows, .domain = "news.example", .client_ip = "192.0.2.10", .qtype = 1, .qclass = 1, .rcode = 0, .blocked = false, .response_time_us = 18_400, .cache_hit = false, .upstream = "https://dns.example/dns-query", .group_id = 1, .group_name = "default", .policy_action = .allow, .policy_reason = .no_match, .matched = null, .source_id = null, .source_name = null, .cname_target = null, .safe_search_target = null, .route_kind = .upstream, .forward_zone = null, }}); try writer.writeBatch(&.{.{ .timestamp = 1_700_000_000 + seeded_plain_query_rows + 1, .domain = "shop.example", .client_ip = "192.0.2.11", .qtype = 1, .qclass = 1, .rcode = 3, .blocked = true, .response_time_us = 900, .cache_hit = null, .upstream = null, .group_id = 2, .group_name = "kids", .policy_action = .block, .policy_reason = .blocklist_wildcard, .matched = "||tracker.example^", .source_id = 4, .source_name = "StevenBlack", .cname_target = "cdn.tracker.example", .safe_search_target = null, .route_kind = .blocked, .forward_zone = null, }}); } /// The matrix the three period aggregations are read against: three clients, /// three query types including a row with none, five route kinds, two named /// upstreams and one upstream row whose resolver the log did not record. /// /// `now` is the real clock, so these rows land in the live window of every /// period. Only their timestamps come from it; the counts are fixed, and the /// contract samples canonicalize every number to zero anyway. const recent_clients = 3; fn seedRecentTraffic(database: *db.Db, now: i64) !void { var writer = try queries_repo.BatchWriter.init(database); defer writer.deinit(); const Shape = struct { client: []const u8, qtype: ?u16, kind: provenance.RouteKind, source: ?[]const u8, }; const shapes = [_]Shape{ .{ .client = "192.0.2.30", .qtype = 1, .kind = .upstream, .source = "https://dns.example/dns-query" }, .{ .client = "192.0.2.30", .qtype = 1, .kind = .upstream, .source = "https://dns.example/dns-query" }, .{ .client = "192.0.2.30", .qtype = 28, .kind = .upstream, .source = "https://dns2.example/dns-query" }, .{ .client = "192.0.2.30", .qtype = 1, .kind = .upstream, .source = null }, .{ .client = "192.0.2.31", .qtype = 28, .kind = .blocked, .source = null }, .{ .client = "192.0.2.31", .qtype = 1, .kind = .cache, .source = null }, .{ .client = "192.0.2.31", .qtype = null, .kind = .local, .source = null }, .{ .client = "192.0.2.32", .qtype = 1, .kind = .forward_zone, .source = "lan" }, .{ .client = "192.0.2.32", .qtype = 1, .kind = .rejected, .source = null }, }; for (shapes, 0..) |shape, index| { // Inside the narrowest bucket of the narrowest period, so every period // sees the whole matrix however close to a boundary the clock is. try writer.writeBatch(&.{.{ .timestamp = now - @as(i64, @intCast(index)) - 1, .domain = "recent.example", .client_ip = shape.client, .qtype = shape.qtype, .qclass = 1, .rcode = 0, .blocked = shape.kind == .blocked, .response_time_us = 1500, .cache_hit = shape.kind == .cache, .upstream = if (shape.kind == .upstream) shape.source else null, .group_id = 1, .group_name = "default", .policy_action = if (shape.kind == .blocked) .block else .allow, .policy_reason = if (shape.kind == .blocked) .blocklist_domain else .no_match, .matched = null, .source_id = null, .source_name = null, .cname_target = null, .safe_search_target = null, .route_kind = shape.kind, .forward_zone = if (shape.kind == .forward_zone) shape.source else null, }}); } } /// A fixed instant, like every other seeded timestamp here: the contract /// samples are byte-compared, so nothing the walk writes may come from a clock. const seeded_now: i64 = 1_787_118_000; /// One active episode and one resolved one, so `/api/diagnostics` answers with /// both states and the committed contract sample describes a real page rather /// than an empty one. fn seedEvents(io: std.Io, store: *events_mod.Store, database: *db.Db) !void { store.report(io, seeded_now, .blocklist_refresh, "https://lists.example/ads.txt", "StevenBlack", .warning, "download failed: ConnectionTimedOut"); store.report(io, seeded_now + 300, .blocklist_refresh, "https://lists.example/ads.txt", "StevenBlack", .warning, "download failed: ConnectionTimedOut"); // A legacy code no producer emits any more. Rows written by an m29 process // survive, and the read path has to keep passing their code through — this // is the resolved episode that proves it. Written through the repository // because the emitter enum no longer has the code at all. const legacy = events_mod.legacy_wire_codes[0]; _ = try events_repo.insertActive(database, seeded_now + 60, legacy, "history", "history", "warning", "Busy"); _ = try events_repo.resolveActiveByCode(database, seeded_now + 120, legacy); } // --------------------------------------------------------------------------- // 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, query_log_flush_interval_s: u16, 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, }; 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/{id}", .auth = .session, .policy = .read, .target = "/api/queries/27", .status = 200, .check = jsonShape(provenance_view.QueryDetail) }, .{ .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/stats/types", .auth = .session, .policy = .read, .target = "/api/stats/types?period=1h", .status = 200, .check = jsonShape(handlers_stats.TypesBody) }, .{ .method = .GET, .pattern = "/api/stats/routes", .auth = .session, .policy = .read, .target = "/api/stats/routes?period=1h", .status = 200, .check = jsonShape(handlers_stats.RoutesBody) }, .{ .method = .GET, .pattern = "/api/stats/clients", .auth = .session, .policy = .read, .target = "/api/stats/clients?period=1h", .status = 200, .check = jsonShape(handlers_stats.ClientsBody) }, // Diagnostics. The seeded store holds one active episode (id 1) and one // resolved one, so both the page and the detail answer with real rows. .{ .method = .GET, .pattern = "/api/diagnostics", .auth = .session, .policy = .read, .target = "/api/diagnostics?limit=10", .status = 200, .check = jsonShape(events_mod.EventsPage) }, .{ .method = .GET, .pattern = "/api/diagnostics/{id}", .auth = .session, .policy = .read, .target = "/api/diagnostics/1", .status = 200, .check = jsonShape(events_mod.Event) }, // The purges follow the reads: id 2 is the seeded resolved episode, and the // sweep after it takes whatever resolved history is left (none). .{ .method = .DELETE, .pattern = "/api/diagnostics/{id}", .auth = .session, .policy = .runtime_action, .target = "/api/diagnostics/2", .status = 204, .kind = .none }, .{ .method = .DELETE, .pattern = "/api/diagnostics", .auth = .session, .policy = .runtime_action, .target = "/api/diagnostics", .status = 200, .check = jsonShape(handlers_diagnostics.PurgeResult) }, // 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 = .GET, .pattern = "/api/config/status", .auth = .session, .policy = .read, .target = "/api/config/status", .status = 200, .check = jsonShape(handlers_config.View) }, .{ .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 loginCookieTtl(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 environment's table is built with the 24-hour default. 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); var set_cookie = response.header("set-cookie") orelse return error.TestNoCookie; try testing.expect(std.mem.containsAtLeast(u8, set_cookie, 1, "Max-Age=86400")); // A live TTL change reaches the next login's cookie, with no restart. env.sessions.setTtl(io, 3); 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); set_cookie = response.header("set-cookie") orelse return error.TestNoCookie; try testing.expect(std.mem.containsAtLeast(u8, set_cookie, 1, "Max-Age=10800")); } test "W10 a fresh login's cookie Max-Age reflects the live session ttl" { 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, loginCookieTtl, .{ 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 fileModeConfigWrites(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 configuration write answers the one envelope — enumerated, not // sampled. The cases come from the contract table because each entry // carries a target and a body the handler would accept: a request the // handler would reject anyway could answer 400 and still look like a pass. var enumerated: usize = 0; for (contract) |entry| { if (entry.policy != .config_write) continue; enumerated += 1; try conn.request(@tagName(entry.method), entry.target, null, entry.body); response = try conn.receive(&body_buf); errdefer std.debug.print( "{t} {s}: {d} {s}\n", .{ entry.method, entry.target, response.status, response.body }, ); try testing.expectEqual(@as(u16, 403), response.status); try testing.expectEqualStrings(managed_body, response.body); try testing.expectEqualStrings("application/json", response.header("content-type").?); } // The served table is the authority on what a configuration write is, so a // route added there cannot ship without a case here. var served: usize = 0; for (router.routes) |route| { if (route.policy == .config_write) served += 1; } try testing.expectEqual(served, enumerated); // Rejected before the handler, not after it: the group was never created. try conn.request("GET", "/api/groups", null, null); response = try conn.receive(&body_buf); 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); // Diagnostics are runtime state, not configuration: purging resolved // history is served under file authority like any other runtime action. try conn.request("DELETE", "/api/diagnostics", null, null); response = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 200), response.status); try testing.expectEqualStrings("{\"purged\":1}", response.body); try conn.request("DELETE", "/api/diagnostics/1", null, null); response = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 409), response.status); } fn diagnosticsRejections(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); // Every bad parameter is a 400 whose message names the parameter, rather // than a filter silently dropped — which would answer a question the client // did not ask. const bad = [_]struct { target: []const u8, needle: []const u8 }{ .{ .target = "/api/diagnostics?state=open", .needle = "state" }, .{ .target = "/api/diagnostics?severity=info", .needle = "severity" }, .{ .target = "/api/diagnostics?since=yesterday", .needle = "since" }, .{ .target = "/api/diagnostics?until=", .needle = "until" }, .{ .target = "/api/diagnostics?limit=0", .needle = "limit" }, .{ .target = "/api/diagnostics?limit=1001", .needle = "limit" }, .{ .target = "/api/diagnostics?before=0", .needle = "before" }, }; for (bad) |case| { try conn.request("GET", case.target, null, null); const response = try conn.receive(&body_buf); errdefer std.debug.print("{s}: {d} {s}\n", .{ case.target, response.status, response.body }); try testing.expectEqual(@as(u16, 400), response.status); try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, case.needle)); } // The resolved episode the seed left behind is reachable by id, and an id // nothing holds is a 404 rather than an empty object. try conn.request("GET", "/api/diagnostics?state=resolved", null, null); 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, "upstream_history.write")); try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "\"active\":{\"warnings\":1,\"errors\":0}")); try conn.request("GET", "/api/diagnostics/999999", null, null); response = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 404), response.status); } fn diagnosticsPurge(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); // Row 1 is the seeded active episode: still the state of the box, so the // purge is refused with a message that says what would change that. try conn.request("DELETE", "/api/diagnostics/1", null, null); var response = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 409), response.status); try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "still active")); try conn.request("DELETE", "/api/diagnostics/999999", null, null); response = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 404), response.status); // Row 2 is the seeded resolved episode. try conn.request("DELETE", "/api/diagnostics/2", null, null); response = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 204), response.status); try testing.expectEqualStrings("", response.body); // Gone is a different answer from still open, even for a row that existed a // moment ago. try conn.request("DELETE", "/api/diagnostics/2", null, null); response = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 404), response.status); // Nothing resolved is left, and the sweep says so rather than failing. try conn.request("DELETE", "/api/diagnostics", null, null); response = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 200), response.status); try testing.expectEqualStrings("{\"purged\":0}", response.body); // The active episode survived every one of those, counts included. try conn.request("GET", "/api/diagnostics", 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, "blocklist.refresh")); try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "\"active\":{\"warnings\":1,\"errors\":0}")); try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "upstream_history.write")); } test "W10 milestone 27: a purge takes resolved events only, and says which of the three answers it gave" { 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, diagnosticsPurge, .{ env.io(), env }); } fn diagnosticsPurgeAll(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 second resolved episode, so the count the sweep reports is a number it // had to compute rather than the one row the seed leaves. env.events_store.reportResolved(io, seeded_now, .query_log_recreated, "one-shot", "corrupt", .warning, "aside"); try conn.request("DELETE", "/api/diagnostics", null, null); var response = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 200), response.status); try testing.expectEqualStrings("{\"purged\":2}", response.body); try conn.request("GET", "/api/diagnostics?state=resolved", 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, "\"events\":[]")); // And the episode that is still failing is untouched: the operator clearing // the page cannot lose what is still true. try conn.request("GET", "/api/diagnostics?state=active", null, null); response = try conn.receive(&body_buf); try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "blocklist.refresh")); } test "W10 milestone 27: purging all resolved events counts them and leaves the active ones" { 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, diagnosticsPurgeAll, .{ env.io(), env }); } test "W10 milestone 27: every diagnostics filter names itself in a 400, and an unknown id is a 404" { 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, diagnosticsRejections, .{ env.io(), env }); } 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, fileModeConfigWrites, .{ 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 }); } // --------------------------------------------------------------------------- // `GET /api/config/status`: the authority and the pending restart (milestone 32) // --------------------------------------------------------------------------- /// One probe of the status route. `extra_header` carries a session cookie once /// a test has turned authentication on. fn configStatus( gpa: Allocator, conn: *Conn, body_buf: []u8, extra_header: ?[]const u8, ) !std.json.Parsed(handlers_config.View) { try conn.request("GET", "/api/config/status", extra_header, null); const response = try conn.receive(body_buf); try testing.expectEqual(@as(u16, 200), response.status); return std.json.parseFromSlice(handlers_config.View, gpa, response.body, .{}); } fn restartPending(gpa: Allocator, conn: *Conn, body_buf: []u8, extra_header: ?[]const u8) !bool { const parsed = try configStatus(gpa, conn, body_buf, extra_header); defer parsed.deinit(); return parsed.value.restart_pending; } /// Logs in with `test_password` and renders the session as a `cookie:` header /// line. Needed by the two password patches below: setting a password revokes /// every session and turns authentication on, so the probe that follows one /// has to carry a fresh cookie. fn loginHeader(conn: *Conn, header_buf: []u8, body_buf: []u8) ![]const u8 { try conn.request("POST", "/api/auth/login", null, "{\"password\":\"" ++ test_password ++ "\"}"); const 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; return std.fmt.bufPrint(header_buf, "cookie: {s}", .{set_cookie[0..pair_end]}); } /// One mutation that must move the flag, with whatever the fresh database owes /// it beforehand. const Setter = struct { method: []const u8, target: []const u8, body: ?[]const u8 = null, status: u16, /// Rows the scenario needs, inserted into the fresh config database before /// the server is probed. seed: [:0]const u8 = "", }; fn setterRaisesFlag(io: std.Io, env: *Env, setter: Setter) anyerror!void { var body_buf: [16 * 1024]u8 = undefined; var conn: Conn = undefined; try conn.connect(io, env.addr); defer conn.close(io); // The false→true transition is the claim, so the "before" reading is part // of the proof: a probe taken while the flag is already true says nothing. try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null)); try conn.request(setter.method, setter.target, null, setter.body); const response = try conn.receive(&body_buf); errdefer std.debug.print("{s} {s}: {d} {s}\n", .{ setter.method, setter.target, response.status, response.body }); try testing.expectEqual(setter.status, response.status); try testing.expect(try restartPending(env.gpa, &conn, &body_buf, null)); } test "W10 milestone 34: only a bind or web-lifecycle key raises the flag, each from its own boot" { if (!build_options.integration) return error.SkipZigTest; const gpa = testing.allocator; // A server per setter. The flag is process state nothing clears, so proving // a second setter raises it needs a process that has never raised it — and // the fresh boot is also the proof that a restart clears it. const setters = [_]Setter{ .{ .method = "PUT", .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200 }, .{ .method = "PUT", .target = "/api/settings", .body = "{\"web\":{\"port\":9090}}", .status = 200 }, .{ .method = "PUT", .target = "/api/settings", .body = "{\"doh_server\":{\"enabled\":true}}", .status = 200 }, .{ .method = "PUT", .target = "/api/settings", .body = "{\"web\":{\"enabled\":false}}", .status = 200 }, }; for (setters) |setter| { var env = try Env.create(gpa, .{}); defer env.destroy(); if (setter.seed.len != 0) try env.config_db.exec(setter.seed); try bounded(env.io(), default_budget, setterRaisesFlag, .{ env.io(), env, setter }); } // Milestone 34: an upstream write is applied in-process, and so is every // settings key that is not one of the twelve above. None of them owes the // operator a restart, and this is the boot that proves it. const live = [_]Setter{ .{ .method = "PUT", .target = "/api/settings", .body = "{\"blocking\":{\"ttl\":42}}", .status = 200 }, .{ .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201 }, .{ .method = "PUT", .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200 }, .{ .method = "DELETE", .target = "/api/upstreams/2", .status = 204, // The seeded row 1 stays enabled, so removing this one is not the // last-enabled-upstream conflict. .seed = \\INSERT INTO upstreams (id, url, priority, enabled, tls_name) \\VALUES (2, 'https://dns2.example/dns-query', 100, 1, '') , }, }; for (live) |setter| { var env = try Env.create(gpa, .{}); defer env.destroy(); if (setter.seed.len != 0) try env.config_db.exec(setter.seed); try bounded(env.io(), default_budget, setterLeavesFlagAlone, .{ env.io(), env, setter }); } } /// The mirror of `setterRaisesFlag`: a mutation that is applied in-process /// must answer its documented status and leave the flag where it found it. fn setterLeavesFlagAlone(io: std.Io, env: *Env, setter: Setter) anyerror!void { var body_buf: [16 * 1024]u8 = undefined; var conn: Conn = undefined; try conn.connect(io, env.addr); defer conn.close(io); try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null)); try conn.request(setter.method, setter.target, null, setter.body); const response = try conn.receive(&body_buf); errdefer std.debug.print("{s} {s}: {d} {s}\n", .{ setter.method, setter.target, response.status, response.body }); try testing.expectEqual(setter.status, response.status); try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null)); } fn passwordOnlyPatch(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 testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null)); 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); // The patch revoked every session and turned authentication on, so the // probe needs a cookie of its own. var header_buf: [256]u8 = undefined; const cookie = try loginHeader(&conn, &header_buf, &body_buf); // A password applies live (ruling 17 of milestone 16). Nothing is owed. try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, cookie)); } test "W10 milestone 32: a password-only patch owes no restart" { 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, passwordOnlyPatch, .{ env.io(), env }); } fn mixedPatch(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 testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null)); try conn.request( "PUT", "/api/settings", null, "{\"web\":{\"password\":\"" ++ test_password ++ "\"},\"dns\":{\"port\":5353}}", ); const response = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 200), response.status); var header_buf: [256]u8 = undefined; const cookie = try loginHeader(&conn, &header_buf, &body_buf); // The password half applies live; the `dns.port` half does not, and one // restart-required key in the patch is enough. try testing.expect(try restartPending(env.gpa, &conn, &body_buf, cookie)); } test "W10 milestone 32: a patch carrying a password and a restart-required key raises the flag" { 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, mixedPatch, .{ env.io(), env }); } fn rejectedMutations(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); // Nothing was committed, so nothing is owed. The SQL-layer half of this — // a write that begins its transaction and then fails — is pinned in // handlers/settings.zig, where the write-fault seam lives. const rejected = [_]Setter{ .{ .method = "PUT", .target = "/api/settings", .body = "{\"logging\":{\"level\":\"chatty\"}}", .status = 400 }, .{ .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"udp://1.1.1.1:53\"}", .status = 400 }, .{ .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"https://dns.example/dns-query\"}", .status = 409 }, .{ .method = "DELETE", .target = "/api/upstreams/1", .status = 409 }, .{ .method = "DELETE", .target = "/api/upstreams/999", .status = 404 }, }; for (rejected) |attempt| { try conn.request(attempt.method, attempt.target, null, attempt.body); const response = try conn.receive(&body_buf); errdefer std.debug.print("{s} {s}: {d} {s}\n", .{ attempt.method, attempt.target, response.status, response.body }); try testing.expectEqual(attempt.status, response.status); try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null)); } } test "W10 milestone 32: a refused mutation leaves the flag alone" { 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, rejectedMutations, .{ env.io(), env }); } fn fileModeStatus(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); { const parsed = try configStatus(env.gpa, &conn, &body_buf, null); defer parsed.deinit(); try testing.expectEqualStrings("managed_file", parsed.value.authority); try testing.expectEqualStrings(managed_path, parsed.value.path.?); try testing.expectEqual(@as(?i64, 1_700_000_042), parsed.value.reconciled_at); try testing.expect(!parsed.value.restart_pending); } // The write the flag would follow never reaches its handler, so the flag // cannot rise in file mode at all. try conn.request("PUT", "/api/settings", null, "{\"dns\":{\"port\":5353}}"); const refused = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 403), refused.status); try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null)); // 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); const 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")); } // And the settings envelope no longer carries a second copy of it. try conn.request("GET", "/api/settings", null, null); const settings_response = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 200), settings_response.status); try testing.expect(!std.mem.containsAtLeast(u8, settings_response.body, 1, "authority")); const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, settings_response.body, .{}); defer parsed.deinit(); } test "W10 milestone 32: file mode reports the file, owes no restart, and keeps the path off the open routes" { 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, fileModeStatus, .{ env.io(), env }); } fn databaseModeStatus(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); const parsed = try configStatus(env.gpa, &conn, &body_buf, null); defer parsed.deinit(); try testing.expectEqualStrings("database", parsed.value.authority); try testing.expectEqual(@as(?[]const u8, null), parsed.value.path); try testing.expectEqual(@as(?i64, null), parsed.value.reconciled_at); try testing.expect(!parsed.value.restart_pending); // 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); // A live-resource write is not a restart: groups take effect at once. try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null)); } test "W10 milestone 32: a fresh database-mode boot reports database, nulls and no pending restart" { 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, databaseModeStatus, .{ 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, .qclass = 1, .blocked = true, .policy_action = .block, .policy_reason = .blocklist_domain, .route_kind = .blocked, })); try conn.readChunkedUntil(&seen, env.gpa, "event: query"); try conn.readChunkedUntil(&seen, env.gpa, "\"domain\":\"live.example\""); try conn.readChunkedUntil(&seen, env.gpa, "\"reason\":\"blocklist_domain\""); // 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); } // 27 seeded rows walk as 10, 10 and 7, 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 }); } // --------------------------------------------------------------------------- // query provenance: the detail endpoint, coverage, and the credential sweep // (milestone 28) // --------------------------------------------------------------------------- /// The id of the seeded CNAME-uncloaked block, which is the last row written. const seeded_detail_id = seeded_query_rows; fn detailWalk(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; const target = try std.fmt.bufPrint(&target_buf, "/api/queries/{d}", .{seeded_detail_id}); try conn.request("GET", target, null, null); const response = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 200), response.status); const detail = try std.json.parseFromSliceLeaky( provenance_view.QueryDetail, arena_state.allocator(), response.body, .{ .ignore_unknown_fields = false }, ); try testing.expectEqual(@as(i64, seeded_detail_id), detail.id); try testing.expectEqualStrings("shop.example", detail.request.domain); try testing.expectEqualStrings("192.0.2.11", detail.request.client); try testing.expectEqual(@as(u16, 1), detail.request.qclass); try testing.expectEqual(@as(?i64, 2), detail.group.id); try testing.expectEqualStrings("kids", detail.group.name); try testing.expectEqual(provenance.PolicyAction.block, detail.policy.action); try testing.expectEqual(provenance.PolicyReason.blocklist_wildcard, detail.policy.reason); try testing.expectEqualStrings("||tracker.example^", detail.policy.matched); try testing.expectEqual(@as(?i64, 4), detail.policy.source_id); try testing.expectEqualStrings("StevenBlack", detail.policy.source_name); try testing.expectEqualStrings("cdn.tracker.example", detail.rewrites.cname_target); try testing.expectEqualStrings("", detail.rewrites.safe_search_target); try testing.expectEqual(provenance.RouteKind.blocked, detail.route.kind); // A blocked query attempted no exchange, so it names no resolver. try testing.expectEqualStrings("", detail.route.upstream); try testing.expectEqual(@as(u16, 3), detail.response.rcode); try testing.expectEqual(@as(?i64, 900), detail.response.duration_us); // An id past the end of the log and an id retention would have pruned are // the same answer. const missing = try std.fmt.bufPrint(&target_buf, "/api/queries/{d}", .{seeded_query_rows + 1000}); try conn.request("GET", missing, null, null); const not_found = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 404), not_found.status); try testing.expect(std.mem.containsAtLeast(u8, not_found.body, 1, "\"error\"")); // A non-positive id never reaches SQL: the pattern captures a positive // integer or does not match, so this is a routing 404. try conn.request("GET", "/api/queries/0", null, null); try testing.expectEqual(@as(u16, 404), (try conn.receive(&body_buf)).status); } test "W10 milestone 28: the detail endpoint answers one row and 404s the rest" { 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, detailWalk, .{ env.io(), env }); } fn detailUnavailable(io: std.Io, env: *Env) anyerror!void { var conn: Conn = undefined; try conn.connect(io, env.addr); defer conn.close(io); var body_buf: [8 * 1024]u8 = undefined; const targets = [_][]const u8{ "/api/queries/1", "/api/queries?limit=1", "/api/stats", "/api/stats/timeseries", "/api/stats/types", "/api/stats/routes", "/api/stats/clients", }; for (targets) |target| { try conn.request("GET", target, null, null); const response = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 503), response.status); try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "query log unavailable")); } } test "W10 milestone 28: a box with no query log answers 503, not an empty page" { if (!build_options.integration) return error.SkipZigTest; const gpa = testing.allocator; var env = try Env.create(gpa, .{ .querylog = false }); defer env.destroy(); try bounded(env.io(), default_budget, detailUnavailable, .{ env.io(), env }); } fn coverageWalk(io: std.Io, env: *Env) anyerror!void { var arena_state: std.heap.ArenaAllocator = .init(env.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); 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; // No lower bound: the request asks about all of history, which a file that // may have pruned cannot promise. try conn.request("GET", "/api/queries?limit=1", null, null); const unbounded = try std.json.parseFromSliceLeaky( handlers_queries.Page, arena, (try conn.receive(&body_buf)).body, .{ .ignore_unknown_fields = false }, ); try testing.expectEqual(seeded_available_since, unbounded.coverage.available_since); try testing.expect(!unbounded.coverage.complete); // Bounded exactly at the watermark. const at = try std.fmt.bufPrint(&target_buf, "/api/queries?limit=1&since={d}", .{seeded_available_since}); try conn.request("GET", at, null, null); const covered = try std.json.parseFromSliceLeaky( handlers_queries.Page, arena, (try conn.receive(&body_buf)).body, .{ .ignore_unknown_fields = false }, ); try testing.expect(covered.coverage.complete); // One second earlier, and the window reaches past what the file holds. const before = try std.fmt.bufPrint(&target_buf, "/api/queries?limit=1&since={d}", .{seeded_available_since - 1}); try conn.request("GET", before, null, null); const partial = try std.json.parseFromSliceLeaky( handlers_queries.Page, arena, (try conn.receive(&body_buf)).body, .{ .ignore_unknown_fields = false }, ); try testing.expect(!partial.coverage.complete); // The stats endpoints judge the same watermark against their own aligned // window, which for any live period starts well after the seeded rows. try conn.request("GET", "/api/stats?period=1h", null, null); const totals = try std.json.parseFromSliceLeaky( handlers_stats.TotalsBody, arena, (try conn.receive(&body_buf)).body, .{ .ignore_unknown_fields = false }, ); try testing.expectEqual(seeded_available_since, totals.coverage.available_since); try testing.expectEqual(totals.since >= seeded_available_since, totals.coverage.complete); try conn.request("GET", "/api/stats/timeseries?period=1h", null, null); const series = try std.json.parseFromSliceLeaky( handlers_stats.TimeseriesBody, arena, (try conn.receive(&body_buf)).body, .{ .ignore_unknown_fields = false }, ); try testing.expectEqual(totals.since, series.since); try testing.expectEqual(totals.coverage.complete, series.coverage.complete); } fn getJson( comptime T: type, arena: Allocator, conn: *Conn, target: []const u8, body_buf: []u8, ) !T { try conn.request("GET", target, null, null); const response = try conn.receive(body_buf); if (response.status != 200) { std.debug.print("{s}: status {d}: {s}\n", .{ target, response.status, response.body }); return error.TestUnexpectedResult; } return std.json.parseFromSliceLeaky(T, arena, response.body, .{ .ignore_unknown_fields = false }); } fn emptyAggregations(io: std.Io, env: *Env) anyerror!void { var arena_state: std.heap.ArenaAllocator = .init(env.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var conn: Conn = undefined; try conn.connect(io, env.addr); defer conn.close(io); var body_buf: [256 * 1024]u8 = undefined; // This environment's only rows are the fixed 2023 seed, so every live // window is empty. The empty bodies are exact, not merely parseable. const types_body = try getJson(handlers_stats.TypesBody, arena, &conn, "/api/stats/types?period=1h", &body_buf); try testing.expectEqualStrings("1h", types_body.period); try testing.expectEqual(@as(usize, 0), types_body.types.len); const routes_body = try getJson(handlers_stats.RoutesBody, arena, &conn, "/api/stats/routes?period=1h", &body_buf); try testing.expectEqual(@as(usize, 0), routes_body.routes.len); // `other` is present and bucket-count sized even here: a chart must never // have to invent the residual series. const clients = try getJson(handlers_stats.ClientsBody, arena, &conn, "/api/stats/clients?period=1h", &body_buf); try testing.expectEqual(@as(usize, 0), clients.clients.len); try testing.expectEqual(@as(u32, 60), clients.bucket_seconds); try testing.expectEqual(@as(usize, 60), clients.other.len); for (clients.other) |count| try testing.expectEqual(@as(u64, 0), count); // A window nobody covers is still reported as such, not as a quiet hour. try testing.expectEqual(seeded_available_since, types_body.coverage.available_since); try testing.expect(types_body.coverage.complete); for ([_][]const u8{ "/api/stats/types", "/api/stats/routes", "/api/stats/clients" }) |path| { var target_buf: [64]u8 = undefined; const target = try std.fmt.bufPrint(&target_buf, "{s}?period=12h", .{path}); try conn.request("GET", target, null, null); const bad = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 400), bad.status); try testing.expect(std.mem.containsAtLeast(u8, bad.body, 1, "period must be one of")); } } test "W10 milestone 30: an empty window answers exact empty aggregations, and a bad period is a 400" { 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, emptyAggregations, .{ env.io(), env }); } fn populatedAggregations(io: std.Io, env: *Env) anyerror!void { var arena_state: std.heap.ArenaAllocator = .init(env.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var conn: Conn = undefined; try conn.connect(io, env.addr); defer conn.close(io); var body_buf: [256 * 1024]u8 = undefined; const totals = try getJson(handlers_stats.TotalsBody, arena, &conn, "/api/stats?period=1h", &body_buf); const series = try getJson(handlers_stats.TimeseriesBody, arena, &conn, "/api/stats/timeseries?period=1h", &body_buf); const types_body = try getJson(handlers_stats.TypesBody, arena, &conn, "/api/stats/types?period=1h", &body_buf); const routes_body = try getJson(handlers_stats.RoutesBody, arena, &conn, "/api/stats/routes?period=1h", &body_buf); const clients = try getJson(handlers_stats.ClientsBody, arena, &conn, "/api/stats/clients?period=1h", &body_buf); // Nothing writes to this box between the five requests, so the window is // one state and conservation is a real assertion rather than a race. try testing.expectEqual(totals.since, series.since); try testing.expectEqual(totals.since, types_body.since); try testing.expectEqual(totals.since, routes_body.since); try testing.expectEqual(totals.since, clients.since); try testing.expect(totals.queries > 0); var typed: u64 = 0; var null_qtype_rows: usize = 0; for (types_body.types) |row| { typed += row.count; if (row.qtype == null) null_qtype_rows += 1; } try testing.expectEqual(totals.queries, typed); // The seeded matrix holds one typeless row, and it must be its own group. try testing.expectEqual(@as(usize, 1), null_qtype_rows); var routed: u64 = 0; var null_source_upstreams: usize = 0; var named_upstreams: usize = 0; for (routes_body.routes) |row| { routed += row.count; if (row.route != .upstream) continue; if (row.source == null) null_source_upstreams += 1 else named_upstreams += 1; } try testing.expectEqual(totals.queries, routed); try testing.expectEqual(@as(usize, 1), null_source_upstreams); try testing.expectEqual(@as(usize, 2), named_upstreams); try testing.expectEqual(@as(usize, recent_clients), clients.clients.len); try testing.expectEqual(series.buckets.len, clients.other.len); for (clients.clients) |entry| try testing.expectEqual(series.buckets.len, entry.buckets.len); // Per bucket, not just over the window: a series off by one bucket would // still sum correctly in total. for (series.buckets, 0..) |bucket, at| { var summed: u64 = clients.other[at]; for (clients.clients) |entry| summed += entry.buckets[at]; try testing.expectEqual(bucket.queries, summed); } } test "W10 milestone 30: the three breakdowns conserve the totals over one window" { if (!build_options.integration) return error.SkipZigTest; const gpa = testing.allocator; var env = try Env.create(gpa, .{ .recent_traffic = true }); defer env.destroy(); try bounded(env.io(), default_budget, populatedAggregations, .{ env.io(), env }); } /// One connection walking every query-log endpoint several times over. fn hammerQuerylog(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; const targets = [_][]const u8{ "/api/stats?period=1h", "/api/stats/timeseries?period=1h", "/api/stats/types?period=1h", "/api/stats/routes?period=1h", "/api/stats/clients?period=1h", "/api/queries?limit=5", "/api/queries/27", }; for (0..3) |_| { for (targets) |target| { try conn.request("GET", target, null, null); const response = try conn.receive(&body_buf); if (response.status != 200) { std.debug.print("{s}: status {d}: {s}\n", .{ target, response.status, response.body }); return error.TestUnexpectedResult; } } } } fn concurrentQuerylogReads(io: std.Io, env: *Env) anyerror!void { // Six tasks on six connections against the one shared query-log // connection. Without `querylog_lock` this is exactly the shape that makes // a second BEGIN fail and a foreign read land inside someone else's // transaction; every response here must still be a 200. var futures: [6]std.Io.Future(anyerror!void) = undefined; for (&futures) |*future| future.* = try io.concurrent(hammerQuerylog, .{ io, env }); var failure: ?anyerror = null; for (&futures) |*future| future.await(io) catch |err| { failure = err; }; if (failure) |err| return err; } fn failedCommitIsBounded(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; // A read that cannot end its transaction. The three things that must hold // are all observable from here: the client is told (500, not a 200 over a // state nobody can name), the process survives (the lock is released // exactly once — releasing twice is `unreachable` in `std.Io.Mutex`), and // the connection recovers (the rollback attempt worked, so the next // `BEGIN` is not refused). db.read_tx_faults.failNextCommit(); try conn.request("GET", "/api/stats/types?period=1h", null, null); const failed = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 500), failed.status); try testing.expect(std.mem.containsAtLeast(u8, failed.body, 1, "internal error")); // Same connection, same shared query-log handle: a request after the fault // is an ordinary 200. This is the assertion the double-unlock bug failed — // it panicked here instead of answering. try conn.request("GET", "/api/stats/types?period=1h", null, null); const recovered = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 200), recovered.status); // And every other query-log route still works on that connection. for ([_][]const u8{ "/api/stats?period=1h", "/api/stats/timeseries?period=1h", "/api/stats/routes?period=1h", "/api/stats/clients?period=1h", "/api/queries?limit=5", "/api/queries/27", }) |target| { try conn.request("GET", target, null, null); const response = try conn.receive(&body_buf); if (response.status != 200) { std.debug.print("{s} after the fault: status {d}\n", .{ target, response.status }); return error.TestUnexpectedResult; } } } test "W10 milestone 30: a read that cannot commit answers 500 and leaves the connection usable" { if (!build_options.integration) return error.SkipZigTest; const gpa = testing.allocator; var env = try Env.create(gpa, .{ .recent_traffic = true }); defer env.destroy(); // The teardown fault is reported at `err`, which the test runner counts as // a failure; this test causes it deliberately and asserts the count. db.read_tx_faults.beginCapture(); defer _ = db.read_tx_faults.endCapture(); try bounded(env.io(), default_budget, failedCommitIsBounded, .{ env.io(), env }); // Exactly the one COMMIT fault: the ROLLBACK behind it succeeded, and no // later request tripped a fault of its own. try testing.expectEqual(@as(usize, 1), db.read_tx_faults.endCapture()); } test "W10 milestone 30: concurrent query-log reads all answer 200 on the shared connection" { if (!build_options.integration) return error.SkipZigTest; const gpa = testing.allocator; var env = try Env.create(gpa, .{ .recent_traffic = true }); defer env.destroy(); try bounded(env.io(), default_budget, concurrentQuerylogReads, .{ env.io(), env }); } test "W10 milestone 28: every window-bounded endpoint reports its own coverage" { 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, coverageWalk, .{ env.io(), env }); } /// NextDNS's shape: the account id rides in the path, which is exactly where a /// credential lives in a url an operator may legitimately configure. /// `Endpoint.parse` refuses userinfo, so the path is the shape a real /// configuration can carry a secret in — and the path is what /// `safe_url.redact` drops. const sweep_token = "b1c2d3"; const sweep_upstream_url = "https://dns.nextdns.io/" ++ sweep_token; /// What every surface must show instead. The origin survives redaction — an /// operator reading a failure has to know where the query went — so each /// surface is checked for it too: one that showed nothing at all would pass a /// secret check by saying nothing. const sweep_redacted_upstream = "https://dns.nextdns.io"; /// The name the swept query asks for, so each surface can be pinned to the row /// this test produced rather than to a seeded one. const sweep_domain = "creds.example"; /// Names the resolver and never its token. fn expectRedacted(text: []const u8) !void { try testing.expect(std.mem.containsAtLeast(u8, text, 1, sweep_redacted_upstream)); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, sweep_token)); } /// The cross-surface credential sweep, driven end to end: a real `Handler` /// answers a real query through a resolver whose url carries a token, and the /// entry travels the production path — `QuerySink`, then the hub and the /// logger, then the query log the API reads. Nothing here redacts anything, so /// a handler that stopped redacting fails this test. /// /// Four surfaces read the same query back: the stored row, straight out of /// SQLite, and the three the operator's browser sees — the live frame, the list /// page and the detail body. A leak on any one of them is a secret in a browser /// history, and the four are separate code paths to the same text. fn credentialSweep( io: std.Io, env: *Env, query_logger: *logger_mod.Logger, handler: *dns_handler.Handler, ) anyerror!void { var arena_state: std.heap.ArenaAllocator = .init(env.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); // Subscribed before the query runs: the hub publishes to whoever is // listening at that moment and keeps nothing for a later reader. var seen: std.ArrayList(u8) = .empty; defer seen.deinit(env.gpa); var stream: Conn = undefined; try openLiveStream(io, env, &stream, &seen); defer stream.close(io); 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(53100) }); const query = queryFor(&query_buf, 0x4444, sweep_domain, .a); try testing.expect(handler.handle(io, .udp, from, query, &response_buf, &scratch) == .reply); // The live frame. Waiting on the redacted origin rather than on the whole // frame is safe in both directions: a leaked url starts with it. try stream.readChunkedUntil(&seen, env.gpa, sweep_redacted_upstream); try testing.expect(std.mem.containsAtLeast(u8, seen.items, 1, sweep_domain)); try expectRedacted(seen.items); // The stored row. The producer has already run, so closing the queue and // running the writer inline drains it in one call: `runWriter` returns when // a closed queue is empty, and a zero flush interval makes it commit the // batch it holds rather than wait for company. query_logger.shutdown(io); try query_logger.runWriter(io, &env.querylog_db, null); try testing.expectEqual(@as(u64, 1), query_logger.rows_written.load(.monotonic)); var stmt = try env.querylog_db.prepare( \\SELECT query_log.id, query_log.upstream \\FROM query_log JOIN domains ON domains.id = query_log.domain_id \\WHERE domains.domain = ? ); defer stmt.deinit(); try stmt.bindText(1, sweep_domain); try testing.expect(try stmt.step()); const row_id = stmt.columnInt(0); try testing.expectEqualStrings(sweep_redacted_upstream, stmt.columnText(1)); try testing.expect(!try stmt.step()); 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; // The list row. The swept query is the newest in the log, so a page of one // is it. try conn.request("GET", "/api/queries?limit=1", null, null); const rows = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 200), rows.status); const page = try std.json.parseFromSliceLeaky( handlers_queries.Page, arena, rows.body, .{ .ignore_unknown_fields = false }, ); try testing.expectEqual(@as(usize, 1), page.queries.len); try testing.expectEqualStrings(sweep_domain, page.queries[0].domain); try testing.expectEqualStrings(sweep_redacted_upstream, page.queries[0].upstream); try expectRedacted(rows.body); // The detail body, read by the row id the database just handed over. const one = try std.fmt.bufPrint(&target_buf, "/api/queries/{d}", .{row_id}); try conn.request("GET", one, null, null); const detail_response = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 200), detail_response.status); const detail = try std.json.parseFromSliceLeaky( provenance_view.QueryDetail, arena, detail_response.body, .{ .ignore_unknown_fields = false }, ); try testing.expectEqualStrings(sweep_domain, detail.request.domain); try testing.expectEqualStrings(sweep_redacted_upstream, detail.route.upstream); try expectRedacted(detail_response.body); } test "W10 milestone 28: no query surface echoes a resolver credential" { if (!build_options.integration) return error.SkipZigTest; const gpa = testing.allocator; var env = try Env.create(gpa, .{}); defer env.destroy(); const io = env.io(); var queue_buf: [4]logger_mod.Entry = undefined; // Zero flush interval: the drain below is synchronous, and nothing else // will ever put an entry on this queue for the writer to wait for. var query_logger: logger_mod.Logger = .init(.{ .query_log_flush_interval_s = 0 }, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&query_logger), env.hub); var fake: FakeUpstream = .{ .identity = sweep_upstream_url }; var handler_owner: upstream_owner.Borrowed = .{}; var handler: dns_handler.Handler = .{ .upstream = handler_owner.client(fake.client()), .policy = .{ .blocking = .{ .mode = .zero, .ttl = 5 }, .forward_read_timeout = .{ .raw = .fromSeconds(2), .clock = .awake } }, .manager = &env.mgr, .pause = &env.pauser, .sink = &sink, }; try bounded(io, default_budget, credentialSweep, .{ io, env, &query_logger, &handler }); try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic)); } // --------------------------------------------------------------------------- // 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); } /// The three files a refresh publishes for one source. The `.allow` file is /// written here too: the delete path has to take every compiled body, and a /// sweep that missed one would leave an orphan this test could not see. 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 = "", }); try dir.writeFile(io, .{ .sub_path = try std.fmt.bufPrint(&buf, "{d}.allow", .{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 testing.expectError(error.FileNotFound, dir.access( io, try std.fmt.bufPrint(&name_buf, "{d}.allow", .{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), /// The resolver the handler reports as having answered. Operator-supplied /// text in production, so the credential sweep points it at a url with a /// token in its path. identity: []const u8 = "fake://web-upstream", fn exchangeFn( ptr: *anyopaque, io: std.Io, query: []const u8, response_buf: []u8, selected: *?[]const u8, ) transport.ExchangeError![]u8 { _ = io; const self: *FakeUpstream = @ptrCast(@alignCast(ptr)); selected.* = self.identity; _ = 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_owner: upstream_owner.Borrowed = .{}; var h: dns_handler.Handler = .{ .upstream = h_owner.client(fake.client()), .policy = .{ .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); } // --------------------------------------------------------------------------- // focused schema drift guards (milestone 28) // --------------------------------------------------------------------------- // // Guard a proves every served route is documented and guard b counts the // operations, and neither looks inside a schema. A field renamed, retyped, made // nullable or dropped from `required` passes both while breaking every client // that reads the document — and the query-log provenance shapes are exactly // where a rename is easy and a wrong `nullable` is silent. // // So these read the schema back and hold it to the Zig struct that produces it: // the same property names, the same types, the same nullability, the same // requiredness, and no extra property on either side. A `$ref` recurses, so // checking `QueryDetail` checks all six of its nested objects. // // The YAML reader below understands only the shape this document is written in // — two-space indentation, schemas at four, properties at eight, inline `{ ... }` // or an indented block, and single-line flow sequences. It is not a YAML parser // and must not become one; a document it cannot read is a document that stopped // matching the house style. /// One schema's body: everything from its key line to the next schema key. fn yamlSchema(schema_name: []const u8) ?[]const u8 { var key_buf: [64]u8 = undefined; const key = std.fmt.bufPrint(&key_buf, "\n {s}:\n", .{schema_name}) catch return null; const at = std.mem.indexOf(u8, openapi.yaml, key) orelse return null; const body = openapi.yaml[at + key.len ..]; var end: usize = 0; var lines = std.mem.splitScalar(u8, body, '\n'); while (lines.next()) |line| { if (line.len != 0 and !std.mem.startsWith(u8, line, " ")) break; end += line.len + 1; } return body[0..@min(end, body.len)]; } /// One property's definition: the rest of its line for the inline form, or the /// indented block that follows it. fn yamlProperty(schema: []const u8, property_name: []const u8) ?[]const u8 { const properties_at = std.mem.indexOf(u8, schema, "\n properties:\n") orelse return null; const properties = schema[properties_at..]; var key_buf: [64]u8 = undefined; const key = std.fmt.bufPrint(&key_buf, "\n {s}:", .{property_name}) catch return null; const at = std.mem.indexOf(u8, properties, key) orelse return null; const rest = properties[at + key.len ..]; const line_end = std.mem.indexOfScalar(u8, rest, '\n') orelse rest.len; if (std.mem.trim(u8, rest[0..line_end], " ").len != 0) return rest[0..line_end]; var end: usize = line_end + 1; var lines = std.mem.splitScalar(u8, rest[line_end + 1 ..], '\n'); while (lines.next()) |line| { if (line.len != 0 and !std.mem.startsWith(u8, line, " ")) break; end += line.len + 1; } return rest[0..@min(end, rest.len)]; } /// The comma-separated items of a single-line flow sequence, `key: [a, b, c]`. fn yamlFlowSeq(schema: []const u8, key: []const u8, out: *std.ArrayList([]const u8), gpa: Allocator) !void { var key_buf: [32]u8 = undefined; const needle = try std.fmt.bufPrint(&key_buf, "\n {s}: [", .{key}); const at = std.mem.indexOf(u8, schema, needle) orelse return error.TestUnexpectedResult; const rest = schema[at + needle.len ..]; const close = std.mem.indexOfScalar(u8, rest, ']') orelse return error.TestUnexpectedResult; var items = std.mem.splitScalar(u8, rest[0..close], ','); while (items.next()) |item| try out.append(gpa, std.mem.trim(u8, item, " ")); } /// The property names the schema declares, in document order. fn yamlPropertyNames(schema: []const u8, out: *std.ArrayList([]const u8), gpa: Allocator) !void { const properties_at = std.mem.indexOf(u8, schema, "\n properties:\n") orelse return error.TestUnexpectedResult; var lines = std.mem.splitScalar(u8, schema[properties_at + 1 ..], '\n'); _ = lines.next(); while (lines.next()) |line| { if (line.len != 0 and !std.mem.startsWith(u8, line, " ")) break; if (!std.mem.startsWith(u8, line, " ") or std.mem.startsWith(u8, line, " ")) continue; const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue; try out.append(gpa, line[8..colon]); } } /// The OpenAPI `type` a Zig field must be documented as, or `null` when the /// field is a nested object and must be a `$ref` instead. fn documentedType(comptime T: type) ?[]const u8 { const Payload = switch (@typeInfo(T)) { .optional => |o| o.child, else => T, }; return switch (@typeInfo(Payload)) { .int => "integer", .bool => "boolean", // A closed enum is a string on the wire, documented as its own schema. .@"enum" => null, // `[]const u8` is a string; every other slice is a JSON array, whose // element type `elementType` below holds the `items:` block to. .pointer => |ptr| if (ptr.child == u8) "string" else "array", .@"struct" => null, else => @compileError("no documented type for " ++ @typeName(Payload)), }; } /// The element type of a field that serializes as a JSON array, or null when /// the field is not one. `[]const u8` is a string, not an array of integers. fn elementType(comptime T: type) ?type { const Payload = switch (@typeInfo(T)) { .optional => |o| o.child, else => T, }; return switch (@typeInfo(Payload)) { .pointer => |ptr| if (ptr.child == u8) null else ptr.child, else => null, }; } /// The `items:` sub-block of an array property. fn yamlItems(property: []const u8) ?[]const u8 { const at = std.mem.indexOf(u8, property, "items:") orelse return null; return property[at..]; } fn isOptional(comptime T: type) bool { return @typeInfo(T) == .optional; } /// The schema name a `$ref` property points at. fn refTarget(property: []const u8) ?[]const u8 { const marker = "$ref: \"#/components/schemas/"; const at = std.mem.indexOf(u8, property, marker) orelse return null; const rest = property[at + marker.len ..]; const close = std.mem.indexOfScalar(u8, rest, '"') orelse return null; return rest[0..close]; } /// Holds `schema_name` to `T`: same properties, same types, same nullability, /// same requiredness, nothing extra on either side. Recurses through `$ref`. fn expectSchemaMatches(gpa: Allocator, comptime T: type, schema_name: []const u8) !void { const schema = yamlSchema(schema_name) orelse { std.debug.print("openapi.yaml has no schema {s}\n", .{schema_name}); return error.TestUnexpectedResult; }; var required: std.ArrayList([]const u8) = .empty; defer required.deinit(gpa); try yamlFlowSeq(schema, "required", &required, gpa); const fields = @typeInfo(T).@"struct".fields; inline for (fields) |field| { const property = yamlProperty(schema, field.name) orelse { std.debug.print("{s}: no property {s}\n", .{ schema_name, field.name }); return error.TestUnexpectedResult; }; // Ahead of both branches: a `$ref` property is as free to go null as a // scalar one, and a nested object or enum the server may omit is // exactly the drift a client reading the document cannot see coming. const documented_nullable = std.mem.containsAtLeast(u8, property, 1, "nullable: true"); if (documented_nullable != isOptional(field.type)) { std.debug.print( "{s}.{s}: nullable is {} in the document and {} in Zig\n", .{ schema_name, field.name, documented_nullable, isOptional(field.type) }, ); return error.TestUnexpectedResult; } if (comptime documentedType(field.type)) |wanted| { var type_buf: [32]u8 = undefined; const needle = try std.fmt.bufPrint(&type_buf, "type: {s}", .{wanted}); if (!std.mem.containsAtLeast(u8, property, 1, needle)) { std.debug.print("{s}.{s}: not documented as {s}\n", .{ schema_name, field.name, wanted }); return error.TestUnexpectedResult; } // An array is only as documented as its elements are: without this // an array of one object would match an array of another. if (comptime elementType(field.type)) |Element| { const items = yamlItems(property) orelse { std.debug.print("{s}.{s}: array with no items\n", .{ schema_name, field.name }); return error.TestUnexpectedResult; }; switch (@typeInfo(Element)) { .int => if (!std.mem.containsAtLeast(u8, items, 1, "type: integer")) { std.debug.print("{s}.{s}: items not documented as integer\n", .{ schema_name, field.name }); return error.TestUnexpectedResult; }, else => { const target = refTarget(items) orelse { std.debug.print("{s}.{s}: items are not a $ref\n", .{ schema_name, field.name }); return error.TestUnexpectedResult; }; switch (@typeInfo(Element)) { .@"enum" => try expectEnumMatches(gpa, Element, target), else => try expectSchemaMatches(gpa, Element, target), } }, } } } else { const target = refTarget(property) orelse { std.debug.print("{s}.{s}: not a $ref\n", .{ schema_name, field.name }); return error.TestUnexpectedResult; }; const Payload = switch (@typeInfo(field.type)) { .optional => |o| o.child, else => field.type, }; switch (@typeInfo(Payload)) { .@"enum" => try expectEnumMatches(gpa, Payload, target), else => try expectSchemaMatches(gpa, Payload, target), } } var listed = false; for (required.items) |listed_name| listed = listed or std.mem.eql(u8, listed_name, field.name); if (!listed) { std.debug.print("{s}.{s}: not in required\n", .{ schema_name, field.name }); return error.TestUnexpectedResult; } } var documented: std.ArrayList([]const u8) = .empty; defer documented.deinit(gpa); try yamlPropertyNames(schema, &documented, gpa); try testing.expectEqual(fields.len, documented.items.len); try testing.expectEqual(fields.len, required.items.len); } /// Holds an enum schema to its Zig enum: the same values, in the same order. fn expectEnumMatches(gpa: Allocator, comptime T: type, schema_name: []const u8) !void { const schema = yamlSchema(schema_name) orelse { std.debug.print("openapi.yaml has no schema {s}\n", .{schema_name}); return error.TestUnexpectedResult; }; var values: std.ArrayList([]const u8) = .empty; defer values.deinit(gpa); try yamlFlowSeq(schema, "enum", &values, gpa); const tags = @typeInfo(T).@"enum".fields; try testing.expectEqual(tags.len, values.items.len); inline for (tags, 0..) |tag, index| { try testing.expectEqualStrings(tag.name, values.items[index]); } } test "drift guard c: the health rollup matches the five objects it documents" { const gpa = testing.allocator; // Recurses through the five `$ref`s, so a condition object that gains, // loses or retypes a field fails here — which is the whole contract: no // condition may degrade the rollup without appearing in the response. try expectSchemaMatches(gpa, handlers_health.Body, "Health"); } test "drift guard c: the stats schemas match the structs that serialize them" { // Guard b counts operations and guard a matches paths, so neither noticed // that `cached` outlived the field it documented. This one would have. const gpa = testing.allocator; try expectSchemaMatches(gpa, handlers_stats.TotalsBody, "StatsTotals"); try expectSchemaMatches(gpa, handlers_stats.TimeseriesBody, "StatsTimeseries"); try expectSchemaMatches(gpa, handlers_stats.TypesBody, "StatsTypes"); try expectSchemaMatches(gpa, handlers_stats.RoutesBody, "StatsRoutes"); try expectSchemaMatches(gpa, handlers_stats.ClientsBody, "StatsClients"); } test "drift guard c: the query-log schemas match the structs that serialize them" { const gpa = testing.allocator; try expectSchemaMatches(gpa, queries_repo.QueryRow, "QueryRow"); // `Provenance` has no schema of its own: it is `QueryDetail` without the // row id, and the live stream documents it in prose rather than a `$ref` // no path could honestly point at. try expectSchemaMatches(gpa, provenance_view.QueryDetail, "QueryDetail"); try expectSchemaMatches(gpa, coverage_mod.Coverage, "Coverage"); } test "drift guard c: the three closed enums are documented value for value" { const gpa = testing.allocator; try expectEnumMatches(gpa, provenance.PolicyAction, "PolicyAction"); try expectEnumMatches(gpa, provenance.PolicyReason, "PolicyReason"); try expectEnumMatches(gpa, provenance.RouteKind, "RouteKind"); } test "drift guard c bites: a renamed, retyped or newly optional field fails it" { const gpa = testing.allocator; // A field the document does not name at all. const Renamed = struct { complete: bool, available_from: i64 }; try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, Renamed, "Coverage")); // A field the document names, with the wrong type. const Retyped = struct { complete: bool, available_since: []const u8 }; try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, Retyped, "Coverage")); // A field the document names and types correctly, but which Zig may now // send as null while `nullable` is absent from the document. const Nullable = struct { complete: bool, available_since: ?i64 }; try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, Nullable, "Coverage")); // The same drift behind a `$ref`, where the property carries no `type:` of // its own: a nested object the server may now omit. const NullableObject = struct { id: i64, request: provenance_view.Request, group: ?provenance_view.Group, policy: provenance_view.Policy, rewrites: provenance_view.Rewrites, route: provenance_view.Route, response: provenance_view.Response, }; try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, NullableObject, "QueryDetail")); // And behind a `$ref` to an enum, whose values would still line up. const NullableEnum = struct { action: ?provenance.PolicyAction, reason: provenance.PolicyReason, matched: []const u8, source_id: ?i64, source_name: []const u8, }; try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, NullableEnum, "ProvenancePolicy")); // A struct short one documented property, which excess-property checking on // the client side would never catch. const Narrowed = struct { complete: bool }; try testing.expectError(error.TestExpectedEqual, expectSchemaMatches(gpa, Narrowed, "Coverage")); // An enum missing one of the document's values. const Short = enum { not_evaluated, allow }; try testing.expectError(error.TestExpectedEqual, expectEnumMatches(gpa, Short, "PolicyAction")); } // --------------------------------------------------------------------------- // 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` 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. /// /// Every successful `.json` route is sampled except three, which carry no JSON /// contract this file could pin: `/metrics` answers Prometheus text, /// `/api/openapi.yaml` is served verbatim from the repo and guarded by the /// drift tests below, and `/api/queries/live` is an open SSE stream rather than /// one byte-comparable body — its frame payload is `QueryDetail` without the /// id, already sampled through `get_query_detail`. const contract_sample_walk = [_]ContractSample{ .{ .name = "get_health", .ts_type = "Health", .method = "GET", .target = "/api/health", .status = 200 }, .{ .name = "get_version", .ts_type = "Version", .method = "GET", .target = "/api/version", .status = 200 }, .{ .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 }, // Diagnostics, ahead of every write below: the seeded store holds one // active episode (id 1) and one resolved one, and a later pass that // reported an event of its own would move the page under the golden. .{ .name = "get_diagnostics", .ts_type = "DiagnosticsPage", .method = "GET", .target = "/api/diagnostics?limit=10", .status = 200 }, .{ .name = "get_diagnostic", .ts_type = "DiagnosticEvent", .method = "GET", .target = "/api/diagnostics/1", .status = 200 }, // The sweep runs after both reads and takes the seeded resolved episode; // the per-id purge answers 204, which has no body to sample. .{ .name = "purge_diagnostics", .ts_type = "DiagnosticsPurge", .method = "DELETE", .target = "/api/diagnostics", .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 = "get_blocklist", .ts_type = "Blocklist", .method = "GET", .target = "/api/blocklists/1", .status = 200 }, .{ .name = "list_blocklists", .ts_type = "{ blocklists: Blocklist[] }", .method = "GET", .target = "/api/blocklists", .status = 200 }, .{ .name = "update_blocklist", .ts_type = "BlocklistEcho", .method = "PUT", .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200 }, .{ .name = "update_blocklists_now", .ts_type = "{ sources: SourceStatus[] }", .method = "POST", .target = "/api/blocklists/update", .body = "{}", .status = 202 }, // Groups. The migrated schema seeds `default` as id 1; the POST creates 2. .{ .name = "list_groups", .ts_type = "{ groups: Group[] }", .method = "GET", .target = "/api/groups", .status = 200 }, .{ .name = "create_group", .ts_type = "Group", .method = "POST", .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201 }, .{ .name = "get_group", .ts_type = "Group", .method = "GET", .target = "/api/groups/2", .status = 200 }, .{ .name = "update_group", .ts_type = "Group", .method = "PUT", .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200 }, .{ .name = "put_group_sources", .ts_type = "{ source_ids: number[] }", .method = "PUT", .target = "/api/groups/1/sources", .body = "{\"source_ids\":[1]}", .status = 200 }, .{ .name = "get_group_sources", .ts_type = "{ source_ids: number[] }", .method = "GET", .target = "/api/groups/1/sources", .status = 200 }, // Rules, then the lookup that the rule makes answer `blocked`. .{ .name = "create_rule", .ts_type = "RuleEcho", .method = "POST", .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201 }, .{ .name = "get_rule", .ts_type = "Rule", .method = "GET", .target = "/api/rules/1", .status = 200 }, .{ .name = "list_rules", .ts_type = "{ rules: Rule[] }", .method = "GET", .target = "/api/rules", .status = 200 }, .{ .name = "update_rule", .ts_type = "RuleEcho", .method = "PUT", .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"*.ads.example\",\"kind\":\"wildcard\",\"action\":\"block\"}", .status = 200 }, .{ .name = "get_lookup", .ts_type = "LookupResult", .method = "GET", .target = "/api/lookup?domain=sub.ads.example", .status = 200 }, // Local records. .{ .name = "create_local_record", .ts_type = "LocalRecord", .method = "POST", .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201 }, .{ .name = "get_local_record", .ts_type = "LocalRecord", .method = "GET", .target = "/api/local-records/1", .status = 200 }, .{ .name = "list_local_records", .ts_type = "{ local_records: LocalRecord[] }", .method = "GET", .target = "/api/local-records", .status = 200 }, .{ .name = "update_local_record", .ts_type = "LocalRecord", .method = "PUT", .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200 }, // Forward zones. .{ .name = "create_forward_zone", .ts_type = "ForwardZone", .method = "POST", .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201 }, .{ .name = "get_forward_zone", .ts_type = "ForwardZone", .method = "GET", .target = "/api/forward-zones/1", .status = 200 }, .{ .name = "list_forward_zones", .ts_type = "{ forward_zones: ForwardZone[] }", .method = "GET", .target = "/api/forward-zones", .status = 200 }, .{ .name = "update_forward_zone", .ts_type = "ForwardZone", .method = "PUT", .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200 }, // Clients (row id 1 is seeded — clients have no POST, ruling 9). .{ .name = "list_clients", .ts_type = "{ clients: Client[] }", .method = "GET", .target = "/api/clients", .status = 200 }, .{ .name = "get_client", .ts_type = "Client", .method = "GET", .target = "/api/clients/1", .status = 200 }, .{ .name = "update_client", .ts_type = "Client", .method = "PUT", .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200 }, .{ .name = "put_client_prefixes", .ts_type = "{ client_prefixes: ClientPrefix[] }", .method = "PUT", .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200 }, .{ .name = "list_client_prefixes", .ts_type = "{ client_prefixes: ClientPrefix[] }", .method = "GET", .target = "/api/client-prefixes", .status = 200 }, // 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 = "get_upstream", .ts_type = "Upstream", .method = "GET", .target = "/api/upstreams/1", .status = 200 }, .{ .name = "update_upstream", .ts_type = "UpstreamEcho", .method = "PUT", .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200 }, // Query log and stats. `limit=5` reaches seeded row 21, the blocked one, so // 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 }, // The newest seeded row: a CNAME-uncloaked block with a group, a source and // a matched pattern, so the golden exercises every nested object rather // than a row of nulls. .{ .name = "get_query_detail", .ts_type = "QueryDetail", .method = "GET", .target = "/api/queries/27", .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 }, // Config status after the settings PUT above, so the sample witnesses a // raised `restart_pending` rather than only the boot value. .{ .name = "get_config_status", .ts_type = "ConfigStatus", .method = "GET", .target = "/api/config/status", .status = 200 }, // Certificate reload. Neither TLS endpoint is wired in this environment, so // the sample carries the disabled outcome and nothing is read from disk. .{ .name = "reload_certs", .ts_type = "CertsReload", .method = "POST", .target = "/api/certs/reload", .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 }, }; /// The three period aggregations, captured against an environment with live /// traffic in it: over the fixed 2023 seed every one of them would answer with /// an empty array, which describes no field at all. const stats_sample_walk = [_]ContractSample{ .{ .name = "get_stats_types", .ts_type = "StatsTypes", .method = "GET", .target = "/api/stats/types?period=1h", .status = 200 }, .{ .name = "get_stats_routes", .ts_type = "StatsRoutes", .method = "GET", .target = "/api/stats/routes?period=1h", .status = 200 }, .{ .name = "get_stats_clients", .ts_type = "StatsClients", .method = "GET", .target = "/api/stats/clients?period=1h", .status = 200 }, }; /// 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("\"\""); } 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 ++ stats_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`, 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 statsSampleWalk(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 (stats_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 env = try Env.create(gpa, .{ .recent_traffic = true }); defer env.destroy(); try bounded(env.io(), default_budget, statsSampleWalk, .{ 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); } // --------------------------------------------------------------------------- // milestone 34 S5: one real PUT per apply-table operation // // Every case below drives the shipped route table over a real socket, so the // path under test is dispatch → applyPut → prepare → commit → publish → retire. // The assertions read the live collaborator the operation owns, in this // process, after the response has been written. // --------------------------------------------------------------------------- fn putSettings(conn: *Conn, body_buf: []u8, body: []const u8) !Response { try conn.request("PUT", "/api/settings", null, body); return conn.receive(body_buf); } fn expectPutOk(conn: *Conn, body_buf: []u8, body: []const u8) !void { const response = try putSettings(conn, body_buf, body); errdefer std.debug.print("PUT {s} -> {d} {s}\n", .{ body, response.status, response.body }); try testing.expectEqual(@as(u16, 200), response.status); } /// The one retirement slot the query-log controller allows must be free before /// the next resize, and the reaper frees it on its own task. fn awaitQuiesced(io: std.Io, env: *Env) !void { for (0..2000) |_| { if (!env.log_controller.retirementPending(io)) return; try (std.Io.Clock.Duration{ .raw = .fromMilliseconds(1), .clock = .awake }).sleep(io); } return error.TestRetirementNeverFinished; } fn liveOperations(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); // dns_policy — four keys of three sections, one owner, one publish. try expectPutOk(&conn, &body_buf, \\{"blocking":{"ttl":30,"response":"nxdomain"},"edns":{"ecs_mode":"forward"}, \\ "cache":{"negative_ttl_max":99},"upstream":{"read_timeout_ms":1234}} ); const policy = env.handler.policySnapshot(io); try testing.expectEqual(@as(u32, 30), policy.blocking.ttl); try testing.expectEqual(model.BlockResponse.nxdomain, policy.blocking.mode); try testing.expectEqual(model.EcsMode.forward, policy.ecs_mode); try testing.expectEqual(@as(u32, 99), policy.negative_ttl_max); try testing.expectEqual( @as(u64, 1234 * std.time.ns_per_ms), policy.forward_read_timeout.raw.nanoseconds, ); // trusted_proxies — the request path reads the holder, never the boot text. try expectPutOk(&conn, &body_buf, "{\"web\":{\"trusted_proxies\":\"10.9.9.9\"}}"); const proxies = env.state.proxies.acquire(io); try testing.expectEqualStrings("10.9.9.9", proxies.text); proxies.release(io); // logger_privacy and logger_flush — two owners, both on the controller. try expectPutOk(&conn, &body_buf, \\{"logging":{"hide_domains":true,"hide_client_ips":true,"query_log_flush_interval_s":7}} ); { const generation = env.log_controller.acquire(io); defer env.log_controller.release(io, generation); const privacy = generation.logger.privacy(); try testing.expect(privacy.hide_domains); try testing.expect(privacy.hide_client_ips); try testing.expectEqual(@as(u16, 7), generation.logger.flush_interval_s.load(.monotonic)); } // disk_thresholds — one packed pair, one store. try expectPutOk(&conn, &body_buf, "{\"disk\":{\"min_free_mb\":11,\"warn_free_mb\":22}}"); try testing.expectEqual(@as(u32, 11), env.monitor.thresholds().min_free_mb); try testing.expectEqual(@as(u32, 22), env.monitor.thresholds().warn_free_mb); // cache — a resize swaps the table, so the handler's pointer moves. const cache_before = env.handler.cache; try expectPutOk(&conn, &body_buf, "{\"cache\":{\"size\":1234}}"); try testing.expect(env.handler.cache != cache_before); // rate_limiter — the same, for the DNS-side table. const limiter_before = env.handler.limiter; try expectPutOk(&conn, &body_buf, "{\"dns\":{\"rate_limit\":42}}"); try testing.expect(env.handler.limiter != limiter_before); // sessions_ttl — server-side, retroactive, and the login cookie reads it. try expectPutOk(&conn, &body_buf, "{\"web\":{\"session_ttl_hours\":48}}"); try testing.expectEqual(@as(i64, 48 * 3600), env.sessions.ttlSeconds(io)); // api_limiter — all three of its keys are one owner. try expectPutOk(&conn, &body_buf, \\{"web":{"api_rate_limit_per_min":7,"api_localhost_exempt":false,"sse_max_connections_per_ip":2}} ); const limits = env.limiter.snapshotConfig(io); try testing.expectEqual(@as(u32, 7), limits.rate_per_min); try testing.expect(!limits.localhost_exempt); try testing.expectEqual(@as(u16, 2), limits.sse_max_per_ip); // retention — one cell, both prune passes. try expectPutOk(&conn, &body_buf, "{\"logging\":{\"retention_days\":7}}"); try testing.expectEqual(@as(u32, 7), env.retention_days.get()); // scheduler — the blocklist refresh loop's live schedule. try expectPutOk(&conn, &body_buf, "{\"blocklist_update\":{\"interval_hours\":48,\"enabled\":false}}"); const schedule = env.mgr.schedule(io); try testing.expectEqual(@as(u16, 48), schedule.interval_hours); try testing.expect(!schedule.enabled); // logger_queue — the resize the controller owns. try expectPutOk(&conn, &body_buf, "{\"logging\":{\"query_log_buffer_max\":500}}"); try testing.expectEqual(@as(u32, 500), env.log_controller.capacity(io)); try awaitQuiesced(io, env); // A resize and a privacy change in ONE put. The candidate is seeded from // the merged configuration, so the generation the swap makes live carries // the privacy the same put committed — seeding it from the live values // would publish the flags onto the generation being retired and leave the // replacement writing what the operator just asked to hide. try expectPutOk(&conn, &body_buf, \\{"logging":{"query_log_buffer_max":600,"hide_domains":false,"hide_client_ips":false}} ); try testing.expectEqual(@as(u32, 600), env.log_controller.capacity(io)); { const generation = env.log_controller.acquire(io); defer env.log_controller.release(io, generation); try testing.expect(!generation.logger.privacy().hide_domains); } try awaitQuiesced(io, env); try expectPutOk(&conn, &body_buf, \\{"logging":{"query_log_buffer_max":700,"hide_domains":true}} ); try testing.expectEqual(@as(u32, 700), env.log_controller.capacity(io)); { const generation = env.log_controller.acquire(io); defer env.log_controller.release(io, generation); try testing.expect(generation.logger.privacy().hide_domains); const transformed = generation.logger.transformed(.init(.{ .timestamp = 1, .domain = "tracker.example", .client_ip = "192.0.2.10", .qtype = 1, .blocked = false, .response_time_us = 900, .cache_hit = false, .upstream = "9.9.9.9", })); try testing.expectEqualStrings(logger_mod.hidden_marker, transformed.domain()); } try awaitQuiesced(io, env); // upstream_generation — a timeout change is a replace, and one only. const replaces_before = env.live_owner.publishedCount(io); try expectPutOk(&conn, &body_buf, "{\"upstream\":{\"total_timeout_ms\":6000}}"); try testing.expectEqual(replaces_before + 1, env.live_owner.publishedCount(io)); // Two keys of ONE owner in ONE put is ONE candidate. const before_pair = env.live_owner.publishedCount(io); try expectPutOk(&conn, &body_buf, \\{"upstream":{"attempt_timeout_ms":1500,"total_timeout_ms":7000}} ); try testing.expectEqual(before_pair + 1, env.live_owner.publishedCount(io)); // Nothing above creates or destroys a socket, so nothing above owes a // restart. This is the milestone's headline claim. try testing.expect(!env.state.restart_pending.load(.monotonic)); } test "W10 milestone 34: every live settings operation applies through a real PUT" { if (!build_options.integration) return error.SkipZigTest; var env = try Env.create(testing.allocator, .{ .live_apply = true }); defer env.destroy(); try bounded(env.io(), default_budget, liveOperations, .{ env.io(), env }); } fn logSinkAndMonitor(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); var path_body: std.ArrayList(u8) = .empty; defer path_body.deinit(env.gpa); // stderr → file: the sink opens the new target and the monitor starts // measuring the directory it is in. path_body.clearRetainingCapacity(); try path_body.print(env.gpa, "{{\"logging\":{{\"output\":\"file\",\"file_path\":\"{s}\"}}}}", .{env.paths.log}); try expectPutOk(&conn, &body_buf, path_body.items); { const borrow = env.monitor.acquireLogDir(io); defer env.monitor.releaseLogDir(io, borrow); try testing.expectEqualStrings(env.paths.abs, borrow.path.?); } // file → a file in another directory: the monitor re-points. path_body.clearRetainingCapacity(); try path_body.print(env.gpa, "{{\"logging\":{{\"file_path\":\"{s}\"}}}}", .{env.paths.log2}); try expectPutOk(&conn, &body_buf, path_body.items); { const borrow = env.monitor.acquireLogDir(io); defer env.monitor.releaseLogDir(io, borrow); try testing.expect(std.mem.endsWith(u8, borrow.path.?, "/logs2")); } // file → stderr: there is no log file to run out of room for, so the // monitor stops measuring one. Restoring stderr also puts this test // binary's own diagnostics back where the rest of the suite expects them. try expectPutOk(&conn, &body_buf, "{\"logging\":{\"output\":\"stderr\"}}"); { const borrow = env.monitor.acquireLogDir(io); defer env.monitor.releaseLogDir(io, borrow); try testing.expectEqual(@as(?[:0]const u8, null), borrow.path); } try testing.expect(!env.state.restart_pending.load(.monotonic)); } test "W10 milestone 34: a log-sink change moves the target and the measured directory" { if (!build_options.integration) return error.SkipZigTest; var env = try Env.create(testing.allocator, .{ .live_apply = true }); defer env.destroy(); try bounded(env.io(), default_budget, logSinkAndMonitor, .{ env.io(), env }); } fn enabledCertPaths(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); var body: std.ArrayList(u8) = .empty; defer body.deinit(env.gpa); const doh_before = env.doh_store.reloads.load(.monotonic); body.clearRetainingCapacity(); try body.print(env.gpa, "{{\"doh_server\":{{\"cert_path\":\"{s}\",\"key_path\":\"{s}\"}}}}", .{ env.paths.cert2, env.paths.key2, }); try expectPutOk(&conn, &body_buf, body.items); try testing.expectEqual(doh_before + 1, env.doh_store.reloads.load(.monotonic)); try testing.expectEqualStrings(env.paths.cert2, env.doh_store.cert_path); // The DoT store is a separate owner and a separate key pair: the DoH apply // above must not have touched it. const dot_before = env.dot_store.reloads.load(.monotonic); body.clearRetainingCapacity(); try body.print(env.gpa, "{{\"dot_server\":{{\"cert_path\":\"{s}\",\"key_path\":\"{s}\"}}}}", .{ env.paths.cert2, env.paths.key2, }); try expectPutOk(&conn, &body_buf, body.items); try testing.expectEqual(dot_before + 1, env.dot_store.reloads.load(.monotonic)); try testing.expectEqual(doh_before + 1, env.doh_store.reloads.load(.monotonic)); // A path with no certificate behind it is refused at prepare: the store // keeps serving what it had and the row does not move. body.clearRetainingCapacity(); try body.print(env.gpa, "{{\"doh_server\":{{\"cert_path\":\"{s}/nope.pem\"}}}}", .{env.paths.dir}); const refused = try putSettings(&conn, &body_buf, body.items); try testing.expectEqual(@as(u16, 400), refused.status); try testing.expectEqual(doh_before + 1, env.doh_store.reloads.load(.monotonic)); try testing.expectEqualStrings(env.paths.cert2, env.doh_store.cert_path); try testing.expect(!env.state.restart_pending.load(.monotonic)); } test "W10 milestone 34: a cert-path change on an enabled endpoint reloads that store only" { if (!build_options.integration) return error.SkipZigTest; var env = try Env.create(testing.allocator, .{ .live_apply = true }); defer env.destroy(); try bounded(env.io(), default_budget, enabledCertPaths, .{ env.io(), env }); } fn disabledCertPaths(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); // No store exists for a disabled endpoint (`doh_certs` is null), so the // change is the database row and nothing else — including no restart. try expectPutOk(&conn, &body_buf, "{\"doh_server\":{\"cert_path\":\"/etc/nxdns/other.pem\"}}"); try testing.expectEqual( @as(i64, 1), try env.config_db.queryInt( "SELECT count(*) FROM settings WHERE key = 'doh_server.cert_path' AND value = '/etc/nxdns/other.pem'", ), ); try testing.expect(!env.state.restart_pending.load(.monotonic)); // And the response says the same thing the table does: this key is not one // of the two that wait for a restart. try conn.request("GET", "/api/settings", null, null); const read = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 200), read.status); try testing.expect(std.mem.indexOf(u8, read.body, "\"doh_server.cert_path\"") == null); try testing.expect(std.mem.indexOf(u8, read.body, "\"doh_server.port\"") != null); } test "W10 milestone 34: a cert-path change on a disabled endpoint is a row and nothing else" { if (!build_options.integration) return error.SkipZigTest; var env = try Env.create(testing.allocator, .{}); defer env.destroy(); try bounded(env.io(), default_budget, disabledCertPaths, .{ env.io(), env }); } fn upstreamMutationsApply(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); const before = env.live_owner.publishedCount(io); try conn.request("POST", "/api/upstreams", null, "{\"url\":\"https://dns2.example/dns-query\"}"); const created = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 201), created.status); try testing.expect(std.mem.indexOf(u8, created.body, "\"restart_required\":false") != null); try testing.expectEqual(before + 1, env.live_owner.publishedCount(io)); const id = try createdId(created.body); // The published generation is the row set the commit made true. { const generation = env.live_owner.acquire(io); defer env.live_owner.release(io, generation); try testing.expectEqual(@as(usize, 2), generation.activeCount()); } try conn.request("PUT", "/api/upstreams/1", null, "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}"); const updated = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 200), updated.status); try testing.expect(std.mem.indexOf(u8, updated.body, "\"restart_required\":false") != null); try testing.expectEqual(before + 2, env.live_owner.publishedCount(io)); var target: [64]u8 = undefined; try conn.request("DELETE", try std.fmt.bufPrint(&target, "/api/upstreams/{d}", .{id}), null, null); const deleted = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 204), deleted.status); try testing.expectEqual(before + 3, env.live_owner.publishedCount(io)); { const generation = env.live_owner.acquire(io); defer env.live_owner.release(io, generation); try testing.expectEqual(@as(usize, 1), generation.activeCount()); } try testing.expect(!env.state.restart_pending.load(.monotonic)); } test "W10 milestone 34: an upstream create, update and delete each publish a generation" { if (!build_options.integration) return error.SkipZigTest; var env = try Env.create(testing.allocator, .{ .live_apply = true }); defer env.destroy(); try bounded(env.io(), default_budget, upstreamMutationsApply, .{ env.io(), env }); } fn activeConfigurationWarnings(io: std.Io, env: *Env, gpa: Allocator, out: *std.ArrayList([]const u8)) !void { var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const page = try env.events_store.selectEvents(io, arena_state.allocator(), .{ .state = .active, .limit = events_mod.max_limit, }); for (page.events) |row| { if (!std.mem.eql(u8, events_mod.wire(.configuration_load), row.code)) continue; try out.append(gpa, try gpa.dupe(u8, row.subject)); } } fn upstreamDiagnosticsReconcile(io: std.Io, env: *Env) anyerror!void { const gpa = env.gpa; var body_buf: [16 * 1024]u8 = undefined; var conn: Conn = undefined; try conn.connect(io, env.addr); defer conn.close(io); // An unrelated `configuration.load` warning, active for the whole cycle. // The reconciler is SCOPED: it may resolve only the upstream keys the // previous generation reported, never every episode of the code. env.events_store.report( io, seeded_now, .configuration_load, "dns.bind_ipv6", "dns.bind_ipv6", .warning, "this system has no IPv6; serving IPv4 only", ); // A row the validator would refuse but the database can hold: the scheme // is what `Endpoint.parse` rejects, and a build over it emits a finding. const bad = "ftp://nope.example"; try env.config_db.exec( \\INSERT INTO upstreams (id, url, priority, enabled, tls_name) \\VALUES (7, 'ftp://nope.example', 100, 1, ''); ); // A settings PUT validates the whole stored configuration and would refuse // it, so the rebuild is driven through the upstream resource, which // validates the item it is handed rather than the rows already stored. try conn.request("POST", "/api/upstreams", null, "{\"url\":\"https://dns2.example/dns-query\"}"); const created = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 201), created.status); const spare = try createdId(created.body); var keys: std.ArrayList([]const u8) = .empty; defer { for (keys.items) |key| gpa.free(key); keys.deinit(gpa); } try activeConfigurationWarnings(io, env, gpa, &keys); try testing.expect(containsKey(keys.items, bad)); try testing.expect(containsKey(keys.items, "dns.bind_ipv6")); // Fixing the row resolves its episode, and only its episode. try env.config_db.exec("UPDATE upstreams SET url = 'https://fixed.example/dns-query' WHERE id = 7;"); var target: [64]u8 = undefined; try conn.request("DELETE", try std.fmt.bufPrint(&target, "/api/upstreams/{d}", .{spare}), null, null); try testing.expectEqual(@as(u16, 204), (try conn.receive(&body_buf)).status); for (keys.items) |key| gpa.free(key); keys.clearRetainingCapacity(); try activeConfigurationWarnings(io, env, gpa, &keys); try testing.expect(!containsKey(keys.items, bad)); try testing.expect(containsKey(keys.items, "dns.bind_ipv6")); } fn containsKey(keys: []const []const u8, want: []const u8) bool { for (keys) |key| { if (std.mem.eql(u8, key, want)) return true; } return false; } test "W10 milestone 34: an upstream apply reconciles its own findings and spares the rest" { if (!build_options.integration) return error.SkipZigTest; var env = try Env.create(testing.allocator, .{ .live_apply = true }); defer env.destroy(); try bounded(env.io(), default_budget, upstreamDiagnosticsReconcile, .{ env.io(), env }); } fn failedPrepareChangesNothing(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); const ttl_before = env.handler.policySnapshot(io).blocking.ttl; const replaces_before = env.live_owner.publishedCount(io); // One live key beside one owner whose prepare cannot succeed: the sink // cannot open a target under a directory that does not exist. var body: std.ArrayList(u8) = .empty; defer body.deinit(env.gpa); try body.print( env.gpa, "{{\"blocking\":{{\"ttl\":77}},\"upstream\":{{\"total_timeout_ms\":6300}}," ++ "\"logging\":{{\"output\":\"file\",\"file_path\":\"{s}/missing/deeper/nxdns.log\"}}}}", .{env.paths.abs}, ); const refused = try putSettings(&conn, &body_buf, body.items); try testing.expectEqual(@as(u16, 400), refused.status); // Nothing published, nothing written. try testing.expectEqual(ttl_before, env.handler.policySnapshot(io).blocking.ttl); try testing.expectEqual(replaces_before, env.live_owner.publishedCount(io)); try testing.expectEqual( @as(i64, 0), try env.config_db.queryInt("SELECT count(*) FROM settings WHERE key = 'blocking.ttl' AND value = '77'"), ); } test "W10 milestone 34: a put mixing a live key with a failing prepare writes nothing" { if (!build_options.integration) return error.SkipZigTest; var env = try Env.create(testing.allocator, .{ .live_apply = true }); defer env.destroy(); try bounded(env.io(), default_budget, failedPrepareChangesNothing, .{ env.io(), env }); } fn webLifecycleCommitsAndWaits(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 testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null)); try expectPutOk(&conn, &body_buf, "{\"web\":{\"enabled\":false}}"); // The row moved, the flag rose, and the listener this request arrived on is // still serving: milestone 35 executes the change, this one records it. try testing.expectEqual( @as(i64, 1), try env.config_db.queryInt("SELECT count(*) FROM settings WHERE key = 'web.enabled' AND value = 'false'"), ); try testing.expect(try restartPending(env.gpa, &conn, &body_buf, null)); } test "W10 milestone 34: a web.enabled put commits, flags a restart, and executes nothing" { if (!build_options.integration) return error.SkipZigTest; var env = try Env.create(testing.allocator, .{ .live_apply = true }); defer env.destroy(); try bounded(env.io(), default_budget, webLifecycleCommitsAndWaits, .{ env.io(), env }); } fn upstreamPutWhileHeld(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); // The hold an in-flight exchange takes, taken directly: `acquire` is the // query path's own pin (handler.zig), and holding it here is the same // condition as an exchange that has not returned yet. const held = env.live_owner.acquire(io); const before = env.live_owner.publishedCount(io); try conn.request("POST", "/api/upstreams", null, "{\"url\":\"https://dns2.example/dns-query\"}"); const created = try conn.receive(&body_buf); try testing.expectEqual(@as(u16, 201), created.status); // The publish and the diagnostics reconciliation both completed on the // request's task, with G1 still pinned: neither waits for the reader. try testing.expectEqual(before + 1, env.live_owner.publishedCount(io)); try testing.expect(held.retired); try testing.expect(!env.events_store.writeFailed()); // The reader still sees the generation it pinned, and only its release // tears that generation down — which the testing allocator's leak check at // the end of this test is what proves. try testing.expectEqual(@as(usize, 1), held.activeCount()); env.live_owner.release(io, held); const fresh = env.live_owner.acquire(io); defer env.live_owner.release(io, fresh); try testing.expect(fresh != held); try testing.expectEqual(@as(usize, 2), fresh.activeCount()); } test "W10 milestone 34: an upstream write publishes while an in-flight reader still holds the old generation" { if (!build_options.integration) return error.SkipZigTest; var env = try Env.create(testing.allocator, .{ .live_apply = true }); defer env.destroy(); try bounded(env.io(), default_budget, upstreamPutWhileHeld, .{ env.io(), env }); }