Files
nxdns/src/web/server_integration_test.zig
T

662 lines
24 KiB
Zig

//! Loopback tests for `server.zig` and `router.zig`.
//!
//! This lives in its own file because it needs `@import("build_options")`,
//! which only exists when the compilation is driven by build.zig. The body is
//! compiled by every `zig build test` run, so it cannot rot, and skips at run
//! time unless `-Dintegration` is passed.
//!
//! Hermetic: one listener and one or two clients on 127.0.0.1, handlers that
//! touch nothing but the request. No stream read in 0.16.0 takes a timeout, so
//! the whole client side of each test runs as one task raced against a budget
//! and nothing can hang.
const std = @import("std");
const build_options = @import("build_options");
const net = std.Io.net;
const http_util = @import("http_util.zig");
const router = @import("router.zig");
const server = @import("server.zig");
const testing = std.testing;
const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(5), .clock = .awake };
/// Long enough that a loopback round trip cannot lose to scheduling, short
/// enough that the cancellation test stays quick.
const settle: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(200), .clock = .awake };
fn okHandler(
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
_ = state;
_ = io;
return http_util.respondBytes(request, .ok, "pong", http_util.content_type_text, &.{});
}
/// Echoes the body length back, so a test can prove the body arrived whole and
/// that the cap fires before a handler ever sees an oversize one.
fn echoLengthHandler(
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
_ = state;
_ = io;
const body = http_util.readBody(request) catch |err| switch (err) {
error.TooLarge => return http_util.respondError(request, .payload_too_large, "body too large"),
error.OutOfMemory => return error.OutOfMemory,
error.ReadFailed => return error.WriteFailed,
error.WriteFailed => return error.WriteFailed,
error.HttpExpectationFailed => return error.HttpExpectationFailed,
};
var buf: [32]u8 = undefined;
const text = std.fmt.bufPrint(&buf, "{d}", .{body.len}) catch unreachable;
return http_util.respondBytes(request, .ok, text, http_util.content_type_text, &.{});
}
/// Answers with the decoded query value, proving the router hands handlers a
/// target copy that survives the head being invalidated.
fn echoDomainHandler(
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
_ = state;
_ = io;
var buf: [http_util.max_query_value_len]u8 = undefined;
const value = http_util.queryValue(request.query, "domain", &buf) catch {
return http_util.respondError(request, .bad_request, "bad query");
} orelse "";
return http_util.respondBytes(request, .ok, value, http_util.content_type_text, &.{});
}
/// Reads the body first and only then looks at the path, which is exactly the
/// order that would break without the head copy (ruling 25).
fn bodyThenPathHandler(
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
_ = state;
_ = io;
_ = http_util.readBody(request) catch return error.WriteFailed;
var buf: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
writer.print("{s}|{?d}", .{ request.raw_path, request.id }) catch unreachable;
return http_util.respondBytes(request, .ok, writer.buffered(), http_util.content_type_text, &.{});
}
const test_routes = [_]router.RouteInfo{
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = okHandler, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = okHandler },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = echoLengthHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = bodyThenPathHandler },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .open, .policy = .read, .handler = echoDomainHandler },
};
fn denyAll(state: *server.WebState, io: std.Io, request: *const http_util.Request) bool {
_ = state;
_ = io;
_ = request;
return false;
}
fn alwaysLimited(state: *server.WebState, io: std.Io, request: *const http_util.Request) server.LimitVerdict {
_ = state;
_ = io;
_ = request;
return .{ .allowed = false, .retry_after_s = 42 };
}
fn testState(gpa: std.mem.Allocator) server.WebState {
return .{
.gpa = gpa,
.routes = &test_routes,
.check_auth = server.allowAll,
.check_limit = server.neverLimit,
};
}
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);
}
/// Runs the client side under a budget so a server that never answers fails the
/// test instead of hanging the run.
fn bounded(io: std.Io, 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;
},
}
}
/// One open connection with a reader and a writer, which is all these tests
/// need of an HTTP client.
const Conn = struct {
stream: net.Stream,
reader: net.Stream.Reader,
writer: net.Stream.Writer,
read_buf: [8192]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, address: net.IpAddress) !void {
self.stream = try address.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, request: []const u8) !void {
try self.writer.interface.writeAll(request);
try self.writer.interface.flush();
}
/// Reads one response: head to the blank line, then exactly
/// `content-length` bytes. Every response these tests provoke carries one.
fn receive(self: *Conn, out: []u8) !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);
const length = try contentLength(head);
if (length > out.len) return error.TestResponseTooLarge;
const body = out[0..length];
try self.reader.interface.readSliceAll(body);
return .{ .status = status, .head = head, .body = body };
}
};
const Response = struct {
status: u16,
/// Borrows the connection's read buffer; valid until the next receive.
head: []const u8,
body: []const u8,
fn header(self: Response, 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], " "), 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 error.TestBadResponse;
}
return error.TestNoContentLength;
}
fn get(path: []const u8, buf: []u8) []const u8 {
return std.fmt.bufPrint(buf, "GET {s} HTTP/1.1\r\nhost: t\r\n\r\n", .{path}) catch unreachable;
}
/// The listener's counters, read once after `f` finished and before the
/// listener is torn down. Plain values, because they are a report of a run that
/// is over: the shared core's counters and the web listener's own one land in
/// the same struct here.
const Counters = struct {
connections: u64,
rejected_at_capacity: u64,
rejected_at_shutdown: u64,
accept_errors: u64,
connection_errors: u64,
requests: u64,
};
/// Starts a listener on 127.0.0.1:0 with `state` and runs `f` against it under
/// the budget, then shuts the listener down through the drain path.
fn withServer(
gpa: std.mem.Allocator,
io: std.Io,
state: *server.WebState,
max_connections: u16,
comptime f: anytype,
extra: anytype,
) !Counters {
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var web = try server.Server.listen(gpa, io, listen_address, state, .{ .max_connections = max_connections });
const address = web.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, server.Server.serve, .{ &web, io });
const result = bounded(io, f, .{ io, address } ++ extra);
const stats: Counters = .{
.connections = web.core.stats.connections.load(.monotonic),
.rejected_at_capacity = web.core.stats.rejected_at_capacity.load(.monotonic),
.rejected_at_shutdown = web.core.stats.rejected_at_shutdown.load(.monotonic),
.accept_errors = web.core.stats.accept_errors.load(.monotonic),
.connection_errors = web.core.stats.connection_errors.load(.monotonic),
.requests = web.stats.requests.load(.monotonic),
};
web.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
try result;
return stats;
}
fn twoRequestsOnOneConnection(io: std.Io, address: net.IpAddress) anyerror!void {
var conn: Conn = undefined;
try conn.connect(io, address);
defer conn.close(io);
var body_buf: [256]u8 = undefined;
for (0..2) |_| {
var request_buf: [128]u8 = undefined;
try conn.send(get("/api/health", &request_buf));
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
try testing.expectEqualStrings("pong", response.body);
}
}
test "one connection carries two requests" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var state = testState(gpa);
const stats = try withServer(gpa, io, &state, 4, twoRequestsOnOneConnection, .{});
// One accept for two requests is the whole point of keep-alive.
try testing.expectEqual(@as(u64, 1), stats.connections);
try testing.expectEqual(@as(u64, 2), stats.requests);
try testing.expectEqual(@as(u64, 0), stats.connection_errors);
}
fn routingMatrix(io: std.Io, address: net.IpAddress) anyerror!void {
var conn: Conn = undefined;
try conn.connect(io, address);
defer conn.close(io);
var body_buf: [512]u8 = undefined;
var request_buf: [256]u8 = undefined;
try conn.send(get("/api/nope", &request_buf));
var response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 404), response.status);
try testing.expectEqualStrings("{\"error\":\"not found\"}", response.body);
try conn.send("DELETE /api/groups HTTP/1.1\r\nhost: t\r\n\r\n");
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 405), response.status);
try testing.expectEqualStrings("GET, POST", response.header("allow").?);
// '+' is a space, %2E is a literal dot: both survive the round trip.
try conn.send(get("/api/lookup?domain=a+b%2Ecom", &request_buf));
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
try testing.expectEqualStrings("a b.com", response.body);
// A truncated escape is a 400, not a value with a stray percent in it.
try conn.send(get("/api/lookup?domain=abc%2", &request_buf));
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 400), response.status);
// A path deeper than the segment budget is refused before matching.
try conn.send(get("/1/2/3/4/5/6/7/8/9", &request_buf));
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 400), response.status);
}
test "routing answers 404, 405 with allow, and rejects malformed targets" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var state = testState(gpa);
const stats = try withServer(gpa, io, &state, 4, routingMatrix, .{});
try testing.expectEqual(@as(u64, 5), stats.requests);
}
fn postBody(io: std.Io, address: net.IpAddress, length: usize, expected_status: u16) anyerror!void {
var conn: Conn = undefined;
try conn.connect(io, address);
defer conn.close(io);
var head_buf: [128]u8 = undefined;
const head = try std.fmt.bufPrint(
&head_buf,
"POST /api/groups HTTP/1.1\r\nhost: t\r\ncontent-length: {d}\r\n\r\n",
.{length},
);
try conn.writer.interface.writeAll(head);
const chunk = [_]u8{'x'} ** 4096;
var sent: usize = 0;
while (sent < length) {
const n = @min(chunk.len, length - sent);
// A refused body ends the connection, so the tail of a rejected write
// is expected to fail; the response is what the test reads.
conn.writer.interface.writeAll(chunk[0..n]) catch break;
sent += n;
}
conn.writer.interface.flush() catch {};
var body_buf: [256]u8 = undefined;
const response = try conn.receive(&body_buf);
try testing.expectEqual(expected_status, response.status);
}
test "a body inside the cap is delivered whole" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var state = testState(gpa);
_ = try withServer(gpa, io, &state, 4, postBody, .{ @as(usize, 64 * 1024), @as(u16, 200) });
}
test "a body over the cap is 413, not a buffered megabyte" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var state = testState(gpa);
_ = try withServer(
gpa,
io,
&state,
4,
postBody,
.{ http_util.max_body_bytes + 1, @as(u16, 413) },
);
}
fn postWithoutLength(io: std.Io, address: net.IpAddress) anyerror!void {
var conn: Conn = undefined;
try conn.connect(io, address);
defer conn.close(io);
var body_buf: [256]u8 = undefined;
try conn.send("POST /api/groups HTTP/1.1\r\nhost: t\r\n\r\n");
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
try testing.expectEqualStrings("0", response.body);
// A fresh connection proves the listener outlived the request; before the
// head normalization it died on http/Server.zig:631's assert.
var second: Conn = undefined;
try second.connect(io, address);
defer second.close(io);
var request_buf: [128]u8 = undefined;
try second.send(get("/api/health", &request_buf));
const again = try second.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), again.status);
}
test "a POST with no content-length and no transfer-encoding is an empty body, not a crash" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var state = testState(gpa);
const stats = try withServer(gpa, io, &state, 4, postWithoutLength, .{});
try testing.expectEqual(@as(u64, 0), stats.connection_errors);
}
fn bodyThenTarget(io: std.Io, address: net.IpAddress) anyerror!void {
var conn: Conn = undefined;
try conn.connect(io, address);
defer conn.close(io);
try conn.send("PUT /api/groups/17 HTTP/1.1\r\nhost: t\r\ncontent-length: 4\r\n\r\nabcd");
var body_buf: [128]u8 = undefined;
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
try testing.expectEqualStrings("/api/groups/17|17", response.body);
}
test "the target survives a body read" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var state = testState(gpa);
_ = try withServer(gpa, io, &state, 4, bodyThenTarget, .{});
}
fn refusedOverCapacity(io: std.Io, address: net.IpAddress) anyerror!void {
// Hold the only slot with an idle keep-alive connection, so the second
// client meets a full table rather than a race.
var held: Conn = undefined;
try held.connect(io, address);
defer held.close(io);
var request_buf: [128]u8 = undefined;
var body_buf: [256]u8 = undefined;
try held.send(get("/api/health", &request_buf));
const first = try held.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), first.status);
var overflow: Conn = undefined;
try overflow.connect(io, address);
defer overflow.close(io);
try overflow.send(get("/api/health", &request_buf));
const refused = try overflow.receive(&body_buf);
try testing.expectEqual(@as(u16, 503), refused.status);
try testing.expectEqualStrings("{\"error\":\"too many connections\"}", refused.body);
}
test "a connection over the cap is told 503, not silently dropped" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var state = testState(gpa);
const stats = try withServer(gpa, io, &state, 1, refusedOverCapacity, .{});
try testing.expectEqual(@as(u64, 1), stats.connections);
try testing.expectEqual(@as(u64, 1), stats.rejected_at_capacity);
}
fn deniedAndLimited(io: std.Io, address: net.IpAddress) anyerror!void {
var conn: Conn = undefined;
try conn.connect(io, address);
defer conn.close(io);
var request_buf: [128]u8 = undefined;
var body_buf: [256]u8 = undefined;
// The limiter runs before authentication, so a limited request is 429 even
// though the same request would also have failed the session check.
try conn.send(get("/api/groups", &request_buf));
var response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 429), response.status);
try testing.expectEqualStrings("42", response.header("retry-after").?);
// Ruling 19: the monitoring endpoints are exempt and answer normally.
try conn.send(get("/api/health", &request_buf));
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
}
test "the limiter and the session check are applied in that order" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var state = testState(gpa);
state.check_auth = denyAll;
state.check_limit = alwaysLimited;
_ = try withServer(gpa, io, &state, 4, deniedAndLimited, .{});
}
fn unauthenticated(io: std.Io, address: net.IpAddress) anyerror!void {
var conn: Conn = undefined;
try conn.connect(io, address);
defer conn.close(io);
var request_buf: [128]u8 = undefined;
var body_buf: [256]u8 = undefined;
try conn.send(get("/api/groups", &request_buf));
var response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 401), response.status);
// An open route stays reachable so the SPA shell can show a login form.
try conn.send(get("/api/health", &request_buf));
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
}
test "a session route without a session is 401 and an open route is not" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var state = testState(gpa);
state.check_auth = denyAll;
_ = try withServer(gpa, io, &state, 4, unauthenticated, .{});
}
/// Opens a connection, answers one request on it, and then leaves it idle and
/// open — the shape a browser tab holds, and the one that must not be able to
/// stall shutdown.
/// Returns plain `void`, not an error union: a group task must be coercible to
/// `Cancelable!void`, so the outcome travels in `failed` instead.
fn holdIdleConnection(io: std.Io, address: net.IpAddress, opened: *std.Io.Event, failed: *bool) void {
holdIdleConnectionInner(io, address, opened) catch {
failed.* = true;
opened.set(io);
};
}
fn holdIdleConnectionInner(io: std.Io, address: net.IpAddress, opened: *std.Io.Event) anyerror!void {
var conn: Conn = undefined;
try conn.connect(io, address);
defer conn.close(io);
var request_buf: [128]u8 = undefined;
var body_buf: [256]u8 = undefined;
try conn.send(get("/api/health", &request_buf));
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
opened.set(io);
// Nothing more is sent. The connection sits in `receiveHead`, which is
// where cancellation has to reach it.
settle.sleep(io) catch {};
}
test "cancellation returns promptly with an idle keep-alive connection open" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var state = testState(gpa);
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var web = try server.Server.listen(gpa, io, listen_address, &state, .{ .max_connections = 4 });
const address = web.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, server.Server.serve, .{ &web, io });
var opened: std.Io.Event = .unset;
var failed = false;
var client: std.Io.Group = .init;
try client.concurrent(io, holdIdleConnection, .{ io, address, &opened, &failed });
opened.wait(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
try testing.expect(!failed);
// The listener task is canceled with a client parked in `receiveHead`. If
// the cancel path awaited the connection group instead of canceling it,
// this would block until the client hung up, which the budget below would
// catch as a failure.
const start = std.Io.Clock.awake.now(io);
group.cancel(io);
const elapsed = start.durationTo(std.Io.Clock.awake.now(io));
client.cancel(io);
web.deinit(io);
try testing.expect(elapsed.toMilliseconds() < settle.raw.toMilliseconds());
}