662 lines
24 KiB
Zig
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());
|
|
}
|