milestone 30: overview as a dashboard, explicit health contract, period aggregations
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
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
This commit is contained in:
@@ -32,6 +32,7 @@ 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");
|
||||
@@ -72,7 +73,6 @@ const handlers_pause = @import("handlers/pause.zig");
|
||||
const handlers_queries = @import("handlers/queries.zig");
|
||||
const handlers_settings = @import("handlers/settings.zig");
|
||||
const handlers_stats = @import("handlers/stats.zig");
|
||||
const handlers_upstream_health = @import("handlers/upstream_health.zig");
|
||||
const handlers_version = @import("handlers/version.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
@@ -282,6 +282,13 @@ const EnvOptions = struct {
|
||||
/// `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.
|
||||
@@ -334,13 +341,14 @@ const Env = struct {
|
||||
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);
|
||||
seedEvents(ioh, &self.events_store);
|
||||
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).
|
||||
@@ -381,7 +389,8 @@ const Env = struct {
|
||||
|
||||
self.pool_entries = .{.{
|
||||
.endpoint = transport.Endpoint.parse("https://dns.example/dns-query") catch unreachable,
|
||||
// Never exchanged with: the pool feeds /api/upstream/health only.
|
||||
// Never exchanged with: the pool feeds `/metrics` and the
|
||||
// `/api/health` upstream condition only.
|
||||
.client = .{ .ptr = undefined, .exchangeFn = undefined },
|
||||
.priority = 1,
|
||||
.enabled = true,
|
||||
@@ -588,6 +597,66 @@ fn seedQueryLog(database: *db.Db) !void {
|
||||
}});
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -595,12 +664,17 @@ 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) void {
|
||||
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");
|
||||
|
||||
store.report(io, seeded_now + 60, .upstream_history_write, "history", "history", .warning, "Busy");
|
||||
store.resolve(io, seeded_now + 120, .upstream_history_write, "history");
|
||||
// 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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -750,7 +824,9 @@ const contract = [_]Contract{
|
||||
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse },
|
||||
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) },
|
||||
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) },
|
||||
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .target = "/api/upstream/health", .status = 200, .check = jsonShape(handlers_upstream_health.Body) },
|
||||
.{ .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.
|
||||
@@ -1977,7 +2053,16 @@ fn detailUnavailable(io: std.Io, env: *Env) anyerror!void {
|
||||
defer conn.close(io);
|
||||
|
||||
var body_buf: [8 * 1024]u8 = undefined;
|
||||
for ([_][]const u8{ "/api/queries/1", "/api/queries?limit=1", "/api/stats", "/api/stats/timeseries" }) |target| {
|
||||
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);
|
||||
@@ -2064,6 +2149,260 @@ fn coverageWalk(io: std.Io, env: *Env) anyerror!void {
|
||||
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;
|
||||
|
||||
@@ -2876,12 +3215,33 @@ fn documentedType(comptime T: type) ?[]const u8 {
|
||||
.bool => "boolean",
|
||||
// A closed enum is a string on the wire, documented as its own schema.
|
||||
.@"enum" => null,
|
||||
.pointer => "string",
|
||||
// `[]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;
|
||||
}
|
||||
@@ -2933,6 +3293,30 @@ fn expectSchemaMatches(gpa: Allocator, comptime T: type, schema_name: []const u8
|
||||
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 });
|
||||
@@ -2981,6 +3365,25 @@ fn expectEnumMatches(gpa: Allocator, comptime T: type, schema_name: []const u8)
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -3134,7 +3537,6 @@ const contract_sample_walk = [_]ContractSample{
|
||||
.{ .name = "list_upstreams", .ts_type = "{ upstreams: Upstream[] }", .method = "GET", .target = "/api/upstreams", .status = 200 },
|
||||
.{ .name = "create_upstream", .ts_type = "UpstreamEcho", .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201 },
|
||||
.{ .name = "update_upstream", .ts_type = "UpstreamEcho", .method = "PUT", .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200 },
|
||||
.{ .name = "get_upstream_health", .ts_type = "UpstreamHealth", .method = "GET", .target = "/api/upstream/health", .status = 200 },
|
||||
|
||||
// Query log and stats. `limit=5` reaches seeded row 21, the blocked one, so
|
||||
// the page carries both the null-bearing and the populated row shape.
|
||||
@@ -3163,6 +3565,15 @@ const contract_sample_walk = [_]ContractSample{
|
||||
.{ .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",
|
||||
@@ -3314,7 +3725,9 @@ const regen_command =
|
||||
/// exactly the import list the generated file needs.
|
||||
fn writeSampleImports(arena: Allocator, w: *std.Io.Writer) !void {
|
||||
var names: std.ArrayList([]const u8) = .empty;
|
||||
for (contract_sample_walk ++ [_]ContractSample{ unauthorized_sample, rate_limited_sample }) |sample| {
|
||||
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])) {
|
||||
@@ -3397,6 +3810,15 @@ fn sampleWalk(io: std.Io, env: *Env, out: *std.Io.Writer) anyerror!void {
|
||||
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);
|
||||
@@ -3437,6 +3859,11 @@ test "W10 milestone 17: the committed contract samples still describe live respo
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user