milestone 16: behavioral fixes for silent failures, locks, counters and the query log
This commit is contained in:
@@ -62,6 +62,7 @@ const handlers_live = @import("handlers/live.zig");
|
||||
const handlers_lookup = @import("handlers/lookup.zig");
|
||||
const handlers_pause = @import("handlers/pause.zig");
|
||||
const handlers_queries = @import("handlers/queries.zig");
|
||||
const handlers_settings = @import("handlers/settings.zig");
|
||||
const handlers_stats = @import("handlers/stats.zig");
|
||||
const handlers_upstream_health = @import("handlers/upstream_health.zig");
|
||||
const handlers_version = @import("handlers/version.zig");
|
||||
@@ -147,7 +148,9 @@ const Conn = struct {
|
||||
extra_header: ?[]const u8,
|
||||
body: ?[]const u8,
|
||||
) !void {
|
||||
var buf: [2048]u8 = undefined;
|
||||
// Room for an over-budget cookie header (ruling 7 of milestone 16) and
|
||||
// still under the server's 8 KiB maximum request head.
|
||||
var buf: [6144]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try w.print("{s} {s} HTTP/1.1\r\nhost: t\r\n", .{ method, target });
|
||||
if (extra_header) |line| try w.print("{s}\r\n", .{line});
|
||||
@@ -814,6 +817,19 @@ test "the certs reload payload with both endpoints disabled parses strictly" {
|
||||
|
||||
const test_password = "correct horse battery staple";
|
||||
|
||||
/// Hashes on an `Io` of its own, before the environment exists, so nothing
|
||||
/// mutates a `WebState` the server tasks are already reading.
|
||||
fn hashTestPassword(gpa: Allocator, buf: []u8) ![]const u8 {
|
||||
var hash_threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer hash_threaded.deinit();
|
||||
return std.crypto.pwhash.argon2.strHash(test_password, .{
|
||||
.allocator = gpa,
|
||||
.params = .owasp_2id,
|
||||
.mode = .argon2id,
|
||||
.encoding = .phc,
|
||||
}, buf, hash_threaded.io());
|
||||
}
|
||||
|
||||
fn authMatrix(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [4096]u8 = undefined;
|
||||
|
||||
@@ -871,21 +887,8 @@ test "W10 auth on: password-hashed environment enforces the session matrix" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
|
||||
// Hashed before the environment exists, so nothing mutates a `WebState`
|
||||
// the server tasks are reading.
|
||||
var hash_threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
var hash_buf: [256]u8 = undefined;
|
||||
const hash = std.crypto.pwhash.argon2.strHash(test_password, .{
|
||||
.allocator = gpa,
|
||||
.params = .owasp_2id,
|
||||
.mode = .argon2id,
|
||||
.encoding = .phc,
|
||||
}, &hash_buf, hash_threaded.io()) catch |err| {
|
||||
hash_threaded.deinit();
|
||||
return err;
|
||||
};
|
||||
hash_threaded.deinit();
|
||||
const hash = try hashTestPassword(gpa, &hash_buf);
|
||||
|
||||
var env = try Env.create(gpa, .{ .password_hash = hash });
|
||||
defer env.destroy();
|
||||
@@ -923,6 +926,77 @@ test "W10 auth off: an empty hash leaves every route open" {
|
||||
try bounded(env.io(), default_budget, authOff, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// oversized cookie headers (ruling 7 of milestone 16)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pads `list` past `http_util.max_cookie_len` with foreign cookies, the way a
|
||||
/// reverse proxy on a shared domain does.
|
||||
fn padCookies(gpa: Allocator, list: *std.ArrayList(u8)) !void {
|
||||
var index: usize = 0;
|
||||
while (list.items.len <= http_util.max_cookie_len * 2) : (index += 1) {
|
||||
try list.print(gpa, "ad_id_{d}=0123456789abcdef; ", .{index});
|
||||
}
|
||||
}
|
||||
|
||||
fn oversizedCookie(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);
|
||||
|
||||
try conn.request("POST", "/api/auth/login", null, "{\"password\":\"" ++ test_password ++ "\"}");
|
||||
var response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
const set_cookie = response.header("set-cookie") orelse return error.TestNoCookie;
|
||||
const pair_end = std.mem.findScalar(u8, set_cookie, ';') orelse set_cookie.len;
|
||||
|
||||
var session_pair: [256]u8 = undefined;
|
||||
@memcpy(session_pair[0..pair_end], set_cookie[0..pair_end]);
|
||||
|
||||
// The session pair buried in the middle of an over-budget header. Before
|
||||
// ruling 7 the whole header read as absent and this 401'd.
|
||||
var with_session: std.ArrayList(u8) = .empty;
|
||||
defer with_session.deinit(env.gpa);
|
||||
try with_session.appendSlice(env.gpa, "cookie: ");
|
||||
try padCookies(env.gpa, &with_session);
|
||||
try with_session.appendSlice(env.gpa, session_pair[0..pair_end]);
|
||||
try with_session.appendSlice(env.gpa, "; ");
|
||||
try padCookies(env.gpa, &with_session);
|
||||
try testing.expect(with_session.items.len > http_util.max_cookie_len);
|
||||
|
||||
try conn.request("GET", "/api/groups", with_session.items, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
// The same size of header carrying no session pair stays unauthenticated:
|
||||
// the degradation keeps the session, it does not open the door.
|
||||
var without_session: std.ArrayList(u8) = .empty;
|
||||
defer without_session.deinit(env.gpa);
|
||||
try without_session.appendSlice(env.gpa, "cookie: ");
|
||||
try padCookies(env.gpa, &without_session);
|
||||
try testing.expect(without_session.items.len > http_util.max_cookie_len);
|
||||
|
||||
try conn.request("GET", "/api/groups", without_session.items, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 401), response.status);
|
||||
try testing.expectEqualStrings("{\"error\":\"authentication required\"}", response.body);
|
||||
}
|
||||
|
||||
test "W10 a 2 KiB cookie header keeps the session and still refuses without one" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
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, oversizedCookie, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rate limiting (ruling 19)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1023,6 +1097,58 @@ test "W10 SSE: retry preamble, query frame, per-address cap and heartbeat" {
|
||||
try bounded(env.io(), sse_budget, sseStream, .{ env.io(), env });
|
||||
}
|
||||
|
||||
/// Opens a live stream and reads the preamble, so the subscriber task is in
|
||||
/// its wait loop by the time this returns.
|
||||
fn openLiveStream(io: std.Io, env: *Env, conn: *Conn, seen: *std.ArrayList(u8)) anyerror!void {
|
||||
try conn.connect(io, env.addr);
|
||||
// Stays open on success — the caller closes it. Only a failure here leaves
|
||||
// a socket for this to reclaim.
|
||||
errdefer conn.close(io);
|
||||
|
||||
try conn.request("GET", "/api/queries/live", null, null);
|
||||
const head = try conn.receiveHead();
|
||||
try testing.expectEqual(@as(u16, 200), head.status);
|
||||
try conn.readChunkedUntil(seen, env.gpa, "retry: 3000");
|
||||
}
|
||||
|
||||
test "W10 shutdown with a live stream open does not wait out a heartbeat" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
|
||||
// The teardown frees the environment's own `Io`, so the clock that times it
|
||||
// has to be somebody else's.
|
||||
var clock_threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer clock_threaded.deinit();
|
||||
const clock_io = clock_threaded.io();
|
||||
|
||||
var env = try Env.create(gpa, .{});
|
||||
|
||||
var conn: Conn = undefined;
|
||||
var seen: std.ArrayList(u8) = .empty;
|
||||
defer seen.deinit(gpa);
|
||||
|
||||
// Not a `defer`: the teardown is what this test measures, so it runs below
|
||||
// rather than after the assertion.
|
||||
bounded(env.io(), default_budget, openLiveStream, .{ env.io(), env, &conn, &seen }) catch |err| {
|
||||
env.destroy();
|
||||
return err;
|
||||
};
|
||||
|
||||
// Closed before the teardown because the teardown frees the `Io` this
|
||||
// socket belongs to. It does not weaken the test: the subscriber task is
|
||||
// parked on a hub event, not on this socket, so closing the client end
|
||||
// does not wake it — only `Hub.close` does (ruling 11 of milestone 16).
|
||||
conn.close(env.io());
|
||||
|
||||
const started = std.Io.Clock.awake.now(clock_io);
|
||||
env.destroy();
|
||||
const elapsed = started.durationTo(std.Io.Clock.awake.now(clock_io));
|
||||
|
||||
// Before ruling 11 the drain waited out the full 15 s heartbeat interval.
|
||||
try testing.expect(elapsed.toMilliseconds() < 5_000);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pagination walk (ruling 11)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1399,6 +1525,57 @@ test "W10 settings written through the API read back changed" {
|
||||
try bounded(env.io(), default_budget, settingsRoundTrip, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// password hashing outside config_lock (ruling 18 of milestone 16)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn putNewPassword(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [16 * 1024]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
try conn.request("PUT", "/api/settings", null, "{\"web\":{\"password\":\"" ++ test_password ++ "\"}}");
|
||||
const response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
}
|
||||
|
||||
fn getWhileHashParked(io: std.Io, env: *Env) anyerror!void {
|
||||
var put = try io.concurrent(putNewPassword, .{ io, env });
|
||||
errdefer {
|
||||
handlers_settings.hash_stall_control.release(io);
|
||||
put.await(io) catch {};
|
||||
}
|
||||
|
||||
handlers_settings.hash_stall_control.waitParked(io);
|
||||
|
||||
// The whole point of the ruling: this read completes while the hash is
|
||||
// still held. Before the fix it blocked on `config_lock` until the hash
|
||||
// finished, and the seam would deadlock the test rather than answer.
|
||||
var body_buf: [16 * 1024]u8 = undefined;
|
||||
var reader: Conn = undefined;
|
||||
try reader.connect(io, env.addr);
|
||||
defer reader.close(io);
|
||||
|
||||
try reader.request("GET", "/api/settings", null, null);
|
||||
const response = try reader.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
handlers_settings.hash_stall_control.release(io);
|
||||
try put.await(io);
|
||||
}
|
||||
|
||||
test "W10 a settings read completes while a password hash is still running" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{});
|
||||
defer env.destroy();
|
||||
|
||||
handlers_settings.hash_stall_control.arm();
|
||||
try bounded(env.io(), default_budget, getWhileHashParked, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// static assets: /, SPA fallback, ETag → 304 (ruling 24)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user