Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
3919 lines
177 KiB
Zig
3919 lines
177 KiB
Zig
//! Milestone-8 W10: the web layer end to end, over loopback sockets.
|
|
//!
|
|
//! The heart of the file is the contract table (ruling 23): one entry per
|
|
//! route in `routes.zig`, carrying the request that exercises it, the status
|
|
//! it must answer, and the response shape that `std.json.parseFromSlice` must
|
|
//! accept with `.ignore_unknown_fields = false`. The auth and rate-limit
|
|
//! markers are asserted against the served table rather than re-typed, so the
|
|
//! contract and the router cannot disagree about policy.
|
|
//!
|
|
//! Every test drives the real stack: `server.Server` accepting on 127.0.0.1,
|
|
//! the shipped route table, real `Sessions`, a real `ApiLimiter`, a real
|
|
//! `Manager` reloading real snapshots out of an in-memory config database,
|
|
//! and a seeded in-memory query log. The only stand-ins are the upstream
|
|
//! pool's transport (never exchanged with) and the blocklist fetcher (never
|
|
//! fetched from — the one `POST /api/blocklists/update` in the walk runs
|
|
//! before any source row exists, so the suite stays hermetic).
|
|
//!
|
|
//! Compiled by every `zig build test`; skips at run time without
|
|
//! `-Dintegration`, like the other loopback suites.
|
|
|
|
const std = @import("std");
|
|
const build_options = @import("build_options");
|
|
const contract_samples = @import("contract_samples");
|
|
const http = std.http;
|
|
const net = std.Io.net;
|
|
const Allocator = std.mem.Allocator;
|
|
|
|
const address = @import("../platform/address.zig");
|
|
const api_limiter = @import("api_limiter.zig");
|
|
const auth = @import("auth.zig");
|
|
const clients_repo = @import("../storage/repositories/clients_repo.zig");
|
|
const db = @import("../storage/db.zig");
|
|
const dns_handler = @import("../server/handler.zig");
|
|
const 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_mod = @import("../storage/logger.zig");
|
|
const manager_mod = @import("../filter/manager.zig");
|
|
const migrations = @import("../storage/migrations.zig");
|
|
const name = @import("../dns/name.zig");
|
|
const openapi = @import("openapi.zig");
|
|
const packet = @import("../dns/packet.zig");
|
|
const pause_mod = @import("../server/pause.zig");
|
|
const coverage_mod = @import("coverage.zig");
|
|
const pool_mod = @import("../upstream/pool.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_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 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,
|
|
};
|
|
|
|
/// 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: pool_mod.Pool,
|
|
state: server.WebState,
|
|
web: server.Server,
|
|
group: std.Io.Group,
|
|
addr: net.IpAddress,
|
|
|
|
fn create(gpa: Allocator, options: EnvOptions) !*Env {
|
|
const self = try gpa.create(Env);
|
|
errdefer gpa.destroy(self);
|
|
|
|
self.gpa = gpa;
|
|
self.threaded = .init(gpa, .{});
|
|
errdefer self.threaded.deinit();
|
|
const ioh = self.threaded.io();
|
|
|
|
self.tmp = testing.tmpDir(.{ .iterate = true });
|
|
errdefer self.tmp.cleanup();
|
|
|
|
self.config_db = try db.Db.open(":memory:", .{ .mode = .memory });
|
|
errdefer self.config_db.close();
|
|
try db.applyPragmas(&self.config_db, .{});
|
|
_ = try migrations.migrate(&self.config_db);
|
|
try seedConfig(&self.config_db);
|
|
|
|
self.querylog_db = try db.Db.open(":memory:", .{ .mode = .memory });
|
|
errdefer self.querylog_db.close();
|
|
try self.querylog_db.exec(querylog_schema.ddl);
|
|
try seedQueryLog(&self.querylog_db);
|
|
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;
|
|
|
|
self.pool_entries = .{.{
|
|
.endpoint = transport.Endpoint.parse("https://dns.example/dns-query") catch unreachable,
|
|
// Never exchanged with: the pool feeds `/metrics` and the
|
|
// `/api/health` upstream condition only.
|
|
.client = .{ .ptr = undefined, .exchangeFn = undefined },
|
|
.priority = 1,
|
|
.enabled = true,
|
|
.health = .init,
|
|
}};
|
|
self.pool = .init(&self.pool_entries, .{}, .{
|
|
.attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake },
|
|
.total = .{ .raw = .fromMilliseconds(100), .clock = .awake },
|
|
}, 1);
|
|
|
|
self.state = .{
|
|
.gpa = gpa,
|
|
.web = .{
|
|
.password_hash = options.password_hash,
|
|
.api_rate_limit_per_min = options.rate_per_min,
|
|
.api_localhost_exempt = options.localhost_exempt,
|
|
.sse_max_connections_per_ip = options.sse_max_per_ip,
|
|
.trusted_proxies = options.trusted_proxies,
|
|
},
|
|
.authority = options.authority,
|
|
.reconciled_at = options.reconciled_at,
|
|
.live_hash = .init(options.password_hash),
|
|
.pause = &self.pauser,
|
|
.manager = &self.mgr,
|
|
.pool = &self.pool,
|
|
.local_tables = &self.tables,
|
|
.sessions = &self.sessions,
|
|
.limiter = &self.limiter,
|
|
.hub = self.hub,
|
|
.config_db = &self.config_db,
|
|
.querylog_db = 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,
|
|
};
|
|
|
|
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
|
self.web = try server.Server.listen(gpa, ioh, listen_address, &self.state, .{ .max_connections = 8 });
|
|
self.addr = self.web.boundAddress();
|
|
|
|
self.group = .init;
|
|
try self.group.concurrent(ioh, server.Server.serve, .{ &self.web, ioh });
|
|
|
|
return self;
|
|
}
|
|
|
|
fn destroy(self: *Env) void {
|
|
const gpa = self.gpa;
|
|
const ioh = self.threaded.io();
|
|
|
|
self.web.deinit(ioh);
|
|
self.group.await(ioh) catch |err| switch (err) {
|
|
error.Canceled => unreachable,
|
|
};
|
|
|
|
self.state.live_hash.deinit(gpa);
|
|
self.tables.deinit(gpa);
|
|
gpa.destroy(self.hub);
|
|
self.limiter.deinit();
|
|
self.mgr.deinit(ioh);
|
|
self.http_client.deinit();
|
|
self.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,
|
|
authority: struct { mode: []const u8, path: ?[]const u8, reconciled_at: ?i64 },
|
|
};
|
|
|
|
const Contract = struct {
|
|
method: http.Method,
|
|
/// Must equal a `routes.zig` pattern; the coverage test enforces it.
|
|
pattern: []const u8,
|
|
auth: router.Auth,
|
|
/// Milestone-20 ruling 7's class, restated here so the coverage test can
|
|
/// hold the served table to it. No default, like the route table.
|
|
policy: router.Policy,
|
|
rate_limit: router.RateLimit = .counted,
|
|
/// The concrete request target the walk sends.
|
|
target: []const u8,
|
|
body: ?[]const u8 = null,
|
|
status: u16,
|
|
kind: enum { json, raw, none, sse } = .json,
|
|
/// `.json`: the strict response shape.
|
|
check: ?*const fn (Allocator, []const u8) anyerror!void = null,
|
|
/// `.raw`: a substring the body must contain.
|
|
needle: []const u8 = "",
|
|
};
|
|
|
|
/// Execution order is the table order: `POST /api/blocklists/update` runs
|
|
/// while no source row exists (hermetic), reads of `{id}` routes follow the
|
|
/// create that made the row, and deletes come last for their resource.
|
|
const contract = [_]Contract{
|
|
// Monitoring and contract.
|
|
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .policy = .read, .rate_limit = .exempt, .target = "/metrics", .status = 200, .kind = .raw, .needle = "nxdns_up 1" },
|
|
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .rate_limit = .exempt, .target = "/api/health", .status = 200, .check = jsonShape(handlers_health.Body) },
|
|
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .policy = .read, .target = "/api/version", .status = 200, .check = jsonShape(handlers_version.Body) },
|
|
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .policy = .read, .target = "/api/openapi.yaml", .status = 200, .kind = .raw, .needle = "openapi: 3.0.3" },
|
|
|
|
// Authentication (auth is disabled in the walk's environment; the on/off
|
|
// matrix has its own test).
|
|
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .policy = .runtime_action, .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200, .check = jsonShape(LoginView) },
|
|
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .policy = .runtime_action, .target = "/api/auth/logout", .status = 200, .check = jsonShape(LogoutView) },
|
|
|
|
// Refresh-all before any source row exists: nothing to fetch, 202 anyway.
|
|
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .target = "/api/blocklists/update", .status = 202, .check = jsonShape(StatusList) },
|
|
|
|
// Query log, stats, live stream, upstream health.
|
|
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) },
|
|
.{ .method = .GET, .pattern = "/api/queries/{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 = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .target = "/api/certs/reload", .status = 200, .check = jsonShape(handlers_certs.View) },
|
|
|
|
// The walk's last delete returns the groups table to its seeded shape.
|
|
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .target = "/api/groups/2", .status = 204, .kind = .none },
|
|
};
|
|
|
|
// Drift guard: the contract table covers the served route table exactly —
|
|
// every entry matches one route, no route is missed, and the policy columns
|
|
// agree with the table the router dispatches from (not a re-typed copy).
|
|
// Pure bookkeeping, so it runs in every suite.
|
|
test "the contract table covers every served route with the served policy" {
|
|
var covered = [_]bool{false} ** 64;
|
|
try testing.expect(router.routes.len <= covered.len);
|
|
try testing.expectEqual(router.routes.len, contract.len);
|
|
|
|
for (contract) |entry| {
|
|
var found = false;
|
|
for (router.routes, 0..) |route, index| {
|
|
if (route.method != entry.method) continue;
|
|
if (!std.mem.eql(u8, route.pattern, entry.pattern)) continue;
|
|
try testing.expect(!covered[index]);
|
|
covered[index] = true;
|
|
try testing.expectEqual(route.auth, entry.auth);
|
|
try testing.expectEqual(route.rate_limit, entry.rate_limit);
|
|
try testing.expectEqual(route.policy, entry.policy);
|
|
found = true;
|
|
break;
|
|
}
|
|
if (!found) {
|
|
std.debug.print("contract entry has no route: {t} {s}\n", .{ entry.method, entry.pattern });
|
|
return error.TestUnexpectedResult;
|
|
}
|
|
}
|
|
for (covered[0..router.routes.len], 0..) |seen, index| {
|
|
if (!seen) {
|
|
std.debug.print("route has no contract entry: {s}\n", .{router.routes[index].pattern});
|
|
return error.TestUnexpectedResult;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn contractWalk(io: std.Io, env: *Env) anyerror!void {
|
|
var arena_state: std.heap.ArenaAllocator = .init(env.gpa);
|
|
defer arena_state.deinit();
|
|
|
|
var body_buf: [128 * 1024]u8 = undefined;
|
|
for (contract, 0..) |entry, index| {
|
|
_ = arena_state.reset(.retain_capacity);
|
|
const arena = arena_state.allocator();
|
|
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
try conn.request(@tagName(entry.method), entry.target, null, entry.body);
|
|
|
|
if (entry.kind == .sse) {
|
|
const head = try conn.receiveHead();
|
|
try testing.expectEqual(entry.status, head.status);
|
|
try testing.expectEqualStrings("text/event-stream", head.header("content-type").?);
|
|
var seen: std.ArrayList(u8) = .empty;
|
|
defer seen.deinit(env.gpa);
|
|
try conn.readChunkedUntil(&seen, env.gpa, "retry: 3000");
|
|
continue;
|
|
}
|
|
|
|
const response = conn.receive(&body_buf) catch |err| {
|
|
std.debug.print("contract[{d}] {t} {s}: no response ({t})\n", .{ index, entry.method, entry.target, err });
|
|
return err;
|
|
};
|
|
if (response.status != entry.status) {
|
|
std.debug.print(
|
|
"contract[{d}] {t} {s}: expected {d}, got {d} body {s}\n",
|
|
.{ index, entry.method, entry.target, entry.status, response.status, response.body },
|
|
);
|
|
return error.TestUnexpectedResult;
|
|
}
|
|
|
|
switch (entry.kind) {
|
|
.json => {
|
|
const check = entry.check orelse return error.TestBadContractEntry;
|
|
check(arena, response.body) catch |err| {
|
|
std.debug.print(
|
|
"contract[{d}] {t} {s}: shape rejected ({t}) body {s}\n",
|
|
.{ index, entry.method, entry.target, err, response.body },
|
|
);
|
|
return err;
|
|
};
|
|
},
|
|
.raw => try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, entry.needle)),
|
|
.none => try testing.expectEqual(@as(usize, 0), response.body.len),
|
|
.sse => unreachable,
|
|
}
|
|
}
|
|
}
|
|
|
|
test "W10 contract: every route answers its documented status and shape" {
|
|
if (!build_options.integration) return error.SkipZigTest;
|
|
|
|
const gpa = testing.allocator;
|
|
var env = try Env.create(gpa, .{});
|
|
defer env.destroy();
|
|
|
|
try bounded(env.io(), default_budget, contractWalk, .{ env.io(), env });
|
|
}
|
|
|
|
// The wire shape of the certs reload payload, restated so the handler's own
|
|
// `View` cannot vouch for itself. Runs in every suite: it needs no socket.
|
|
const CertOutcomeShape = struct { enabled: bool, reloaded: bool, @"error": ?[]const u8 };
|
|
const CertsReloadShape = struct { doh: CertOutcomeShape, dot: CertOutcomeShape };
|
|
|
|
test "the certs reload payload with both endpoints disabled parses strictly" {
|
|
const gpa = testing.allocator;
|
|
|
|
var state: server.WebState = .{ .gpa = gpa };
|
|
const view = handlers_certs.applyReload(&state, undefined);
|
|
|
|
var out: std.Io.Writer.Allocating = .init(gpa);
|
|
defer out.deinit();
|
|
try std.json.Stringify.value(view, .{}, &out.writer);
|
|
|
|
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
|
defer arena_state.deinit();
|
|
const parsed = try std.json.parseFromSliceLeaky(
|
|
CertsReloadShape,
|
|
arena_state.allocator(),
|
|
out.written(),
|
|
.{ .ignore_unknown_fields = false },
|
|
);
|
|
for ([_]CertOutcomeShape{ parsed.doh, parsed.dot }) |per_endpoint| {
|
|
try testing.expect(!per_endpoint.enabled);
|
|
try testing.expect(!per_endpoint.reloaded);
|
|
try testing.expectEqual(@as(?[]const u8, null), per_endpoint.@"error");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// auth on/off matrix (rulings 17, 18)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const test_password = "correct horse battery staple";
|
|
|
|
/// Hashes on an `Io` of its own, before the environment exists, so nothing
|
|
/// mutates a `WebState` the server tasks are already reading.
|
|
fn hashTestPassword(gpa: Allocator, buf: []u8) ![]const u8 {
|
|
var hash_threaded: std.Io.Threaded = .init(gpa, .{});
|
|
defer hash_threaded.deinit();
|
|
return std.crypto.pwhash.argon2.strHash(test_password, .{
|
|
.allocator = gpa,
|
|
.params = .owasp_2id,
|
|
.mode = .argon2id,
|
|
.encoding = .phc,
|
|
}, buf, hash_threaded.io());
|
|
}
|
|
|
|
fn authMatrix(io: std.Io, env: *Env) anyerror!void {
|
|
var body_buf: [4096]u8 = undefined;
|
|
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
// Without a session: session routes are 401, ruling 18's open set is not.
|
|
try conn.request("GET", "/api/groups", null, null);
|
|
var response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 401), response.status);
|
|
try testing.expectEqualStrings("{\"error\":\"authentication required\"}", response.body);
|
|
|
|
try conn.request("GET", "/api/health", null, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
|
|
// A wrong password is 401 and mints nothing.
|
|
try conn.request("POST", "/api/auth/login", null, "{\"password\":\"wrong\"}");
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 401), response.status);
|
|
try testing.expectEqual(@as(?[]const u8, null), response.header("set-cookie"));
|
|
|
|
// The right password sets the session cookie with ruling 17's attributes.
|
|
try conn.request("POST", "/api/auth/login", null, "{\"password\":\"" ++ test_password ++ "\"}");
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
const set_cookie = response.header("set-cookie") orelse return error.TestNoCookie;
|
|
try testing.expect(std.mem.containsAtLeast(u8, set_cookie, 1, "HttpOnly"));
|
|
try testing.expect(std.mem.containsAtLeast(u8, set_cookie, 1, "SameSite=Lax"));
|
|
try testing.expect(!std.mem.containsAtLeast(u8, set_cookie, 1, "Secure"));
|
|
|
|
const cookie_end = std.mem.findScalar(u8, set_cookie, ';') orelse set_cookie.len;
|
|
var cookie_buf: [256]u8 = undefined;
|
|
const cookie_line = try std.fmt.bufPrint(&cookie_buf, "cookie: {s}", .{set_cookie[0..cookie_end]});
|
|
|
|
// The cookie opens the session routes.
|
|
try conn.request("GET", "/api/groups", cookie_line, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
|
|
// Logout deletes the cookie and closes the session.
|
|
try conn.request("POST", "/api/auth/logout", cookie_line, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
const deleted = response.header("set-cookie") orelse return error.TestNoCookie;
|
|
try testing.expect(std.mem.containsAtLeast(u8, deleted, 1, "Max-Age=0"));
|
|
|
|
try conn.request("GET", "/api/groups", cookie_line, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 401), response.status);
|
|
}
|
|
|
|
test "W10 auth on: password-hashed environment enforces the session matrix" {
|
|
if (!build_options.integration) return error.SkipZigTest;
|
|
|
|
const gpa = testing.allocator;
|
|
var hash_buf: [256]u8 = undefined;
|
|
const hash = try hashTestPassword(gpa, &hash_buf);
|
|
|
|
var env = try Env.create(gpa, .{ .password_hash = hash });
|
|
defer env.destroy();
|
|
|
|
try bounded(env.io(), default_budget, authMatrix, .{ env.io(), env });
|
|
}
|
|
|
|
fn authOff(io: std.Io, env: *Env) anyerror!void {
|
|
var body_buf: [4096]u8 = undefined;
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
// No hash stored: every session route is open (ruling 17).
|
|
try conn.request("GET", "/api/groups", null, null);
|
|
var response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
|
|
// Login still answers, reporting that no password is required, and mints
|
|
// no cookie.
|
|
try conn.request("POST", "/api/auth/login", null, "{\"password\":\"anything\"}");
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "\"auth_required\":false"));
|
|
try testing.expectEqual(@as(?[]const u8, null), response.header("set-cookie"));
|
|
}
|
|
|
|
test "W10 auth off: an empty hash leaves every route open" {
|
|
if (!build_options.integration) return error.SkipZigTest;
|
|
|
|
const gpa = testing.allocator;
|
|
var env = try Env.create(gpa, .{});
|
|
defer env.destroy();
|
|
|
|
try bounded(env.io(), default_budget, authOff, .{ env.io(), env });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// file authority (milestone-20 ruling 7)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const managed_path = "/etc/nxdns/config.zon";
|
|
const managed_body = "{\"error\":\"configuration is managed by " ++ managed_path ++
|
|
"; edit the file and restart\"}";
|
|
|
|
/// Long enough that the envelope could not be built in the 512-byte stack
|
|
/// buffer `respondError` used before this milestone. Nested bind mounts really
|
|
/// do produce paths like this, and the old code answered them in `text/plain`.
|
|
const long_managed_path = "/mnt/" ++ ("deeply-nested-bind-mount/" ** 24) ++ "config.zon";
|
|
|
|
fn fileModeClasses(io: std.Io, env: *Env) anyerror!void {
|
|
var body_buf: [8192]u8 = undefined;
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
// A read is untouched.
|
|
try conn.request("GET", "/api/groups", null, null);
|
|
var response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
|
|
// Every class of configuration write answers the one envelope.
|
|
const writes = [_]struct { method: []const u8, target: []const u8, body: ?[]const u8 }{
|
|
.{ .method = "POST", .target = "/api/groups", .body = "{\"name\":\"kids\"}" },
|
|
.{ .method = "PUT", .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}" },
|
|
.{ .method = "PUT", .target = "/api/clients/1", .body = "{\"name\":\"x\",\"group_id\":1}" },
|
|
.{ .method = "DELETE", .target = "/api/upstreams/1", .body = null },
|
|
};
|
|
for (writes) |write| {
|
|
try conn.request(write.method, write.target, null, write.body);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 403), response.status);
|
|
try testing.expectEqualStrings(managed_body, response.body);
|
|
try testing.expectEqualStrings("application/json", response.header("content-type").?);
|
|
}
|
|
|
|
// Rejected before the handler, not after it: the group was never created.
|
|
try conn.request("GET", "/api/groups", null, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "kids"));
|
|
|
|
// Runtime actions stay live.
|
|
try conn.request("POST", "/api/pause", null, "{\"paused\":false}");
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
|
|
try conn.request("POST", "/api/blocklists/update", null, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 202), response.status);
|
|
|
|
try conn.request("POST", "/api/certs/reload", null, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
|
|
// 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, fileModeClasses, .{ env.io(), env });
|
|
}
|
|
|
|
fn fileModeClientDelete(io: std.Io, env: *Env) anyerror!void {
|
|
var body_buf: [4096]u8 = undefined;
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
// The declared row contradicts the file, so it stays.
|
|
try conn.request("DELETE", "/api/clients/2", null, null);
|
|
var response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 403), response.status);
|
|
try testing.expectEqualStrings(managed_body, response.body);
|
|
|
|
// The observed row is runtime state the file never declared; without this
|
|
// a departed device would be immortal, since the file can only promote an
|
|
// address, never forget one.
|
|
try conn.request("DELETE", "/api/clients/1", null, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 204), response.status);
|
|
|
|
// An id no client holds is still a 404, not a policy verdict.
|
|
try conn.request("DELETE", "/api/clients/999", null, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 404), response.status);
|
|
}
|
|
|
|
test "W10 milestone 20: file authority deletes an observed client and refuses a declared one" {
|
|
if (!build_options.integration) return error.SkipZigTest;
|
|
|
|
const gpa = testing.allocator;
|
|
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = managed_path } });
|
|
defer env.destroy();
|
|
|
|
// Row 1 is seeded observed (`hand_edited = 0`); row 2 is what the file
|
|
// declares.
|
|
try env.config_db.exec(
|
|
\\INSERT INTO clients (id, ip, name, group_id, hand_edited, first_seen, last_seen)
|
|
\\VALUES (2, '192.168.1.51', 'nas', 1, 1, 1700000000, 1700000000)
|
|
);
|
|
|
|
try bounded(env.io(), default_budget, fileModeClientDelete, .{ env.io(), env });
|
|
}
|
|
|
|
fn longPathEnvelope(io: std.Io, env: *Env) anyerror!void {
|
|
var body_buf: [8192]u8 = undefined;
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
|
|
const response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 403), response.status);
|
|
try testing.expect(response.body.len > 512);
|
|
try testing.expectEqualStrings("application/json", response.header("content-type").?);
|
|
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, long_managed_path));
|
|
|
|
// Still the documented envelope, not a truncation and not plain text.
|
|
const parsed = try std.json.parseFromSlice(
|
|
struct { @"error": []const u8 },
|
|
env.gpa,
|
|
response.body,
|
|
.{},
|
|
);
|
|
defer parsed.deinit();
|
|
}
|
|
|
|
test "W10 milestone 20: an error longer than the old 512-byte buffer stays application/json" {
|
|
if (!build_options.integration) return error.SkipZigTest;
|
|
|
|
const gpa = testing.allocator;
|
|
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = long_managed_path } });
|
|
defer env.destroy();
|
|
|
|
try bounded(env.io(), default_budget, longPathEnvelope, .{ env.io(), env });
|
|
}
|
|
|
|
fn fileModeUnauthenticated(io: std.Io, env: *Env) anyerror!void {
|
|
var body_buf: [4096]u8 = undefined;
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
// Policy runs after authentication: a caller with no session learns that
|
|
// it needs one, never that the route exists and is managed by a file whose
|
|
// path the envelope would otherwise disclose.
|
|
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
|
|
const response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 401), response.status);
|
|
try testing.expectEqualStrings("{\"error\":\"authentication required\"}", response.body);
|
|
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, managed_path));
|
|
}
|
|
|
|
test "W10 milestone 20: an unauthenticated configuration write is 401, never 403" {
|
|
if (!build_options.integration) return error.SkipZigTest;
|
|
|
|
const gpa = testing.allocator;
|
|
var hash_buf: [256]u8 = undefined;
|
|
const hash = try hashTestPassword(gpa, &hash_buf);
|
|
|
|
var env = try Env.create(gpa, .{
|
|
.password_hash = hash,
|
|
.authority = .{ .managed_file = managed_path },
|
|
});
|
|
defer env.destroy();
|
|
|
|
try bounded(env.io(), default_budget, fileModeUnauthenticated, .{ env.io(), env });
|
|
}
|
|
|
|
fn authorityEnvelope(io: std.Io, env: *Env) anyerror!void {
|
|
var body_buf: [16384]u8 = undefined;
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
try conn.request("GET", "/api/settings", null, null);
|
|
var response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
|
|
const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{});
|
|
defer parsed.deinit();
|
|
try testing.expectEqualStrings("managed_file", parsed.value.authority.mode);
|
|
try testing.expectEqualStrings(managed_path, parsed.value.authority.path.?);
|
|
try testing.expectEqual(@as(?i64, 1_700_000_042), parsed.value.authority.reconciled_at);
|
|
|
|
// The path is a filesystem path and must not reach the open routes.
|
|
for ([_][]const u8{ "/api/version", "/api/health" }) |target| {
|
|
try conn.request("GET", target, null, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, managed_path));
|
|
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "authority"));
|
|
}
|
|
}
|
|
|
|
test "W10 milestone 20: the settings envelope reports the authority and the open routes do not" {
|
|
if (!build_options.integration) return error.SkipZigTest;
|
|
|
|
const gpa = testing.allocator;
|
|
var env = try Env.create(gpa, .{
|
|
.authority = .{ .managed_file = managed_path },
|
|
.reconciled_at = 1_700_000_042,
|
|
});
|
|
defer env.destroy();
|
|
|
|
try bounded(env.io(), default_budget, authorityEnvelope, .{ env.io(), env });
|
|
}
|
|
|
|
fn databaseAuthorityEnvelope(io: std.Io, env: *Env) anyerror!void {
|
|
var body_buf: [16384]u8 = undefined;
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
try conn.request("GET", "/api/settings", null, null);
|
|
const response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
|
|
const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{});
|
|
defer parsed.deinit();
|
|
try testing.expectEqualStrings("database", parsed.value.authority.mode);
|
|
try testing.expectEqual(@as(?[]const u8, null), parsed.value.authority.path);
|
|
try testing.expectEqual(@as(?i64, null), parsed.value.authority.reconciled_at);
|
|
|
|
// And nothing is rejected.
|
|
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
|
|
const created = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 201), created.status);
|
|
}
|
|
|
|
test "W10 milestone 20: database authority reports null and writes normally" {
|
|
if (!build_options.integration) return error.SkipZigTest;
|
|
|
|
const gpa = testing.allocator;
|
|
var env = try Env.create(gpa, .{});
|
|
defer env.destroy();
|
|
|
|
try bounded(env.io(), default_budget, databaseAuthorityEnvelope, .{ env.io(), env });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// oversized cookie headers (ruling 7 of milestone 16)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Pads `list` past `http_util.max_cookie_len` with foreign cookies, the way a
|
|
/// reverse proxy on a shared domain does.
|
|
fn padCookies(gpa: Allocator, list: *std.ArrayList(u8)) !void {
|
|
var index: usize = 0;
|
|
while (list.items.len <= http_util.max_cookie_len * 2) : (index += 1) {
|
|
try list.print(gpa, "ad_id_{d}=0123456789abcdef; ", .{index});
|
|
}
|
|
}
|
|
|
|
fn oversizedCookie(io: std.Io, env: *Env) anyerror!void {
|
|
var body_buf: [4096]u8 = undefined;
|
|
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
try conn.request("POST", "/api/auth/login", null, "{\"password\":\"" ++ test_password ++ "\"}");
|
|
var response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
const set_cookie = response.header("set-cookie") orelse return error.TestNoCookie;
|
|
const pair_end = std.mem.findScalar(u8, set_cookie, ';') orelse set_cookie.len;
|
|
|
|
var session_pair: [256]u8 = undefined;
|
|
@memcpy(session_pair[0..pair_end], set_cookie[0..pair_end]);
|
|
|
|
// The session pair buried in the middle of an over-budget header. Before
|
|
// ruling 7 the whole header read as absent and this 401'd.
|
|
var with_session: std.ArrayList(u8) = .empty;
|
|
defer with_session.deinit(env.gpa);
|
|
try with_session.appendSlice(env.gpa, "cookie: ");
|
|
try padCookies(env.gpa, &with_session);
|
|
try with_session.appendSlice(env.gpa, session_pair[0..pair_end]);
|
|
try with_session.appendSlice(env.gpa, "; ");
|
|
try padCookies(env.gpa, &with_session);
|
|
try testing.expect(with_session.items.len > http_util.max_cookie_len);
|
|
|
|
try conn.request("GET", "/api/groups", with_session.items, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
|
|
// The same size of header carrying no session pair stays unauthenticated:
|
|
// the degradation keeps the session, it does not open the door.
|
|
var without_session: std.ArrayList(u8) = .empty;
|
|
defer without_session.deinit(env.gpa);
|
|
try without_session.appendSlice(env.gpa, "cookie: ");
|
|
try padCookies(env.gpa, &without_session);
|
|
try testing.expect(without_session.items.len > http_util.max_cookie_len);
|
|
|
|
try conn.request("GET", "/api/groups", without_session.items, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 401), response.status);
|
|
try testing.expectEqualStrings("{\"error\":\"authentication required\"}", response.body);
|
|
}
|
|
|
|
test "W10 a 2 KiB cookie header keeps the session and still refuses without one" {
|
|
if (!build_options.integration) return error.SkipZigTest;
|
|
|
|
const gpa = testing.allocator;
|
|
var hash_buf: [256]u8 = undefined;
|
|
const hash = try hashTestPassword(gpa, &hash_buf);
|
|
|
|
var env = try Env.create(gpa, .{ .password_hash = hash });
|
|
defer env.destroy();
|
|
|
|
try bounded(env.io(), default_budget, oversizedCookie, .{ env.io(), env });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// rate limiting (ruling 19)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn rateLimited(io: std.Io, env: *Env) anyerror!void {
|
|
// Large enough for the /metrics exposition at the end.
|
|
var body_buf: [64 * 1024]u8 = undefined;
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
// Capacity 1: the first counted request spends the only token.
|
|
try conn.request("GET", "/api/version", null, null);
|
|
var response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
|
|
try conn.request("GET", "/api/version", null, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 429), response.status);
|
|
try testing.expectEqualStrings("{\"error\":\"rate limited\"}", response.body);
|
|
const retry_after = response.header("retry-after") orelse return error.TestNoRetryAfter;
|
|
const seconds = try std.fmt.parseInt(u32, retry_after, 10);
|
|
try testing.expect(seconds >= 1);
|
|
|
|
// The monitoring endpoints never see 429 (ruling 19).
|
|
try conn.request("GET", "/api/health", null, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
try conn.request("GET", "/metrics", null, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
}
|
|
|
|
test "W10 a drained bucket answers 429 with Retry-After and spares monitoring" {
|
|
if (!build_options.integration) return error.SkipZigTest;
|
|
|
|
const gpa = testing.allocator;
|
|
var env = try Env.create(gpa, .{ .rate_per_min = 1, .localhost_exempt = false });
|
|
defer env.destroy();
|
|
|
|
try bounded(env.io(), default_budget, rateLimited, .{ env.io(), env });
|
|
}
|
|
|
|
fn proxiedRateLimit(io: std.Io, env: *Env) anyerror!void {
|
|
var body_buf: [4096]u8 = undefined;
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
// The socket peer is loopback and `api_localhost_exempt` is on, so without
|
|
// the forwarded-for header the bucket is never consulted: capacity is 1 and
|
|
// three requests in a row all pass.
|
|
for (0..3) |_| {
|
|
try conn.request("GET", "/api/version", null, null);
|
|
const exempt = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), exempt.status);
|
|
}
|
|
|
|
// The same connection, now carrying what the trusted proxy appends: the
|
|
// remote client is no longer loopback, so it spends its own token and the
|
|
// second request is refused.
|
|
try conn.request("GET", "/api/version", "x-forwarded-for: 203.0.113.9", null);
|
|
var response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
|
|
try conn.request("GET", "/api/version", "x-forwarded-for: 203.0.113.9", null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 429), response.status);
|
|
|
|
// A second remote client behind the same proxy has its own bucket.
|
|
try conn.request("GET", "/api/version", "x-forwarded-for: 198.51.100.4", null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
|
|
// A chain whose last entry the proxy did not write is a 400, never a
|
|
// silent fall back to the exempt loopback peer.
|
|
try conn.request("GET", "/api/version", "x-forwarded-for: 203.0.113.9, nonsense", null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 400), response.status);
|
|
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "x-forwarded-for"));
|
|
|
|
// The proxy itself is still exempt: its own unforwarded requests pass
|
|
// after every bucket above was drained.
|
|
try conn.request("GET", "/api/version", null, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
}
|
|
|
|
test "W10 milestone 17: a proxied client is rate limited while the proxy stays exempt" {
|
|
if (!build_options.integration) return error.SkipZigTest;
|
|
|
|
const gpa = testing.allocator;
|
|
var env = try Env.create(gpa, .{
|
|
.rate_per_min = 1,
|
|
.localhost_exempt = true,
|
|
.trusted_proxies = "127.0.0.1, ::1",
|
|
});
|
|
defer env.destroy();
|
|
|
|
try bounded(env.io(), default_budget, proxiedRateLimit, .{ env.io(), env });
|
|
}
|
|
|
|
fn spoofedForwardedFor(io: std.Io, env: *Env) anyerror!void {
|
|
var body_buf: [4096]u8 = undefined;
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
// No proxy is trusted, so the header is inert: the loopback peer keeps its
|
|
// exemption and no remote bucket is ever touched.
|
|
for (0..3) |_| {
|
|
try conn.request("GET", "/api/version", "x-forwarded-for: 203.0.113.9", null);
|
|
const response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
}
|
|
|
|
// Not even a chain nxdns would refuse from a trusted proxy.
|
|
try conn.request("GET", "/api/version", "x-forwarded-for: nonsense", null);
|
|
const ignored = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), ignored.status);
|
|
}
|
|
|
|
fn proxiedSseBudget(io: std.Io, env: *Env) anyerror!void {
|
|
var seen: std.ArrayList(u8) = .empty;
|
|
defer seen.deinit(env.gpa);
|
|
|
|
// One stream per address, and every connection arrives from loopback: keyed
|
|
// on the socket peer these two would be one client and the second would be
|
|
// refused.
|
|
var first: Conn = undefined;
|
|
try first.connect(io, env.addr);
|
|
defer first.close(io);
|
|
try first.request("GET", "/api/queries/live", "x-forwarded-for: 203.0.113.9", null);
|
|
try testing.expectEqual(@as(u16, 200), (try first.receiveHead()).status);
|
|
try first.readChunkedUntil(&seen, env.gpa, "retry: 3000");
|
|
|
|
var second: Conn = undefined;
|
|
try second.connect(io, env.addr);
|
|
defer second.close(io);
|
|
try second.request("GET", "/api/queries/live", "x-forwarded-for: 198.51.100.4", null);
|
|
try testing.expectEqual(@as(u16, 200), (try second.receiveHead()).status);
|
|
try second.readChunkedUntil(&seen, env.gpa, "retry: 3000");
|
|
|
|
// The first client's own budget is spent, though.
|
|
var again: Conn = undefined;
|
|
try again.connect(io, env.addr);
|
|
defer again.close(io);
|
|
var body_buf: [1024]u8 = undefined;
|
|
try again.request("GET", "/api/queries/live", "x-forwarded-for: 203.0.113.9", null);
|
|
const refused = try again.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 429), refused.status);
|
|
}
|
|
|
|
test "W10 milestone 17: each proxied client holds its own SSE budget" {
|
|
if (!build_options.integration) return error.SkipZigTest;
|
|
|
|
const gpa = testing.allocator;
|
|
var env = try Env.create(gpa, .{
|
|
.sse_max_per_ip = 1,
|
|
.trusted_proxies = "127.0.0.1, ::1",
|
|
});
|
|
defer env.destroy();
|
|
|
|
try bounded(env.io(), default_budget, proxiedSseBudget, .{ env.io(), env });
|
|
}
|
|
|
|
test "W10 milestone 17: a forwarded-for from an untrusted peer changes nothing" {
|
|
if (!build_options.integration) return error.SkipZigTest;
|
|
|
|
const gpa = testing.allocator;
|
|
var env = try Env.create(gpa, .{ .rate_per_min = 1, .localhost_exempt = true });
|
|
defer env.destroy();
|
|
|
|
try bounded(env.io(), default_budget, spoofedForwardedFor, .{ env.io(), env });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SSE (ruling 20): preamble, event frame, heartbeat, per-address cap
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn sseStream(io: std.Io, env: *Env) anyerror!void {
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
var seen: std.ArrayList(u8) = .empty;
|
|
defer seen.deinit(env.gpa);
|
|
errdefer std.debug.print("sse stream so far: {s}\n", .{seen.items});
|
|
|
|
try conn.request("GET", "/api/queries/live", null, null);
|
|
const head = try conn.receiveHead();
|
|
try testing.expectEqual(@as(u16, 200), head.status);
|
|
try testing.expectEqualStrings("text/event-stream", head.header("content-type").?);
|
|
try conn.readChunkedUntil(&seen, env.gpa, "retry: 3000");
|
|
|
|
// One query on the hot path reaches the open stream as one frame.
|
|
env.hub.publish(io, .init(.{
|
|
.timestamp = 1_700_000_000,
|
|
.domain = "live.example",
|
|
.client_ip = "192.0.2.99",
|
|
.qtype = 1,
|
|
.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 sink: query_sink.QuerySink = .init(&query_logger, env.hub);
|
|
|
|
var fake: FakeUpstream = .{ .identity = sweep_upstream_url };
|
|
var handler: dns_handler.Handler = .{
|
|
.upstream = fake.client(),
|
|
.blocking = .{ .mode = .zero, .ttl = 5 },
|
|
.forward_read_timeout = .{ .raw = .fromSeconds(2), .clock = .awake },
|
|
.manager = &env.mgr,
|
|
.pause = &env.pauser,
|
|
.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: dns_handler.Handler = .{
|
|
.upstream = fake.client(),
|
|
.blocking = .{ .mode = .zero, .ttl = 5 },
|
|
.forward_read_timeout = .{ .raw = .fromSeconds(2), .clock = .awake },
|
|
.manager = &env.mgr,
|
|
.pause = &env.pauser,
|
|
};
|
|
|
|
try bounded(io, default_budget, pauseAffectsHandler, .{ io, env, &h });
|
|
try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// settings PUT round trip (ruling 16)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn settingsRoundTrip(io: std.Io, env: *Env) anyerror!void {
|
|
var arena_state: std.heap.ArenaAllocator = .init(env.gpa);
|
|
defer arena_state.deinit();
|
|
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
var body_buf: [32 * 1024]u8 = undefined;
|
|
try conn.request("PUT", "/api/settings", null, "{\"dns\":{\"port\":5353},\"logging\":{\"level\":\"debug\"}}");
|
|
var response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
|
|
try conn.request("GET", "/api/settings", null, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
|
|
const view = try std.json.parseFromSliceLeaky(
|
|
SettingsView,
|
|
arena_state.allocator(),
|
|
response.body,
|
|
.{ .ignore_unknown_fields = false },
|
|
);
|
|
try testing.expectEqual(@as(u16, 5353), view.settings.dns.port);
|
|
try testing.expectEqualStrings("debug", view.settings.logging.level);
|
|
try testing.expect(!view.settings.web.auth_enabled);
|
|
|
|
// Every key is restart-required this milestone, the changed one included.
|
|
var found = false;
|
|
for (view.restart_required) |key| found = found or std.mem.eql(u8, key, "dns.port");
|
|
try testing.expect(found);
|
|
|
|
// A value the validator refuses changes nothing and answers 400.
|
|
try conn.request("PUT", "/api/settings", null, "{\"logging\":{\"level\":\"chatty\"}}");
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 400), response.status);
|
|
}
|
|
|
|
test "W10 settings written through the API read back changed" {
|
|
if (!build_options.integration) return error.SkipZigTest;
|
|
|
|
const gpa = testing.allocator;
|
|
var env = try Env.create(gpa, .{});
|
|
defer env.destroy();
|
|
|
|
try bounded(env.io(), default_budget, settingsRoundTrip, .{ env.io(), env });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// password hashing outside config_lock (ruling 18 of milestone 16)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn putNewPassword(io: std.Io, env: *Env) anyerror!void {
|
|
var body_buf: [16 * 1024]u8 = undefined;
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
try conn.request("PUT", "/api/settings", null, "{\"web\":{\"password\":\"" ++ test_password ++ "\"}}");
|
|
const response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
}
|
|
|
|
fn getWhileHashParked(io: std.Io, env: *Env) anyerror!void {
|
|
var put = try io.concurrent(putNewPassword, .{ io, env });
|
|
errdefer {
|
|
handlers_settings.hash_stall_control.release(io);
|
|
put.await(io) catch {};
|
|
}
|
|
|
|
handlers_settings.hash_stall_control.waitParked(io);
|
|
|
|
// The whole point of the ruling: this read completes while the hash is
|
|
// still held. Before the fix it blocked on `config_lock` until the hash
|
|
// finished, and the seam would deadlock the test rather than answer.
|
|
var body_buf: [16 * 1024]u8 = undefined;
|
|
var reader: Conn = undefined;
|
|
try reader.connect(io, env.addr);
|
|
defer reader.close(io);
|
|
|
|
try reader.request("GET", "/api/settings", null, null);
|
|
const response = try reader.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
|
|
handlers_settings.hash_stall_control.release(io);
|
|
try put.await(io);
|
|
}
|
|
|
|
test "W10 a settings read completes while a password hash is still running" {
|
|
if (!build_options.integration) return error.SkipZigTest;
|
|
|
|
const gpa = testing.allocator;
|
|
var env = try Env.create(gpa, .{});
|
|
defer env.destroy();
|
|
|
|
handlers_settings.hash_stall_control.arm();
|
|
try bounded(env.io(), default_budget, getWhileHashParked, .{ env.io(), env });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// static assets: /, SPA fallback, ETag → 304 (ruling 24)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn staticFlow(io: std.Io, env: *Env) anyerror!void {
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
var body_buf: [256 * 1024]u8 = undefined;
|
|
|
|
try conn.request("GET", "/", null, null);
|
|
var response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
const content_type = response.header("content-type") orelse return error.TestNoContentType;
|
|
try testing.expect(std.mem.startsWith(u8, content_type, "text/html"));
|
|
const etag = response.header("etag") orelse return error.TestNoEtag;
|
|
try testing.expect(std.mem.startsWith(u8, etag, "\""));
|
|
|
|
var etag_buf: [256]u8 = undefined;
|
|
const etag_line = try std.fmt.bufPrint(&etag_buf, "if-none-match: {s}", .{etag});
|
|
|
|
// The same asset behind its own validator is 304 with no body.
|
|
try conn.request("GET", "/", etag_line, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 304), response.status);
|
|
try testing.expectEqual(@as(usize, 0), response.body.len);
|
|
|
|
// An unknown non-/api path is the SPA's and serves index.html (200, not a
|
|
// redirect); an unknown /api path stays a JSON 404.
|
|
try conn.request("GET", "/some/client/route", null, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
|
|
try conn.request("GET", "/api/nope", null, null);
|
|
response = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 404), response.status);
|
|
try testing.expectEqualStrings("{\"error\":\"not found\"}", response.body);
|
|
}
|
|
|
|
test "W10 the embedded assets serve /, fall back for the SPA and honor ETag" {
|
|
if (!build_options.integration) return error.SkipZigTest;
|
|
|
|
const gpa = testing.allocator;
|
|
var env = try Env.create(gpa, .{ .fallback = static.fallback });
|
|
defer env.destroy();
|
|
|
|
try bounded(env.io(), default_budget, staticFlow, .{ env.io(), env });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// bodyless POST (W9's critical finding)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn bodylessPost(io: std.Io, env: *Env) anyerror!void {
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
// Exactly `curl -X POST`: no content-length, no transfer-encoding. RFC
|
|
// 9110 gives this request an empty body; before the W9 fix it tripped the
|
|
// `discardBody` assert in std (http/Server.zig:631) and took the whole
|
|
// process down, DNS included.
|
|
try conn.send("POST /api/auth/logout HTTP/1.1\r\nhost: t\r\n\r\n");
|
|
|
|
var body_buf: [1024]u8 = undefined;
|
|
const response = conn.receive(&body_buf) catch |err| {
|
|
std.debug.print(
|
|
"bodyless-POST regression: the server sent no well-formed response ({t}); " ++
|
|
"the discardBody fix in web/server.zig has not landed\n",
|
|
.{err},
|
|
);
|
|
return err;
|
|
};
|
|
try testing.expectEqual(@as(u16, 200), response.status);
|
|
|
|
// The connection survives and the next request is answered.
|
|
try conn.request("GET", "/api/version", null, null);
|
|
const second = try conn.receive(&body_buf);
|
|
try testing.expectEqual(@as(u16, 200), second.status);
|
|
}
|
|
|
|
test "W10 a POST with no body framing gets a response and keeps the connection" {
|
|
if (!build_options.integration) return error.SkipZigTest;
|
|
|
|
const gpa = testing.allocator;
|
|
var env = try Env.create(gpa, .{});
|
|
defer env.destroy();
|
|
|
|
try bounded(env.io(), default_budget, bodylessPost, .{ env.io(), env });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// drift guards (ruling 23 a and b)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// The route's path key must exist and its lowercase method key must sit
|
|
// inside that path's own block (from the key line to the next line at two
|
|
// spaces of indentation or less), so two documented paths cannot cover for
|
|
// each other's methods. Takes the document as a parameter so the negative
|
|
// test below can feed it a doctored copy.
|
|
fn yamlDocumentsRoute(yaml: []const u8, route: router.RouteInfo) error{ PathMissing, MethodMissing }!void {
|
|
var key_buf: [128]u8 = undefined;
|
|
const key = std.fmt.bufPrint(&key_buf, "\n {s}:\n", .{route.pattern}) catch
|
|
return error.PathMissing;
|
|
const key_at = std.mem.indexOf(u8, yaml, key) orelse return error.PathMissing;
|
|
|
|
var method_buf: [16]u8 = undefined;
|
|
const method = std.fmt.bufPrint(&method_buf, " {s}:", .{@tagName(route.method)}) catch
|
|
return error.MethodMissing;
|
|
const needle = std.ascii.lowerString(method_buf[0..method.len], method);
|
|
|
|
var lines = std.mem.splitScalar(u8, yaml[key_at + key.len ..], '\n');
|
|
while (lines.next()) |line| {
|
|
if (line.len > 0 and !std.mem.startsWith(u8, line, " ")) break;
|
|
if (std.mem.eql(u8, line, needle)) return;
|
|
}
|
|
return error.MethodMissing;
|
|
}
|
|
|
|
test "drift guard a: every served route appears under its own path in openapi.yaml" {
|
|
for (router.routes) |route| {
|
|
yamlDocumentsRoute(openapi.yaml, route) catch |err| {
|
|
std.debug.print(
|
|
"openapi.yaml drift for {s} {s}: {t}\n",
|
|
.{ @tagName(route.method), route.pattern, err },
|
|
);
|
|
return error.TestUnexpectedResult;
|
|
};
|
|
}
|
|
}
|
|
|
|
test "drift guard a bites: methods swapped between two documented paths fail the guard" {
|
|
const gpa = testing.allocator;
|
|
|
|
// Swap the only operation of /metrics (get) with the only operation of
|
|
// /api/auth/logout (post). Both methods still appear somewhere in the
|
|
// document and the operation count is unchanged, so a whole-document
|
|
// substring check and guard b both stay green on this copy.
|
|
const half = try std.mem.replaceOwned(
|
|
u8,
|
|
gpa,
|
|
openapi.yaml,
|
|
"\n /metrics:\n get:",
|
|
"\n /metrics:\n post:",
|
|
);
|
|
defer gpa.free(half);
|
|
const doctored = try std.mem.replaceOwned(
|
|
u8,
|
|
gpa,
|
|
half,
|
|
"\n /api/auth/logout:\n post:",
|
|
"\n /api/auth/logout:\n get:",
|
|
);
|
|
defer gpa.free(doctored);
|
|
try testing.expect(std.mem.containsAtLeast(u8, doctored, 1, "\n /metrics:\n post:"));
|
|
try testing.expect(std.mem.containsAtLeast(u8, doctored, 1, "\n /api/auth/logout:\n get:"));
|
|
|
|
var swapped_routes: usize = 0;
|
|
for (router.routes) |route| {
|
|
const swapped = (route.method == .GET and std.mem.eql(u8, route.pattern, "/metrics")) or
|
|
(route.method == .POST and std.mem.eql(u8, route.pattern, "/api/auth/logout"));
|
|
if (swapped) {
|
|
try testing.expectError(error.MethodMissing, yamlDocumentsRoute(doctored, route));
|
|
swapped_routes += 1;
|
|
} else {
|
|
try yamlDocumentsRoute(doctored, route);
|
|
}
|
|
}
|
|
try testing.expectEqual(@as(usize, 2), swapped_routes);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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");
|
|
try expectSchemaMatches(gpa, provenance_view.QueryDetail, "QueryDetail");
|
|
try expectSchemaMatches(gpa, provenance_view.Provenance, "Provenance");
|
|
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 {
|
|
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, "Provenance"));
|
|
|
|
// 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<T>` for this endpoint — derived from that file, never invented.
|
|
const ContractSample = struct {
|
|
name: []const u8,
|
|
ts_type: []const u8,
|
|
method: []const u8,
|
|
target: []const u8,
|
|
body: ?[]const u8 = null,
|
|
status: u16,
|
|
};
|
|
|
|
/// Execution order is table order, and it is load-bearing twice over: a list
|
|
/// route runs after the create that gave it a row (an empty array witnesses no
|
|
/// field at all), and `POST /api/blocklists/update` runs while the only source
|
|
/// row is disabled, so the pass syncs its status without fetching anything.
|
|
const contract_sample_walk = [_]ContractSample{
|
|
.{ .name = "get_health", .ts_type = "Health", .method = "GET", .target = "/api/health", .status = 200 },
|
|
.{ .name = "get_version", .ts_type = "Version", .method = "GET", .target = "/api/version", .status = 200 },
|
|
.{ .name = "login", .ts_type = "LoginResponse", .method = "POST", .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200 },
|
|
.{ .name = "logout", .ts_type = "LogoutResponse", .method = "POST", .target = "/api/auth/logout", .body = "{}", .status = 200 },
|
|
|
|
// 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 = "list_blocklists", .ts_type = "{ blocklists: Blocklist[] }", .method = "GET", .target = "/api/blocklists", .status = 200 },
|
|
.{ .name = "update_blocklist", .ts_type = "BlocklistEcho", .method = "PUT", .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200 },
|
|
.{ .name = "update_blocklists_now", .ts_type = "{ sources: SourceStatus[] }", .method = "POST", .target = "/api/blocklists/update", .body = "{}", .status = 202 },
|
|
|
|
// Groups. The migrated schema seeds `default` as id 1; the POST creates 2.
|
|
.{ .name = "list_groups", .ts_type = "{ groups: Group[] }", .method = "GET", .target = "/api/groups", .status = 200 },
|
|
.{ .name = "create_group", .ts_type = "Group", .method = "POST", .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201 },
|
|
.{ .name = "update_group", .ts_type = "Group", .method = "PUT", .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200 },
|
|
.{ .name = "put_group_sources", .ts_type = "{ source_ids: number[] }", .method = "PUT", .target = "/api/groups/1/sources", .body = "{\"source_ids\":[1]}", .status = 200 },
|
|
.{ .name = "get_group_sources", .ts_type = "{ source_ids: number[] }", .method = "GET", .target = "/api/groups/1/sources", .status = 200 },
|
|
|
|
// Rules, then the lookup that the rule makes answer `blocked`.
|
|
.{ .name = "create_rule", .ts_type = "RuleEcho", .method = "POST", .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201 },
|
|
.{ .name = "list_rules", .ts_type = "{ rules: Rule[] }", .method = "GET", .target = "/api/rules", .status = 200 },
|
|
.{ .name = "update_rule", .ts_type = "RuleEcho", .method = "PUT", .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"*.ads.example\",\"kind\":\"wildcard\",\"action\":\"block\"}", .status = 200 },
|
|
.{ .name = "get_lookup", .ts_type = "LookupResult", .method = "GET", .target = "/api/lookup?domain=sub.ads.example", .status = 200 },
|
|
|
|
// Local records.
|
|
.{ .name = "create_local_record", .ts_type = "LocalRecord", .method = "POST", .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201 },
|
|
.{ .name = "list_local_records", .ts_type = "{ local_records: LocalRecord[] }", .method = "GET", .target = "/api/local-records", .status = 200 },
|
|
.{ .name = "update_local_record", .ts_type = "LocalRecord", .method = "PUT", .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200 },
|
|
|
|
// Forward zones.
|
|
.{ .name = "create_forward_zone", .ts_type = "ForwardZone", .method = "POST", .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201 },
|
|
.{ .name = "list_forward_zones", .ts_type = "{ forward_zones: ForwardZone[] }", .method = "GET", .target = "/api/forward-zones", .status = 200 },
|
|
.{ .name = "update_forward_zone", .ts_type = "ForwardZone", .method = "PUT", .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200 },
|
|
|
|
// Clients (row id 1 is seeded — clients have no POST, ruling 9).
|
|
.{ .name = "list_clients", .ts_type = "{ clients: Client[] }", .method = "GET", .target = "/api/clients", .status = 200 },
|
|
.{ .name = "update_client", .ts_type = "Client", .method = "PUT", .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200 },
|
|
.{ .name = "put_client_prefixes", .ts_type = "{ client_prefixes: ClientPrefix[] }", .method = "PUT", .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200 },
|
|
.{ .name = "list_client_prefixes", .ts_type = "{ client_prefixes: ClientPrefix[] }", .method = "GET", .target = "/api/client-prefixes", .status = 200 },
|
|
|
|
// Upstreams. Row id 1 is seeded; the PUT leaves its url alone so the
|
|
// conflict sample below can collide with it.
|
|
.{ .name = "list_upstreams", .ts_type = "{ upstreams: Upstream[] }", .method = "GET", .target = "/api/upstreams", .status = 200 },
|
|
.{ .name = "create_upstream", .ts_type = "UpstreamEcho", .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201 },
|
|
.{ .name = "update_upstream", .ts_type = "UpstreamEcho", .method = "PUT", .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200 },
|
|
|
|
// 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 },
|
|
|
|
// 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("\"<build>\"");
|
|
} else {
|
|
try writeCanonical(arena, w, map.get(key).?, depth + 1);
|
|
}
|
|
try w.writeAll(",\n");
|
|
}
|
|
try writeTabs(w, depth);
|
|
try w.writeAll("}");
|
|
}
|
|
|
|
fn writeCanonicalArray(
|
|
arena: Allocator,
|
|
w: *std.Io.Writer,
|
|
items: []const std.json.Value,
|
|
depth: usize,
|
|
) anyerror!void {
|
|
if (items.len == 0) return w.writeAll("[]");
|
|
|
|
// Elements that canonicalize identically witness the same shape, so only
|
|
// the first of each is kept: a 60-bucket timeseries is 60 copies of one
|
|
// object and would bury everything else in the file.
|
|
var kept: std.ArrayList([]const u8) = .empty;
|
|
var all_primitive = true;
|
|
for (items) |item| {
|
|
switch (item) {
|
|
.array, .object => all_primitive = false,
|
|
else => {},
|
|
}
|
|
var one: std.Io.Writer.Allocating = .init(arena);
|
|
try writeCanonical(arena, &one.writer, item, depth + 1);
|
|
const text = one.written();
|
|
var seen = false;
|
|
for (kept.items) |prior| seen = seen or std.mem.eql(u8, prior, text);
|
|
if (!seen) try kept.append(arena, text);
|
|
}
|
|
|
|
if (all_primitive) {
|
|
var width = depth * ts_tab_width + 2;
|
|
for (kept.items, 0..) |text, index| width += text.len + @as(usize, if (index == 0) 0 else 2);
|
|
if (width <= ts_print_width) {
|
|
try w.writeAll("[");
|
|
for (kept.items, 0..) |text, index| {
|
|
if (index != 0) try w.writeAll(", ");
|
|
try w.writeAll(text);
|
|
}
|
|
return w.writeAll("]");
|
|
}
|
|
}
|
|
|
|
try w.writeAll("[\n");
|
|
for (kept.items) |text| {
|
|
try writeTabs(w, depth + 1);
|
|
try w.writeAll(text);
|
|
try w.writeAll(",\n");
|
|
}
|
|
try writeTabs(w, depth);
|
|
try w.writeAll("]");
|
|
}
|
|
|
|
/// The pinned regeneration command, quoted verbatim in the file header and in
|
|
/// the failure message.
|
|
const regen_command =
|
|
"zig build test -Dintegration -Dcontract-samples-out=\"$PWD/" ++ contract_samples.path ++ "\"";
|
|
|
|
/// Every capitalised identifier in the sample table's type expressions, sorted:
|
|
/// exactly the import list the generated file needs.
|
|
fn writeSampleImports(arena: Allocator, w: *std.Io.Writer) !void {
|
|
var names: std.ArrayList([]const u8) = .empty;
|
|
for (contract_sample_walk ++ 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<T>`, so `tsc`
|
|
\\// refuses a field the wire does not send, a wire field types.ts does not
|
|
\\// declare, and a string outside a literal union.
|
|
\\//
|
|
\\// Regenerate with:
|
|
\\//
|
|
);
|
|
try w.print(" {s}\n\n", .{regen_command});
|
|
try writeSampleImports(arena, w);
|
|
}
|
|
|
|
/// Sends one sample's request on `conn` and appends its canonical rendering.
|
|
fn captureSample(
|
|
gpa: Allocator,
|
|
conn: *Conn,
|
|
out: *std.Io.Writer,
|
|
sample: ContractSample,
|
|
body_buf: []u8,
|
|
) anyerror!void {
|
|
try conn.request(sample.method, sample.target, null, sample.body);
|
|
const response = try conn.receive(body_buf);
|
|
if (response.status != sample.status) {
|
|
std.debug.print(
|
|
"contract sample {s} ({s} {s}): expected {d}, got {d} body {s}\n",
|
|
.{ sample.name, sample.method, sample.target, sample.status, response.status, response.body },
|
|
);
|
|
return error.TestUnexpectedResult;
|
|
}
|
|
|
|
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
const value = std.json.parseFromSliceLeaky(std.json.Value, arena, response.body, .{}) catch |err| {
|
|
std.debug.print("contract sample {s}: body is not JSON ({t}): {s}\n", .{ sample.name, err, response.body });
|
|
return err;
|
|
};
|
|
|
|
try out.print("\nexport const sample_{s}: {s} = ", .{ sample.name, sample.ts_type });
|
|
try writeCanonical(arena, out, value, 0);
|
|
try out.writeAll(";\n");
|
|
}
|
|
|
|
fn sampleWalk(io: std.Io, env: *Env, out: *std.Io.Writer) anyerror!void {
|
|
var conn: Conn = undefined;
|
|
try conn.connect(io, env.addr);
|
|
defer conn.close(io);
|
|
|
|
var body_buf: [128 * 1024]u8 = undefined;
|
|
for (contract_sample_walk) |sample| try captureSample(env.gpa, &conn, out, sample, &body_buf);
|
|
}
|
|
|
|
fn 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);
|
|
}
|