milestone 17: real deadlines, validator holes, upstream editor, trusted proxies, contract samples, badvers
This commit is contained in:
@@ -15,7 +15,9 @@
|
||||
//!
|
||||
//! The route is rate-limit exempt (a long-lived stream must not drain its
|
||||
//! address's token bucket) but pays the per-address SSE connection cap, which
|
||||
//! binds loopback too: hub slots are a fixed resource.
|
||||
//! binds loopback too: hub slots are a fixed resource. The address it keys on is
|
||||
//! `client_addr`, so behind a trusted proxy each remote client holds its own
|
||||
//! budget rather than all of them sharing the proxy's.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
@@ -79,12 +81,15 @@ pub fn stream(
|
||||
const hub = state.hub orelse
|
||||
return http_util.respondError(request, .service_unavailable, "live stream unavailable");
|
||||
|
||||
const peer = address.NetAddress.fromIp(request.peer);
|
||||
// The effective client, not the socket peer: behind a trusted proxy every
|
||||
// stream would otherwise share one address's budget. Acquire and release
|
||||
// read the same value, so a release can never miss the slot it took.
|
||||
const client = address.NetAddress.fromIp(request.client_addr);
|
||||
if (state.limiter) |limiter| {
|
||||
if (!limiter.tryAcquireSse(io, std.Io.Clock.awake.now(io), peer))
|
||||
if (!limiter.tryAcquireSse(io, std.Io.Clock.awake.now(io), client))
|
||||
return http_util.respondError(request, .too_many_requests, "too many live streams from this address");
|
||||
}
|
||||
defer if (state.limiter) |limiter| limiter.releaseSse(io, peer);
|
||||
defer if (state.limiter) |limiter| limiter.releaseSse(io, client);
|
||||
|
||||
const id = hub.subscribe(io) orelse
|
||||
return http_util.respondError(request, .service_unavailable, "live stream is full");
|
||||
|
||||
@@ -213,6 +213,7 @@ const WebView = struct {
|
||||
api_rate_limit_per_min: u32,
|
||||
api_localhost_exempt: bool,
|
||||
sse_max_connections_per_ip: u16,
|
||||
trusted_proxies: []const u8,
|
||||
/// Derived, not stored: the hash itself is never serialized, and the UI
|
||||
/// still has to know whether a password is set.
|
||||
auth_enabled: bool,
|
||||
@@ -246,6 +247,7 @@ pub fn view(cfg: model.Config) View {
|
||||
.api_rate_limit_per_min = cfg.web.api_rate_limit_per_min,
|
||||
.api_localhost_exempt = cfg.web.api_localhost_exempt,
|
||||
.sse_max_connections_per_ip = cfg.web.sse_max_connections_per_ip,
|
||||
.trusted_proxies = cfg.web.trusted_proxies,
|
||||
.auth_enabled = auth.authEnabled(cfg.web),
|
||||
},
|
||||
.doh_server = cfg.doh_server,
|
||||
|
||||
@@ -88,7 +88,10 @@ fn testEntry(url: []const u8, enabled: bool) pool_mod.Entry {
|
||||
}
|
||||
|
||||
fn testPool(entries: []pool_mod.Entry) pool_mod.Pool {
|
||||
return .init(entries, .{}, .{ .raw = .fromMilliseconds(50), .clock = .awake }, 1);
|
||||
return .init(entries, .{}, .{
|
||||
.attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake },
|
||||
.total = .{ .raw = .fromMilliseconds(100), .clock = .awake },
|
||||
}, 1);
|
||||
}
|
||||
|
||||
test "every upstream is copied, counted and owned by the arena" {
|
||||
|
||||
@@ -38,6 +38,12 @@ pub const max_cookie_len: usize = 1024;
|
||||
/// reads. Both are short; an over-long one is treated as absent.
|
||||
pub const max_header_value_len: usize = 128;
|
||||
|
||||
/// How much of an `x-forwarded-for` header is kept. A trusted proxy appends its
|
||||
/// own entry last, so the entry that names the real client always sits in the
|
||||
/// final bytes; the copy therefore keeps the tail rather than the head (see
|
||||
/// `server.copyHeaderSuffix`). 256 bytes hold an IPv6 entry many times over.
|
||||
pub const max_xff_len: usize = 256;
|
||||
|
||||
/// A path deeper than this matches no route, so parsing can stop there.
|
||||
pub const max_path_segments: usize = 8;
|
||||
|
||||
@@ -248,6 +254,17 @@ pub const Request = struct {
|
||||
accept_encoding: []const u8,
|
||||
if_none_match: []const u8,
|
||||
peer: net.IpAddress,
|
||||
/// Who the request is from, as far as every policy decision is concerned:
|
||||
/// the socket peer, unless that peer is a configured trusted proxy and the
|
||||
/// request carried an `x-forwarded-for`, in which case it is that header's
|
||||
/// last entry. Computed once per request by the router, because the header
|
||||
/// it derives from dies on the first body read.
|
||||
///
|
||||
/// Only the address is meaningful — a forwarded entry carries no port, so
|
||||
/// the port reads 0 there. This file stays free of nxdns imports (the fuzz
|
||||
/// target builds it as a module root), so the address type is the std one;
|
||||
/// `address.NetAddress.fromIp` is what the policy sites key on.
|
||||
client_addr: net.IpAddress,
|
||||
/// Reset between requests on the same connection. Nothing allocated here
|
||||
/// survives the response.
|
||||
arena: Allocator,
|
||||
|
||||
@@ -730,6 +730,22 @@ test "every HELP line has a TYPE line and a sample, and every sample a name" {
|
||||
try testing.expectEqual(helps, samples);
|
||||
// `nxdns_up` plus every DNS counter: the families a bare state still has.
|
||||
try testing.expectEqual(1 + dns_stat_fields.len + 7, samples);
|
||||
// The reflective walk names the counters, so a renamed `Handler.Stats`
|
||||
// field silently renames a scraped series. Pin the ones an operator alerts
|
||||
// on by name.
|
||||
for ([_][]const u8{
|
||||
"nxdns_dns_queries_total",
|
||||
"nxdns_dns_formerr_total",
|
||||
"nxdns_dns_notimp_total",
|
||||
"nxdns_dns_badvers_total",
|
||||
"nxdns_dns_servfail_total",
|
||||
"nxdns_dns_refused_total",
|
||||
"nxdns_dns_blocked_total",
|
||||
}) |metric| {
|
||||
var line_buf: [128]u8 = undefined;
|
||||
const line = try std.fmt.bufPrint(&line_buf, "# TYPE {s} counter\n", .{metric});
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, line));
|
||||
}
|
||||
}
|
||||
|
||||
test "an unwired collaborator omits its family rather than reporting zeros" {
|
||||
|
||||
+11
-2
@@ -2092,8 +2092,9 @@ components:
|
||||
properties:
|
||||
upstream:
|
||||
type: object
|
||||
required: [read_timeout_ms, total_timeout_ms]
|
||||
required: [attempt_timeout_ms, read_timeout_ms, total_timeout_ms]
|
||||
properties:
|
||||
attempt_timeout_ms: { type: integer }
|
||||
read_timeout_ms: { type: integer }
|
||||
total_timeout_ms: { type: integer }
|
||||
dns:
|
||||
@@ -2121,7 +2122,7 @@ components:
|
||||
negative_ttl_max: { type: integer }
|
||||
web:
|
||||
type: object
|
||||
required: [enabled, bind, port, session_ttl_hours, api_rate_limit_per_min, api_localhost_exempt, sse_max_connections_per_ip, auth_enabled]
|
||||
required: [enabled, bind, port, session_ttl_hours, api_rate_limit_per_min, api_localhost_exempt, sse_max_connections_per_ip, trusted_proxies, auth_enabled]
|
||||
properties:
|
||||
enabled: { type: boolean }
|
||||
bind: { type: string }
|
||||
@@ -2130,6 +2131,12 @@ components:
|
||||
api_rate_limit_per_min: { type: integer }
|
||||
api_localhost_exempt: { type: boolean }
|
||||
sse_max_connections_per_ip: { type: integer }
|
||||
trusted_proxies:
|
||||
type: string
|
||||
description: |
|
||||
Comma-separated IP literals. A request from one of these peers
|
||||
is identified by the last entry of its X-Forwarded-For header.
|
||||
Empty trusts no proxy.
|
||||
auth_enabled:
|
||||
type: boolean
|
||||
description: Derived, read-only; true iff a password hash is stored.
|
||||
@@ -2208,6 +2215,7 @@ components:
|
||||
upstream:
|
||||
type: object
|
||||
properties:
|
||||
attempt_timeout_ms: { type: integer }
|
||||
read_timeout_ms: { type: integer }
|
||||
total_timeout_ms: { type: integer }
|
||||
dns:
|
||||
@@ -2243,6 +2251,7 @@ components:
|
||||
api_rate_limit_per_min: { type: integer }
|
||||
api_localhost_exempt: { type: boolean }
|
||||
sse_max_connections_per_ip: { type: integer }
|
||||
trusted_proxies: { type: string }
|
||||
doh_server:
|
||||
$ref: "#/components/schemas/TlsListenerPatch"
|
||||
dot_server:
|
||||
|
||||
+155
-2
@@ -61,7 +61,7 @@ const log = std.log.scoped(.web_server);
|
||||
const recv_buffer_len = 8 * 1024;
|
||||
const send_buffer_len = 4 * 1024;
|
||||
|
||||
/// Ruling 7. 64 slots at ~15.7 KiB each is ~1 MiB of fixed connection state.
|
||||
/// Ruling 7. 64 slots at ~16 KiB each is ~1 MiB of fixed connection state.
|
||||
pub const default_max_connections: u16 = 64;
|
||||
|
||||
/// How much per-request arena a connection keeps between requests. Enough that
|
||||
@@ -206,10 +206,13 @@ pub fn sessionAuth(state: *WebState, io: std.Io, request: *const http_util.Reque
|
||||
|
||||
/// Ruling 19. No limiter wired means no limit: the limiter is a defence the
|
||||
/// operator configures, and its absence must not refuse traffic.
|
||||
///
|
||||
/// Keyed on `client_addr`, not on the socket peer: behind a trusted proxy every
|
||||
/// peer is the proxy, and one bucket for every remote user is no limiter at all.
|
||||
pub fn bucketLimit(state: *WebState, io: std.Io, request: *const http_util.Request) LimitVerdict {
|
||||
const limiter = state.limiter orelse return .ok;
|
||||
const now = std.Io.Clock.awake.now(io);
|
||||
return limiter.check(io, now, address.NetAddress.fromIp(request.peer));
|
||||
return limiter.check(io, now, address.NetAddress.fromIp(request.client_addr));
|
||||
}
|
||||
|
||||
/// Seam double: refuses nothing. For tests and for a server with no admin
|
||||
@@ -281,6 +284,7 @@ pub const Server = struct {
|
||||
cookie_buf: [http_util.max_cookie_len]u8,
|
||||
accept_encoding_buf: [http_util.max_header_value_len]u8,
|
||||
if_none_match_buf: [http_util.max_header_value_len]u8,
|
||||
xff_buf: [http_util.max_xff_len]u8,
|
||||
/// Per-request working memory, reset between requests on the same
|
||||
/// connection so a keep-alive client cannot grow it without bound.
|
||||
arena: std.heap.ArenaAllocator,
|
||||
@@ -523,6 +527,19 @@ pub const Server = struct {
|
||||
const accept_encoding = copyHeader(request, "accept-encoding", &conn.accept_encoding_buf);
|
||||
const if_none_match = copyHeader(request, "if-none-match", &conn.if_none_match_buf);
|
||||
|
||||
const peer = address.NetAddress.fromIp(conn.peer);
|
||||
const forwarded_for = copyHeaderSuffix(request, "x-forwarded-for", &conn.xff_buf);
|
||||
const client_addr = switch (clientAddr(self.state.web.trusted_proxies, peer, forwarded_for)) {
|
||||
.addr => |addr| addr,
|
||||
.bad_forwarded_for => {
|
||||
var view = bareRequest(request, conn, arena);
|
||||
return http_util.respondError(&view, .bad_request, "malformed x-forwarded-for");
|
||||
},
|
||||
};
|
||||
// A forwarded entry has no port of its own; the peer keeps the one it
|
||||
// connected from.
|
||||
const client_ip = if (client_addr.eql(peer)) conn.peer else client_addr.toIp(0);
|
||||
|
||||
// Decoding is destructive, so it runs on a copy: W8's asset lookup needs
|
||||
// the raw path to match embedded file names byte for byte.
|
||||
const decodable = arena.dupe(u8, raw_path) catch return error.OutOfMemory;
|
||||
@@ -542,6 +559,7 @@ pub const Server = struct {
|
||||
.accept_encoding = accept_encoding,
|
||||
.if_none_match = if_none_match,
|
||||
.peer = conn.peer,
|
||||
.client_addr = client_ip,
|
||||
.arena = arena,
|
||||
};
|
||||
return router.dispatch(self.state, io, &view);
|
||||
@@ -560,6 +578,10 @@ pub const Server = struct {
|
||||
.accept_encoding = "",
|
||||
.if_none_match = "",
|
||||
.peer = conn.peer,
|
||||
// These responses are decided before the forwarded-for header is
|
||||
// read, or because reading it failed; the socket peer is all that
|
||||
// is known.
|
||||
.client_addr = conn.peer,
|
||||
.arena = arena,
|
||||
};
|
||||
}
|
||||
@@ -629,6 +651,68 @@ fn copyHeader(request: *http.Server.Request, name: []const u8, buf: []u8) []cons
|
||||
return buf[0..value.len];
|
||||
}
|
||||
|
||||
/// Copies the **last** `buf.len` bytes of one header value into `buf`. Null
|
||||
/// means the header is absent, which is a different answer from an empty value.
|
||||
///
|
||||
/// The tail is what matters for `x-forwarded-for`, and `copyHeader`'s
|
||||
/// empty-on-overflow rule would be a security hole here: an empty value reads as
|
||||
/// "no header", the effective client falls back to the socket peer, and behind
|
||||
/// the same-box proxy this feature exists for that peer is loopback — which
|
||||
/// `web.api_localhost_exempt` exempts from the API limiter by default. A client
|
||||
/// would regain the exemption by sending an oversized header. Keeping the tail
|
||||
/// closes that: the proxy appends its entry last, so the entry that names the
|
||||
/// real client is always in the final bytes.
|
||||
fn copyHeaderSuffix(request: *http.Server.Request, name: []const u8, buf: []u8) ?[]const u8 {
|
||||
const value = headerValue(request, name) orelse return null;
|
||||
const tail = if (value.len > buf.len) value[value.len - buf.len ..] else value;
|
||||
@memcpy(buf[0..tail.len], tail);
|
||||
return buf[0..tail.len];
|
||||
}
|
||||
|
||||
/// Who a request is from, or the one way deriving that can fail.
|
||||
const ClientAddr = union(enum) {
|
||||
addr: address.NetAddress,
|
||||
bad_forwarded_for,
|
||||
};
|
||||
|
||||
/// Milestone-17 ruling 4. The socket peer, unless it is a trusted proxy that
|
||||
/// forwarded the request, in which case the last entry of `x-forwarded-for` —
|
||||
/// the one that proxy appended, and the only entry in the chain a client cannot
|
||||
/// write.
|
||||
///
|
||||
/// A missing header falls back to the peer: a trusted proxy that adds no header
|
||||
/// is a proxy nobody asked to trust anything about, and the peer is still true.
|
||||
/// A header that is present but whose last entry is not an IP literal fails
|
||||
/// closed with a 400 instead. Only a misconfigured proxy can produce that — a
|
||||
/// client's spoofed entries can never terminate the chain — and falling back
|
||||
/// there would hand the request the loopback identity this ruling exists to
|
||||
/// take away.
|
||||
fn clientAddr(
|
||||
trusted_proxies: []const u8,
|
||||
peer: address.NetAddress,
|
||||
forwarded_for: ?[]const u8,
|
||||
) ClientAddr {
|
||||
if (!trustsPeer(trusted_proxies, peer)) return .{ .addr = peer };
|
||||
const chain = forwarded_for orelse return .{ .addr = peer };
|
||||
|
||||
const start = if (std.mem.lastIndexOfScalar(u8, chain, ',')) |comma| comma + 1 else 0;
|
||||
const last = std.mem.trim(u8, chain[start..], " \t");
|
||||
const addr = address.NetAddress.parse(last) catch return .bad_forwarded_for;
|
||||
return .{ .addr = addr };
|
||||
}
|
||||
|
||||
/// Whether `peer` is one of the configured trusted proxies. An element that is
|
||||
/// not an IP literal matches nothing; `validate.zig` is what tells the operator
|
||||
/// about it, and refusing to serve over a typo here would be a worse answer.
|
||||
fn trustsPeer(trusted_proxies: []const u8, peer: address.NetAddress) bool {
|
||||
var it = model.trustedProxies(trusted_proxies);
|
||||
while (it.next()) |element| {
|
||||
const trusted = address.NetAddress.parse(element) catch continue;
|
||||
if (trusted.eql(peer)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// The first value sent under `name`, borrowed from the request head.
|
||||
fn headerValue(request: *http.Server.Request, name: []const u8) ?[]const u8 {
|
||||
var it = request.iterateHeaders();
|
||||
@@ -825,6 +909,75 @@ fn testRequest() http_util.Request {
|
||||
.accept_encoding = "",
|
||||
.if_none_match = "",
|
||||
.peer = .{ .ip4 = .loopback(0) },
|
||||
.client_addr = .{ .ip4 = .loopback(0) },
|
||||
.arena = testing.allocator,
|
||||
};
|
||||
}
|
||||
|
||||
fn ip(text: []const u8) address.NetAddress {
|
||||
return address.NetAddress.parse(text) catch unreachable;
|
||||
}
|
||||
|
||||
test "with no trusted proxy configured the socket peer is the client" {
|
||||
const peer = ip("192.0.2.1");
|
||||
try testing.expect(clientAddr("", peer, "203.0.113.9").addr.eql(peer));
|
||||
}
|
||||
|
||||
test "a spoofed forwarded-for from an untrusted peer is ignored" {
|
||||
const peer = ip("192.0.2.1");
|
||||
try testing.expect(clientAddr("10.0.0.1", peer, "203.0.113.9").addr.eql(peer));
|
||||
}
|
||||
|
||||
test "a trusted peer is identified by the last forwarded-for entry" {
|
||||
const peer = ip("10.0.0.1");
|
||||
const derived = clientAddr("10.0.0.1, 10.0.0.2", peer, "203.0.113.9, 198.51.100.7");
|
||||
try testing.expect(derived.addr.eql(ip("198.51.100.7")));
|
||||
}
|
||||
|
||||
test "a trusted peer that forwards nothing stays itself" {
|
||||
const peer = ip("10.0.0.1");
|
||||
try testing.expect(clientAddr("10.0.0.1", peer, null).addr.eql(peer));
|
||||
}
|
||||
|
||||
test "an IPv6 proxy and an IPv6 forwarded entry work the same way" {
|
||||
const peer = ip("fd00::1");
|
||||
const derived = clientAddr("fd00::1", peer, "2001:db8::5, 2001:db8::7");
|
||||
try testing.expect(derived.addr.eql(ip("2001:db8::7")));
|
||||
}
|
||||
|
||||
test "an oversized forwarded-for keeps the entry the proxy appended" {
|
||||
var header: std.ArrayList(u8) = .empty;
|
||||
defer header.deinit(testing.allocator);
|
||||
while (header.items.len < 4096) try header.appendSlice(testing.allocator, "203.0.113.9, ");
|
||||
try header.appendSlice(testing.allocator, "198.51.100.7");
|
||||
|
||||
// What the connection would copy: the tail alone, the head thrown away.
|
||||
const tail = header.items[header.items.len - http_util.max_xff_len ..];
|
||||
const derived = clientAddr("10.0.0.1", ip("10.0.0.1"), tail);
|
||||
try testing.expect(derived.addr.eql(ip("198.51.100.7")));
|
||||
}
|
||||
|
||||
test "a trusted peer whose forwarded-for has no valid last entry is refused" {
|
||||
const peer = ip("10.0.0.1");
|
||||
try testing.expectEqual(
|
||||
ClientAddr.bad_forwarded_for,
|
||||
std.meta.activeTag(clientAddr("10.0.0.1", peer, "203.0.113.9, not-an-ip")),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
ClientAddr.bad_forwarded_for,
|
||||
std.meta.activeTag(clientAddr("10.0.0.1", peer, "")),
|
||||
);
|
||||
// An oversized header whose tail cuts the last entry in half is the same
|
||||
// failure, and must not degrade to the exemptible socket peer.
|
||||
try testing.expectEqual(
|
||||
ClientAddr.bad_forwarded_for,
|
||||
std.meta.activeTag(clientAddr("10.0.0.1", peer, "8.51.100.7000")),
|
||||
);
|
||||
}
|
||||
|
||||
test "a trusted-proxy element that is not an IP literal trusts nobody" {
|
||||
const peer = ip("10.0.0.1");
|
||||
try testing.expect(!trustsPeer("proxy.example", peer));
|
||||
try testing.expect(trustsPeer("proxy.example, 10.0.0.1", peer));
|
||||
try testing.expect(!trustsPeer("", peer));
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
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;
|
||||
@@ -261,6 +262,7 @@ const EnvOptions = struct {
|
||||
rate_per_min: u32 = 100_000,
|
||||
localhost_exempt: bool = true,
|
||||
sse_max_per_ip: u16 = 3,
|
||||
trusted_proxies: []const u8 = "",
|
||||
fallback: ?router.HandlerFn = null,
|
||||
};
|
||||
|
||||
@@ -356,7 +358,10 @@ const Env = struct {
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
}};
|
||||
self.pool = .init(&self.pool_entries, .{}, .{ .raw = .fromMilliseconds(50), .clock = .awake }, 1);
|
||||
self.pool = .init(&self.pool_entries, .{}, .{
|
||||
.attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake },
|
||||
.total = .{ .raw = .fromMilliseconds(100), .clock = .awake },
|
||||
}, 1);
|
||||
|
||||
self.state = .{
|
||||
.gpa = gpa,
|
||||
@@ -365,6 +370,7 @@ const Env = struct {
|
||||
.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,
|
||||
},
|
||||
.live_hash = .init(options.password_hash),
|
||||
.pause = &self.pauser,
|
||||
@@ -528,7 +534,7 @@ const TlsEndpointView = struct {
|
||||
/// response never carries `web.password` or `web.password_hash` (ruling 16).
|
||||
const SettingsView = struct {
|
||||
settings: struct {
|
||||
upstream: struct { read_timeout_ms: u32, total_timeout_ms: u32 },
|
||||
upstream: struct { attempt_timeout_ms: u32, read_timeout_ms: u32, total_timeout_ms: u32 },
|
||||
dns: struct {
|
||||
bind_ipv4: []const u8,
|
||||
bind_ipv6: []const u8,
|
||||
@@ -546,6 +552,7 @@ const SettingsView = struct {
|
||||
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,
|
||||
@@ -1040,6 +1047,139 @@ test "W10 a drained bucket answers 429 with Retry-After and spares monitoring" {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1748,6 +1888,421 @@ test "drift guard a bites: methods swapped between two documented paths fail the
|
||||
try testing.expectEqual(@as(usize, 2), swapped_routes);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// contract samples: the frontend's consumed shapes against real responses
|
||||
// (milestone-17 ruling 5)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The Zig side of the REST contract is already guarded: the table above parses
|
||||
// every live response strictly, and the openapi guards below cover the routes.
|
||||
// `web/src/lib/types.ts` was guarded by nothing — every frontend test stubs
|
||||
// fetch, and types.ts is narrower than the wire in places (literal unions like
|
||||
// `Health.status`), so re-parsing into Zig structs can never catch an
|
||||
// out-of-union string.
|
||||
//
|
||||
// This walk drives the real `Env` server through every `.json` route the
|
||||
// frontend reaches through an `api.ts` wrapper — the GETs and the JSON-returning
|
||||
// writes — and renders the canonicalized bodies into a committed TypeScript
|
||||
// file. TypeScript object literals get excess-property checking, so a server
|
||||
// field missing from types.ts, a types.ts field missing from the wire, and an
|
||||
// out-of-union literal all fail `npm run typecheck`.
|
||||
|
||||
/// One captured response. `ts_type` is the type argument `api.ts` hands to its
|
||||
/// own `request<T>` for this endpoint — derived from that file, never invented.
|
||||
const ContractSample = struct {
|
||||
name: []const u8,
|
||||
ts_type: []const u8,
|
||||
method: []const u8,
|
||||
target: []const u8,
|
||||
body: ?[]const u8 = null,
|
||||
status: u16,
|
||||
};
|
||||
|
||||
/// Execution order is table order, and it is load-bearing twice over: a list
|
||||
/// route runs after the create that gave it a row (an empty array witnesses no
|
||||
/// field at all), and `POST /api/blocklists/update` runs while the only source
|
||||
/// row is disabled, so the pass syncs its status without fetching anything.
|
||||
const contract_sample_walk = [_]ContractSample{
|
||||
.{ .name = "get_health", .ts_type = "Health", .method = "GET", .target = "/api/health", .status = 200 },
|
||||
.{ .name = "get_version", .ts_type = "Version", .method = "GET", .target = "/api/version", .status = 200 },
|
||||
.{ .name = "login", .ts_type = "LoginResponse", .method = "POST", .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200 },
|
||||
.{ .name = "logout", .ts_type = "LogoutResponse", .method = "POST", .target = "/api/auth/logout", .body = "{}", .status = 200 },
|
||||
|
||||
// Blocklists. The row is created disabled so the refresh below has a status
|
||||
// to report and still downloads nothing.
|
||||
.{ .name = "create_blocklist", .ts_type = "BlocklistEcho", .method = "POST", .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\",\"enabled\":false}", .status = 201 },
|
||||
.{ .name = "list_blocklists", .ts_type = "{ blocklists: Blocklist[] }", .method = "GET", .target = "/api/blocklists", .status = 200 },
|
||||
.{ .name = "update_blocklist", .ts_type = "BlocklistEcho", .method = "PUT", .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200 },
|
||||
.{ .name = "update_blocklists_now", .ts_type = "{ sources: SourceStatus[] }", .method = "POST", .target = "/api/blocklists/update", .body = "{}", .status = 202 },
|
||||
|
||||
// Groups. The migrated schema seeds `default` as id 1; the POST creates 2.
|
||||
.{ .name = "list_groups", .ts_type = "{ groups: Group[] }", .method = "GET", .target = "/api/groups", .status = 200 },
|
||||
.{ .name = "create_group", .ts_type = "Group", .method = "POST", .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201 },
|
||||
.{ .name = "update_group", .ts_type = "Group", .method = "PUT", .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200 },
|
||||
.{ .name = "put_group_sources", .ts_type = "{ source_ids: number[] }", .method = "PUT", .target = "/api/groups/1/sources", .body = "{\"source_ids\":[1]}", .status = 200 },
|
||||
.{ .name = "get_group_sources", .ts_type = "{ source_ids: number[] }", .method = "GET", .target = "/api/groups/1/sources", .status = 200 },
|
||||
|
||||
// Rules, then the lookup that the rule makes answer `blocked`.
|
||||
.{ .name = "create_rule", .ts_type = "RuleEcho", .method = "POST", .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201 },
|
||||
.{ .name = "list_rules", .ts_type = "{ rules: Rule[] }", .method = "GET", .target = "/api/rules", .status = 200 },
|
||||
.{ .name = "update_rule", .ts_type = "RuleEcho", .method = "PUT", .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"*.ads.example\",\"kind\":\"wildcard\",\"action\":\"block\"}", .status = 200 },
|
||||
.{ .name = "get_lookup", .ts_type = "LookupResult", .method = "GET", .target = "/api/lookup?domain=sub.ads.example", .status = 200 },
|
||||
|
||||
// Local records.
|
||||
.{ .name = "create_local_record", .ts_type = "LocalRecord", .method = "POST", .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201 },
|
||||
.{ .name = "list_local_records", .ts_type = "{ local_records: LocalRecord[] }", .method = "GET", .target = "/api/local-records", .status = 200 },
|
||||
.{ .name = "update_local_record", .ts_type = "LocalRecord", .method = "PUT", .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200 },
|
||||
|
||||
// Forward zones.
|
||||
.{ .name = "create_forward_zone", .ts_type = "ForwardZone", .method = "POST", .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201 },
|
||||
.{ .name = "list_forward_zones", .ts_type = "{ forward_zones: ForwardZone[] }", .method = "GET", .target = "/api/forward-zones", .status = 200 },
|
||||
.{ .name = "update_forward_zone", .ts_type = "ForwardZone", .method = "PUT", .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200 },
|
||||
|
||||
// Clients (row id 1 is seeded — clients have no POST, ruling 9).
|
||||
.{ .name = "list_clients", .ts_type = "{ clients: Client[] }", .method = "GET", .target = "/api/clients", .status = 200 },
|
||||
.{ .name = "update_client", .ts_type = "Client", .method = "PUT", .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200 },
|
||||
.{ .name = "put_client_prefixes", .ts_type = "{ client_prefixes: ClientPrefix[] }", .method = "PUT", .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200 },
|
||||
.{ .name = "list_client_prefixes", .ts_type = "{ client_prefixes: ClientPrefix[] }", .method = "GET", .target = "/api/client-prefixes", .status = 200 },
|
||||
|
||||
// Upstreams. Row id 1 is seeded; the PUT leaves its url alone so the
|
||||
// conflict sample below can collide with it.
|
||||
.{ .name = "list_upstreams", .ts_type = "{ upstreams: Upstream[] }", .method = "GET", .target = "/api/upstreams", .status = 200 },
|
||||
.{ .name = "create_upstream", .ts_type = "UpstreamEcho", .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201 },
|
||||
.{ .name = "update_upstream", .ts_type = "UpstreamEcho", .method = "PUT", .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200 },
|
||||
.{ .name = "get_upstream_health", .ts_type = "UpstreamHealth", .method = "GET", .target = "/api/upstream/health", .status = 200 },
|
||||
|
||||
// Query log and stats. `limit=5` reaches seeded row 21, the blocked one, so
|
||||
// the page carries both the null-bearing and the populated row shape.
|
||||
.{ .name = "get_queries", .ts_type = "QueriesPage", .method = "GET", .target = "/api/queries?limit=5", .status = 200 },
|
||||
.{ .name = "get_stats", .ts_type = "StatsTotals", .method = "GET", .target = "/api/stats?period=1h", .status = 200 },
|
||||
.{ .name = "get_stats_timeseries", .ts_type = "StatsTimeseries", .method = "GET", .target = "/api/stats/timeseries?period=1h", .status = 200 },
|
||||
|
||||
// Pause: the GET before the POST, so one sample carries `until: null` and
|
||||
// the other the deadline.
|
||||
.{ .name = "get_pause", .ts_type = "PauseState", .method = "GET", .target = "/api/pause", .status = 200 },
|
||||
.{ .name = "post_pause", .ts_type = "PauseState", .method = "POST", .target = "/api/pause", .body = "{\"paused\":true,\"duration_seconds\":600}", .status = 200 },
|
||||
|
||||
// Settings: the GET before any write, so `restart_required` is empty there
|
||||
// and populated in the PUT's echo.
|
||||
.{ .name = "get_settings", .ts_type = "SettingsEnvelope", .method = "GET", .target = "/api/settings", .status = 200 },
|
||||
.{ .name = "put_settings", .ts_type = "SettingsEnvelope", .method = "PUT", .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200 },
|
||||
|
||||
// One sample per shared error class this environment can produce. 401 and
|
||||
// 429 need their own environments and follow below.
|
||||
.{ .name = "error_bad_request", .ts_type = "ErrorEnvelope", .method = "PUT", .target = "/api/settings", .body = "{\"logging\":{\"level\":\"chatty\"}}", .status = 400 },
|
||||
.{ .name = "error_conflict", .ts_type = "ErrorEnvelope", .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"https://dns.example/dns-query\"}", .status = 409 },
|
||||
.{ .name = "error_not_found", .ts_type = "ErrorEnvelope", .method = "GET", .target = "/api/nope", .status = 404 },
|
||||
};
|
||||
|
||||
/// A session-authenticated environment answers this without a cookie.
|
||||
const unauthorized_sample: ContractSample = .{
|
||||
.name = "error_unauthorized",
|
||||
.ts_type = "ErrorEnvelope",
|
||||
.method = "GET",
|
||||
.target = "/api/groups",
|
||||
.status = 401,
|
||||
};
|
||||
|
||||
/// The second request on a one-token bucket.
|
||||
const rate_limited_sample: ContractSample = .{
|
||||
.name = "error_rate_limited",
|
||||
.ts_type = "ErrorEnvelope",
|
||||
.method = "GET",
|
||||
.target = "/api/version",
|
||||
.status = 429,
|
||||
};
|
||||
|
||||
/// `prettier` settings from web/package.json: tabs four columns wide, 120
|
||||
/// columns. The generated file has to be a fixpoint of the repo's formatter or
|
||||
/// CI's `npm run format:check` fails on it.
|
||||
const ts_print_width = 120;
|
||||
const ts_tab_width = 4;
|
||||
|
||||
/// Build identity, not contract data: `git_commit` comes from `-Dgit-commit`
|
||||
/// and `zig_version` from the compiler that built the test, so keeping either
|
||||
/// verbatim would pin the golden to one machine. Neither name occurs anywhere
|
||||
/// else in the contract.
|
||||
const volatile_string_keys = [_][]const u8{ "git_commit", "zig_version" };
|
||||
|
||||
fn writeTabs(w: *std.Io.Writer, depth: usize) !void {
|
||||
for (0..depth) |_| try w.writeByte('\t');
|
||||
}
|
||||
|
||||
/// True when `prettier` would print this object key without quotes.
|
||||
fn isTsIdentifier(text: []const u8) bool {
|
||||
if (text.len == 0) return false;
|
||||
if (!std.ascii.isAlphabetic(text[0]) and text[0] != '_' and text[0] != '$') return false;
|
||||
for (text[1..]) |byte| {
|
||||
if (!std.ascii.isAlphanumeric(byte) and byte != '_' and byte != '$') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
fn lessThanKey(_: void, a: []const u8, b: []const u8) bool {
|
||||
return std.mem.order(u8, a, b) == .lt;
|
||||
}
|
||||
|
||||
/// Object keys sorted, every number 0, strings and booleans verbatim. The
|
||||
/// canonical form is what makes the golden byte-stable across runs: the seed is
|
||||
/// fixed, so only the numbers (row ids, timestamps, uptimes) move.
|
||||
fn writeCanonical(arena: Allocator, w: *std.Io.Writer, value: std.json.Value, depth: usize) anyerror!void {
|
||||
switch (value) {
|
||||
.null => try w.writeAll("null"),
|
||||
.bool => |flag| try w.writeAll(if (flag) "true" else "false"),
|
||||
.integer, .float, .number_string => try w.writeAll("0"),
|
||||
.string => |text| try std.json.Stringify.value(text, .{}, w),
|
||||
.array => |list| try writeCanonicalArray(arena, w, list.items, depth),
|
||||
.object => |map| try writeCanonicalObject(arena, w, map, depth),
|
||||
}
|
||||
}
|
||||
|
||||
fn writeCanonicalObject(
|
||||
arena: Allocator,
|
||||
w: *std.Io.Writer,
|
||||
map: std.json.ObjectMap,
|
||||
depth: usize,
|
||||
) anyerror!void {
|
||||
if (map.count() == 0) return w.writeAll("{}");
|
||||
|
||||
const keys = try arena.dupe([]const u8, map.keys());
|
||||
std.mem.sort([]const u8, keys, {}, lessThanKey);
|
||||
|
||||
// An object that starts with a newline stays expanded under `prettier`, so
|
||||
// expanding every one of them is a fixpoint without measuring anything.
|
||||
try w.writeAll("{\n");
|
||||
for (keys) |key| {
|
||||
try writeTabs(w, depth + 1);
|
||||
if (isTsIdentifier(key)) try w.writeAll(key) else try std.json.Stringify.value(key, .{}, w);
|
||||
try w.writeAll(": ");
|
||||
var volatile_key = false;
|
||||
for (volatile_string_keys) |name_| volatile_key = volatile_key or std.mem.eql(u8, name_, key);
|
||||
if (volatile_key) {
|
||||
try w.writeAll("\"<build>\"");
|
||||
} else {
|
||||
try writeCanonical(arena, w, map.get(key).?, depth + 1);
|
||||
}
|
||||
try w.writeAll(",\n");
|
||||
}
|
||||
try writeTabs(w, depth);
|
||||
try w.writeAll("}");
|
||||
}
|
||||
|
||||
fn writeCanonicalArray(
|
||||
arena: Allocator,
|
||||
w: *std.Io.Writer,
|
||||
items: []const std.json.Value,
|
||||
depth: usize,
|
||||
) anyerror!void {
|
||||
if (items.len == 0) return w.writeAll("[]");
|
||||
|
||||
// Elements that canonicalize identically witness the same shape, so only
|
||||
// the first of each is kept: a 60-bucket timeseries is 60 copies of one
|
||||
// object and would bury everything else in the file.
|
||||
var kept: std.ArrayList([]const u8) = .empty;
|
||||
var all_primitive = true;
|
||||
for (items) |item| {
|
||||
switch (item) {
|
||||
.array, .object => all_primitive = false,
|
||||
else => {},
|
||||
}
|
||||
var one: std.Io.Writer.Allocating = .init(arena);
|
||||
try writeCanonical(arena, &one.writer, item, depth + 1);
|
||||
const text = one.written();
|
||||
var seen = false;
|
||||
for (kept.items) |prior| seen = seen or std.mem.eql(u8, prior, text);
|
||||
if (!seen) try kept.append(arena, text);
|
||||
}
|
||||
|
||||
if (all_primitive) {
|
||||
var width = depth * ts_tab_width + 2;
|
||||
for (kept.items, 0..) |text, index| width += text.len + @as(usize, if (index == 0) 0 else 2);
|
||||
if (width <= ts_print_width) {
|
||||
try w.writeAll("[");
|
||||
for (kept.items, 0..) |text, index| {
|
||||
if (index != 0) try w.writeAll(", ");
|
||||
try w.writeAll(text);
|
||||
}
|
||||
return w.writeAll("]");
|
||||
}
|
||||
}
|
||||
|
||||
try w.writeAll("[\n");
|
||||
for (kept.items) |text| {
|
||||
try writeTabs(w, depth + 1);
|
||||
try w.writeAll(text);
|
||||
try w.writeAll(",\n");
|
||||
}
|
||||
try writeTabs(w, depth);
|
||||
try w.writeAll("]");
|
||||
}
|
||||
|
||||
/// The pinned regeneration command, quoted verbatim in the file header and in
|
||||
/// the failure message.
|
||||
const regen_command =
|
||||
"zig build test -Dintegration -Dcontract-samples-out=\"$PWD/" ++ contract_samples.path ++ "\"";
|
||||
|
||||
/// Every capitalised identifier in the sample table's type expressions, sorted:
|
||||
/// exactly the import list the generated file needs.
|
||||
fn writeSampleImports(arena: Allocator, w: *std.Io.Writer) !void {
|
||||
var names: std.ArrayList([]const u8) = .empty;
|
||||
for (contract_sample_walk ++ [_]ContractSample{ unauthorized_sample, rate_limited_sample }) |sample| {
|
||||
var index: usize = 0;
|
||||
while (index < sample.ts_type.len) {
|
||||
if (!std.ascii.isUpper(sample.ts_type[index])) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
var end = index;
|
||||
while (end < sample.ts_type.len and std.ascii.isAlphanumeric(sample.ts_type[end])) end += 1;
|
||||
const word = sample.ts_type[index..end];
|
||||
var seen = false;
|
||||
for (names.items) |prior| seen = seen or std.mem.eql(u8, prior, word);
|
||||
if (!seen) try names.append(arena, word);
|
||||
index = end;
|
||||
}
|
||||
}
|
||||
std.mem.sort([]const u8, names.items, {}, lessThanKey);
|
||||
|
||||
try w.writeAll("import type {\n");
|
||||
for (names.items) |word| try w.print("\t{s},\n", .{word});
|
||||
try w.writeAll("} from \"@/lib/types\";\n");
|
||||
}
|
||||
|
||||
fn writeSampleHeader(arena: Allocator, w: *std.Io.Writer) !void {
|
||||
try w.writeAll(
|
||||
\\// Generated file — do not edit by hand.
|
||||
\\//
|
||||
\\// Every value below is a real response from the web server, captured by the
|
||||
\\// contract-sample test in src/web/web_integration_test.zig and canonicalized:
|
||||
\\// object keys sorted, every number 0, strings and booleans as the deterministic
|
||||
\\// seed produced them, repeated array elements collapsed to the first. The type
|
||||
\\// annotations are the ones api.ts hands to its own `request<T>`, so `tsc`
|
||||
\\// refuses a field the wire does not send, a wire field types.ts does not
|
||||
\\// declare, and a string outside a literal union.
|
||||
\\//
|
||||
\\// Regenerate with:
|
||||
\\//
|
||||
);
|
||||
try w.print(" {s}\n\n", .{regen_command});
|
||||
try writeSampleImports(arena, w);
|
||||
}
|
||||
|
||||
/// Sends one sample's request on `conn` and appends its canonical rendering.
|
||||
fn captureSample(
|
||||
gpa: Allocator,
|
||||
conn: *Conn,
|
||||
out: *std.Io.Writer,
|
||||
sample: ContractSample,
|
||||
body_buf: []u8,
|
||||
) anyerror!void {
|
||||
try conn.request(sample.method, sample.target, null, sample.body);
|
||||
const response = try conn.receive(body_buf);
|
||||
if (response.status != sample.status) {
|
||||
std.debug.print(
|
||||
"contract sample {s} ({s} {s}): expected {d}, got {d} body {s}\n",
|
||||
.{ sample.name, sample.method, sample.target, sample.status, response.status, response.body },
|
||||
);
|
||||
return error.TestUnexpectedResult;
|
||||
}
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
const value = std.json.parseFromSliceLeaky(std.json.Value, arena, response.body, .{}) catch |err| {
|
||||
std.debug.print("contract sample {s}: body is not JSON ({t}): {s}\n", .{ sample.name, err, response.body });
|
||||
return err;
|
||||
};
|
||||
|
||||
try out.print("\nexport const sample_{s}: {s} = ", .{ sample.name, sample.ts_type });
|
||||
try writeCanonical(arena, out, value, 0);
|
||||
try out.writeAll(";\n");
|
||||
}
|
||||
|
||||
fn sampleWalk(io: std.Io, env: *Env, out: *std.Io.Writer) anyerror!void {
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
var body_buf: [128 * 1024]u8 = undefined;
|
||||
for (contract_sample_walk) |sample| try captureSample(env.gpa, &conn, out, sample, &body_buf);
|
||||
}
|
||||
|
||||
fn sampleUnauthorized(io: std.Io, env: *Env, out: *std.Io.Writer) anyerror!void {
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
var body_buf: [4096]u8 = undefined;
|
||||
try captureSample(env.gpa, &conn, out, unauthorized_sample, &body_buf);
|
||||
}
|
||||
|
||||
fn sampleRateLimited(io: std.Io, env: *Env, out: *std.Io.Writer) anyerror!void {
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
var body_buf: [4096]u8 = undefined;
|
||||
// Capacity 1: the first counted request spends the only token.
|
||||
try conn.request("GET", "/api/version", null, null);
|
||||
const spent = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), spent.status);
|
||||
|
||||
try captureSample(env.gpa, &conn, out, rate_limited_sample, &body_buf);
|
||||
}
|
||||
|
||||
test "W10 milestone 17: the committed contract samples still describe live responses" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena_state.deinit();
|
||||
|
||||
var rendered: std.Io.Writer.Allocating = .init(gpa);
|
||||
defer rendered.deinit();
|
||||
try writeSampleHeader(arena_state.allocator(), &rendered.writer);
|
||||
|
||||
{
|
||||
var env = try Env.create(gpa, .{});
|
||||
defer env.destroy();
|
||||
try bounded(env.io(), default_budget, sampleWalk, .{ env.io(), env, &rendered.writer });
|
||||
}
|
||||
{
|
||||
var hash_buf: [256]u8 = undefined;
|
||||
const hash = try hashTestPassword(gpa, &hash_buf);
|
||||
var env = try Env.create(gpa, .{ .password_hash = hash });
|
||||
defer env.destroy();
|
||||
try bounded(env.io(), default_budget, sampleUnauthorized, .{ env.io(), env, &rendered.writer });
|
||||
}
|
||||
{
|
||||
var env = try Env.create(gpa, .{ .rate_per_min = 1, .localhost_exempt = false });
|
||||
defer env.destroy();
|
||||
try bounded(env.io(), default_budget, sampleRateLimited, .{ env.io(), env, &rendered.writer });
|
||||
}
|
||||
|
||||
if (build_options.contract_samples_out.len != 0) {
|
||||
var write_threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer write_threaded.deinit();
|
||||
try std.Io.Dir.cwd().writeFile(write_threaded.io(), .{
|
||||
.sub_path = build_options.contract_samples_out,
|
||||
.data = rendered.written(),
|
||||
});
|
||||
std.debug.print("wrote {s}\n", .{build_options.contract_samples_out});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!std.mem.eql(u8, contract_samples.bytes, rendered.written())) {
|
||||
std.debug.print(
|
||||
"{s} no longer matches the live responses.\n" ++
|
||||
"The server and the frontend's types.ts have drifted, or the seed changed.\n" ++
|
||||
"Regenerate, then read the diff and `npm run typecheck`:\n {s}\n",
|
||||
.{ contract_samples.path, regen_command },
|
||||
);
|
||||
return error.TestUnexpectedResult;
|
||||
}
|
||||
}
|
||||
|
||||
test "drift guard b: openapi.yaml documents exactly as many operations as the router serves" {
|
||||
const yaml = openapi.yaml;
|
||||
const paths_start = std.mem.indexOf(u8, yaml, "\npaths:\n") orelse return error.TestUnexpectedResult;
|
||||
|
||||
Reference in New Issue
Block a user