//! 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()); }