milestone 6: dns cache, rate limiting, query logging, disk monitoring and retention
This commit is contained in:
@@ -0,0 +1,609 @@
|
||||
//! Milestone-6 integration tests (spec S8): the phase-6 components against real
|
||||
//! files, a real `querylog.db`, a real filesystem sample and a real log sink.
|
||||
//!
|
||||
//! 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 compiles
|
||||
//! on every `zig build test` run, so it cannot rot, and every case skips at run
|
||||
//! time unless `-Dintegration` is passed.
|
||||
//!
|
||||
//! Hermetic: every case works inside one `std.testing.tmpDir` and none of them
|
||||
//! opens a socket or resolves a name.
|
||||
//!
|
||||
//! Two mechanisms resolve the same paths here. `std.Io.Dir` calls go through the
|
||||
//! temporary directory handle, while SQLite and the log sink resolve their
|
||||
//! filenames through the process working directory. Every path handed to those
|
||||
//! two is therefore built from `Fixture.root`.
|
||||
|
||||
const std = @import("std");
|
||||
const build_options = @import("build_options");
|
||||
|
||||
const dns_cache = @import("../cache/dns_cache.zig");
|
||||
const address = @import("../platform/address.zig");
|
||||
const logging = @import("../platform/logging.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const rate_limiter = @import("../server/rate_limiter.zig");
|
||||
const db = @import("db.zig");
|
||||
const disk_monitor = @import("disk_monitor.zig");
|
||||
const logger = @import("logger.zig");
|
||||
const queries_repo = @import("repositories/queries_repo.zig");
|
||||
const querylog_schema = @import("querylog_schema.zig");
|
||||
const retention = @import("retention.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
/// `std.testing.tmpDir` creates its directory against `std.testing.io`, so every
|
||||
/// call into the code under test uses the same `Io` instance. That instance is
|
||||
/// an `Io.Threaded` (`lib/std/testing.zig:34`), which is what makes
|
||||
/// `io.concurrent` available to the writer-task cases.
|
||||
const io = testing.io;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Where `std.testing.tmpDir` puts its directories (`lib/std/testing.zig:634`).
|
||||
const tmp_prefix = ".zig-cache/tmp/";
|
||||
|
||||
const sub_path_len = @typeInfo(@FieldType(testing.TmpDir, "sub_path")).array.len;
|
||||
|
||||
const path_buf_len = 256;
|
||||
|
||||
const Fixture = struct {
|
||||
tmp: testing.TmpDir,
|
||||
root_buf: [tmp_prefix.len + sub_path_len]u8,
|
||||
|
||||
fn init() Fixture {
|
||||
var self: Fixture = .{
|
||||
.tmp = testing.tmpDir(.{ .iterate = true }),
|
||||
.root_buf = undefined,
|
||||
};
|
||||
@memcpy(self.root_buf[0..tmp_prefix.len], tmp_prefix);
|
||||
@memcpy(self.root_buf[tmp_prefix.len..], &self.tmp.sub_path);
|
||||
return self;
|
||||
}
|
||||
|
||||
fn deinit(self: *Fixture) void {
|
||||
self.tmp.cleanup();
|
||||
}
|
||||
|
||||
/// The temporary directory as a path relative to the process working
|
||||
/// directory.
|
||||
fn root(self: *const Fixture) []const u8 {
|
||||
return &self.root_buf;
|
||||
}
|
||||
|
||||
fn path(self: *const Fixture, buf: []u8, name: []const u8) ![]const u8 {
|
||||
return std.fmt.bufPrint(buf, "{s}/{s}", .{ self.root(), name });
|
||||
}
|
||||
|
||||
fn pathZ(self: *const Fixture, buf: []u8, name: []const u8) ![:0]const u8 {
|
||||
return std.fmt.bufPrintZ(buf, "{s}/{s}", .{ self.root(), name });
|
||||
}
|
||||
|
||||
fn rootZ(self: *const Fixture, buf: []u8) ![:0]const u8 {
|
||||
return std.fmt.bufPrintZ(buf, "{s}", .{self.root()});
|
||||
}
|
||||
|
||||
fn exists(self: *const Fixture, name: []const u8) !bool {
|
||||
self.tmp.dir.access(io, name, .{}) catch |e| switch (e) {
|
||||
error.FileNotFound => return false,
|
||||
else => |other| return other,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
fn sizeOf(self: *const Fixture, name: []const u8) !u64 {
|
||||
const stat = try self.tmp.dir.statFile(io, name, .{});
|
||||
return stat.size;
|
||||
}
|
||||
};
|
||||
|
||||
/// A fresh `querylog.db` inside the fixture, opened the way the daemon opens it.
|
||||
const QueryLog = struct {
|
||||
opened: querylog_schema.OpenResult,
|
||||
|
||||
fn create(f: *const Fixture) !QueryLog {
|
||||
var buf: [path_buf_len]u8 = undefined;
|
||||
const path = try f.pathZ(&buf, "querylog.db");
|
||||
return .{ .opened = try querylog_schema.open(io, std.Io.Dir.cwd(), path) };
|
||||
}
|
||||
|
||||
fn deinit(self: *QueryLog) void {
|
||||
self.opened.database.close();
|
||||
}
|
||||
|
||||
fn database(self: *QueryLog) *db.Db {
|
||||
return &self.opened.database;
|
||||
}
|
||||
};
|
||||
|
||||
fn entryAt(timestamp: i64, domain: []const u8) logger.Entry {
|
||||
return .init(.{
|
||||
.timestamp = timestamp,
|
||||
.domain = domain,
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 1,
|
||||
.blocked = false,
|
||||
.response_time_us = 1200,
|
||||
.cache_hit = false,
|
||||
.upstream = "9.9.9.9",
|
||||
});
|
||||
}
|
||||
|
||||
const poll_interval: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };
|
||||
|
||||
/// Waits for `counter` to reach `target`, up to `limit` polls of 5 ms. A
|
||||
/// deadline that passes is a failure rather than slowness: every case here is
|
||||
/// bounded well below the 2 s the spec allows.
|
||||
fn awaitCount(counter: *const std.atomic.Value(u64), target: u64, limit: usize) !void {
|
||||
var polls: usize = 0;
|
||||
while (counter.load(.monotonic) < target) : (polls += 1) {
|
||||
try testing.expect(polls < limit);
|
||||
try poll_interval.sleep(io);
|
||||
}
|
||||
}
|
||||
|
||||
fn writeRows(database: *db.Db, timestamps: []const i64, domain: []const u8) !void {
|
||||
var writer = try queries_repo.BatchWriter.init(database);
|
||||
defer writer.deinit();
|
||||
|
||||
var rows: [16]queries_repo.Row = undefined;
|
||||
for (timestamps, rows[0..timestamps.len]) |timestamp, *row| {
|
||||
row.* = .{
|
||||
.timestamp = timestamp,
|
||||
.domain = domain,
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 1,
|
||||
.blocked = false,
|
||||
.block_reason = null,
|
||||
.response_time_us = null,
|
||||
.cache_hit = null,
|
||||
.upstream = null,
|
||||
};
|
||||
}
|
||||
try writer.writeBatch(rows[0..timestamps.len]);
|
||||
}
|
||||
|
||||
/// The timestamps in the log, oldest first.
|
||||
fn readTimestamps(database: *db.Db, out: []i64) ![]i64 {
|
||||
var stmt = try database.prepare("SELECT timestamp FROM query_log ORDER BY timestamp");
|
||||
defer stmt.deinit();
|
||||
var n: usize = 0;
|
||||
while (try stmt.step()) : (n += 1) {
|
||||
if (n == out.len) return error.TestUnexpectedResult;
|
||||
out[n] = stmt.columnInt(0);
|
||||
}
|
||||
return out[0..n];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// case 1-6: the query logger, retention and the disk gate on a real database
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "S8 case 1: the logger writes a real querylog.db end to end" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var f: Fixture = .init();
|
||||
defer f.deinit();
|
||||
|
||||
var log_db = try QueryLog.create(&f);
|
||||
defer log_db.deinit();
|
||||
try testing.expectEqual(querylog_schema.RecreateReason.missing, log_db.opened.recreated.?);
|
||||
|
||||
var queue_buf: [512]logger.Entry = undefined;
|
||||
var query_log: logger.Logger = .init(.{}, &queue_buf);
|
||||
|
||||
var future = try io.concurrent(logger.Logger.runWriter, .{
|
||||
&query_log,
|
||||
io,
|
||||
log_db.database(),
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
|
||||
var name_buf: [32]u8 = undefined;
|
||||
for (0..250) |i| {
|
||||
const domain = try std.fmt.bufPrint(&name_buf, "d{d}.example", .{i % 10});
|
||||
query_log.log(io, entryAt(@intCast(i), domain));
|
||||
}
|
||||
query_log.shutdown(io);
|
||||
try future.await(io);
|
||||
|
||||
try testing.expectEqual(@as(u64, 0), query_log.queries_dropped.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 250), query_log.rows_written.load(.monotonic));
|
||||
try testing.expectEqual(@as(i64, 250), try queries_repo.countRows(log_db.database()));
|
||||
try testing.expectEqual(@as(i64, 10), try queries_repo.countDomains(log_db.database()));
|
||||
try testing.expectEqual(
|
||||
@as(i64, querylog_schema.fingerprint),
|
||||
try log_db.database().queryInt("PRAGMA user_version"),
|
||||
);
|
||||
}
|
||||
|
||||
test "S8 case 2: a single entry reaches the file once the flush interval passes" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var f: Fixture = .init();
|
||||
defer f.deinit();
|
||||
|
||||
var log_db = try QueryLog.create(&f);
|
||||
defer log_db.deinit();
|
||||
|
||||
var queue_buf: [8]logger.Entry = undefined;
|
||||
var query_log: logger.Logger = .init(.{}, &queue_buf);
|
||||
|
||||
var future = try io.concurrent(logger.Logger.runWriter, .{
|
||||
&query_log,
|
||||
io,
|
||||
log_db.database(),
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
|
||||
query_log.log(io, entryAt(1, "only.example"));
|
||||
|
||||
// Ten flush intervals of headroom: a row that has not landed by then is a
|
||||
// failure of the interval race, not a slow machine.
|
||||
const limit = 10 * logger.flush_interval_ms / 5;
|
||||
try awaitCount(&query_log.rows_written, 1, limit);
|
||||
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(log_db.database()));
|
||||
|
||||
query_log.shutdown(io);
|
||||
try future.await(io);
|
||||
}
|
||||
|
||||
test "S8 case 3: a full queue drops the oldest entries and the newest survive" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var f: Fixture = .init();
|
||||
defer f.deinit();
|
||||
|
||||
var log_db = try QueryLog.create(&f);
|
||||
defer log_db.deinit();
|
||||
|
||||
var root_buf: [path_buf_len]u8 = undefined;
|
||||
var monitor: disk_monitor.Monitor = .init(
|
||||
.{},
|
||||
f.tmp.dir,
|
||||
try f.rootZ(&root_buf),
|
||||
null,
|
||||
);
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
|
||||
|
||||
var queue_buf: [8]logger.Entry = undefined;
|
||||
var query_log: logger.Logger = .init(.{}, &queue_buf);
|
||||
|
||||
// The whole burst is enqueued before the writer starts. A writer already
|
||||
// draining the queue would take entries out of it mid-burst and make the
|
||||
// number of drops depend on the scheduler.
|
||||
for (0..20) |i| query_log.log(io, entryAt(@intCast(i), "burst.example"));
|
||||
try testing.expectEqual(@as(u64, 12), query_log.queries_dropped.load(.monotonic));
|
||||
|
||||
var future = try io.concurrent(logger.Logger.runWriter, .{
|
||||
&query_log,
|
||||
io,
|
||||
log_db.database(),
|
||||
@as(?*disk_monitor.Monitor, &monitor),
|
||||
});
|
||||
|
||||
try awaitCount(&query_log.batches_gated, 1, 200);
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(log_db.database()));
|
||||
|
||||
// Un-gate before the shutdown: a writer held by the disk gate holds its
|
||||
// batch, and `shutdown` alone would never release it.
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
|
||||
try awaitCount(&query_log.rows_written, 8, 300);
|
||||
query_log.shutdown(io);
|
||||
try future.await(io);
|
||||
|
||||
var stamps: [16]i64 = undefined;
|
||||
const kept = try readTimestamps(log_db.database(), &stamps);
|
||||
try testing.expectEqual(@as(usize, 8), kept.len);
|
||||
for (kept, 0..) |stamp, i| {
|
||||
try testing.expectEqual(@as(i64, @intCast(i + 12)), stamp);
|
||||
}
|
||||
}
|
||||
|
||||
test "S8 case 4: the privacy transforms reach the stored rows" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var f: Fixture = .init();
|
||||
defer f.deinit();
|
||||
|
||||
var log_db = try QueryLog.create(&f);
|
||||
defer log_db.deinit();
|
||||
|
||||
var queue_buf: [64]logger.Entry = undefined;
|
||||
var query_log: logger.Logger = .init(
|
||||
.{ .hide_domains = true, .hide_client_ips = true },
|
||||
&queue_buf,
|
||||
);
|
||||
|
||||
var future = try io.concurrent(logger.Logger.runWriter, .{
|
||||
&query_log,
|
||||
io,
|
||||
log_db.database(),
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
|
||||
var name_buf: [32]u8 = undefined;
|
||||
for (0..20) |i| {
|
||||
const domain = try std.fmt.bufPrint(&name_buf, "private{d}.example", .{i});
|
||||
query_log.log(io, entryAt(@intCast(i), domain));
|
||||
}
|
||||
query_log.shutdown(io);
|
||||
try future.await(io);
|
||||
|
||||
try testing.expectEqual(@as(i64, 20), try queries_repo.countRows(log_db.database()));
|
||||
// Every name collapsed onto the marker, so the dimension table holds one row.
|
||||
try testing.expectEqual(@as(i64, 1), try queries_repo.countDomains(log_db.database()));
|
||||
try testing.expectEqual(
|
||||
@as(i64, 20),
|
||||
try log_db.database().queryInt(
|
||||
\\SELECT count(*) FROM query_log q JOIN domains d ON d.id = q.domain_id
|
||||
\\ WHERE d.domain = 'hidden' AND q.client_ip = 'hidden'
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test "S8 case 5: a retention pass prunes the old rows and truncates the write-ahead log" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var f: Fixture = .init();
|
||||
defer f.deinit();
|
||||
|
||||
var log_db = try QueryLog.create(&f);
|
||||
defer log_db.deinit();
|
||||
|
||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||
const day = 86_400;
|
||||
try writeRows(log_db.database(), &.{ now - 40 * day, now - 31 * day }, "old.example");
|
||||
try writeRows(log_db.database(), &.{ now - 3 * day, now - 60 }, "fresh.example");
|
||||
try testing.expectEqual(@as(i64, 4), try queries_repo.countRows(log_db.database()));
|
||||
try testing.expect(try f.sizeOf("querylog.db-wal") > 0);
|
||||
|
||||
var pass: retention.Retention = .init(.{ .retention_days = 30 });
|
||||
pass.runOnce(io, log_db.database());
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), pass.stats.passes);
|
||||
try testing.expectEqual(@as(u64, 2), pass.stats.rows_pruned);
|
||||
try testing.expectEqual(@as(u64, 1), pass.stats.checkpoints);
|
||||
try testing.expectEqual(@as(u64, 0), pass.stats.vacuums);
|
||||
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(log_db.database()));
|
||||
// Both names stay: the dimension table is not collected.
|
||||
try testing.expectEqual(@as(i64, 2), try queries_repo.countDomains(log_db.database()));
|
||||
|
||||
if (try f.exists("querylog.db-wal")) {
|
||||
try testing.expectEqual(@as(u64, 0), try f.sizeOf("querylog.db-wal"));
|
||||
}
|
||||
}
|
||||
|
||||
test "S8 case 6: a critical disk gates the flushes and recovery releases them" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var f: Fixture = .init();
|
||||
defer f.deinit();
|
||||
|
||||
var log_db = try QueryLog.create(&f);
|
||||
defer log_db.deinit();
|
||||
|
||||
var root_buf: [path_buf_len]u8 = undefined;
|
||||
const data_path = try f.rootZ(&root_buf);
|
||||
|
||||
// No filesystem holds this much free space, so the sample classifies
|
||||
// critical against the real `statvfs` reading rather than a stub.
|
||||
const unreachable_mb = std.math.maxInt(u32);
|
||||
var monitor: disk_monitor.Monitor = .init(
|
||||
.{ .min_free_mb = unreachable_mb, .warn_free_mb = unreachable_mb },
|
||||
f.tmp.dir,
|
||||
data_path,
|
||||
null,
|
||||
);
|
||||
monitor.sample(io);
|
||||
try testing.expectEqual(disk_monitor.State.critical, monitor.state());
|
||||
try testing.expect(!monitor.writesAllowed());
|
||||
try testing.expect(monitor.gauges().free_bytes > 0);
|
||||
try testing.expect(monitor.gauges().db_bytes > 0);
|
||||
|
||||
var queue_buf: [64]logger.Entry = undefined;
|
||||
var query_log: logger.Logger = .init(.{}, &queue_buf);
|
||||
for (0..5) |i| query_log.log(io, entryAt(@intCast(i), "gated.example"));
|
||||
|
||||
var future = try io.concurrent(logger.Logger.runWriter, .{
|
||||
&query_log,
|
||||
io,
|
||||
log_db.database(),
|
||||
@as(?*disk_monitor.Monitor, &monitor),
|
||||
});
|
||||
|
||||
try awaitCount(&query_log.batches_gated, 1, 200);
|
||||
try testing.expectEqual(@as(u64, 0), query_log.rows_written.load(.monotonic));
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(log_db.database()));
|
||||
|
||||
monitor.cfg = .{ .min_free_mb = 0, .warn_free_mb = 0 };
|
||||
monitor.sample(io);
|
||||
try testing.expectEqual(disk_monitor.State.ok, monitor.state());
|
||||
try testing.expect(monitor.writesAllowed());
|
||||
|
||||
// The gate re-reads the monitor once per `gate_retry_s`, so the release
|
||||
// costs at most that one second.
|
||||
const limit = (logger.gate_retry_s * 1000 + 500) / 5;
|
||||
try awaitCount(&query_log.rows_written, 5, limit);
|
||||
|
||||
query_log.shutdown(io);
|
||||
try future.await(io);
|
||||
|
||||
try testing.expect(query_log.batches_gated.load(.monotonic) > 0);
|
||||
try testing.expectEqual(@as(i64, 5), try queries_repo.countRows(log_db.database()));
|
||||
try testing.expectEqual(@as(u64, 0), monitor.sample_failures.load(.monotonic));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// case 7: the log sink against a real file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "S8 case 7: the log sink appends, rotates and honours max_files" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var f: Fixture = .init();
|
||||
defer f.deinit();
|
||||
|
||||
var path_buf: [path_buf_len]u8 = undefined;
|
||||
const log_path = try f.path(&path_buf, "nxdns.log");
|
||||
|
||||
const before = logging.stats();
|
||||
|
||||
// `install` would take the 1 MiB floor of `max_size_mb`, which is a
|
||||
// megabyte of writes per generation; `installForTest` overrides that one
|
||||
// threshold and nothing else.
|
||||
const max_bytes = 256;
|
||||
logging.installForTest(io, .{
|
||||
.level = .info,
|
||||
.output = .file,
|
||||
.file_path = log_path,
|
||||
.max_files = 3,
|
||||
}, max_bytes);
|
||||
defer logging.deinstall();
|
||||
|
||||
// `logFn` is called directly: the test runner installs its own
|
||||
// `std_options`, so a `std.log` call here would never reach this sink.
|
||||
for (0..40) |i| logging.logFn(.warn, .s8_sink, "rotation line {d}", .{i});
|
||||
for (0..5) |i| logging.logFn(.info, .s8_sink, "tail line {d}", .{i});
|
||||
// Below the configured threshold, so it is filtered rather than written.
|
||||
logging.logFn(.debug, .s8_sink, "never written", .{});
|
||||
|
||||
logging.deinstall();
|
||||
|
||||
const after = logging.stats();
|
||||
try testing.expectEqual(@as(u64, 45), after.lines_written - before.lines_written);
|
||||
try testing.expect(after.rotations > before.rotations);
|
||||
try testing.expectEqual(@as(u64, 0), after.sink_errors - before.sink_errors);
|
||||
try testing.expectEqual(@as(u64, 0), after.lines_deduped - before.lines_deduped);
|
||||
|
||||
try testing.expect(try f.exists("nxdns.log"));
|
||||
try testing.expect(try f.sizeOf("nxdns.log") <= max_bytes);
|
||||
try testing.expect(try f.exists("nxdns.log.1"));
|
||||
try testing.expect(try f.exists("nxdns.log.2"));
|
||||
// `max_files` counts the live file, so generation 3 is never created.
|
||||
try testing.expect(!try f.exists("nxdns.log.3"));
|
||||
|
||||
// The live file holds the newest lines, and it is reopened rather than
|
||||
// truncated: a second install appends behind what is already there.
|
||||
const kept = try f.tmp.dir.readFileAlloc(io, "nxdns.log", testing.allocator, .limited(4096));
|
||||
defer testing.allocator.free(kept);
|
||||
try testing.expect(std.mem.count(u8, kept, "tail line 4") == 1);
|
||||
try testing.expect(std.mem.count(u8, kept, "(s8_sink)") >= 1);
|
||||
|
||||
const live_bytes = kept.len;
|
||||
logging.installForTest(io, .{
|
||||
.level = .info,
|
||||
.output = .file,
|
||||
.file_path = log_path,
|
||||
.max_files = 3,
|
||||
}, max_bytes * 16);
|
||||
logging.logFn(.info, .s8_sink, "after reopen", .{});
|
||||
logging.deinstall();
|
||||
|
||||
const reopened = try f.tmp.dir.readFileAlloc(io, "nxdns.log", testing.allocator, .limited(8192));
|
||||
defer testing.allocator.free(reopened);
|
||||
try testing.expect(reopened.len > live_bytes);
|
||||
try testing.expectEqualStrings(kept, reopened[0..live_bytes]);
|
||||
try testing.expect(std.mem.count(u8, reopened, "after reopen") == 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// case 8-9: the pure components against real packets and real addresses
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A NOERROR response for example.com A carrying one answer per TTL.
|
||||
fn buildAnswer(buf: []u8, ttls: []const u32) ![]u8 {
|
||||
const query = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01";
|
||||
const request = try packet.parse(query);
|
||||
const q = packet.firstQuestion(request).?;
|
||||
|
||||
var builder = try packet.ResponseBuilder.init(buf, request.header, q);
|
||||
for (ttls) |ttl| {
|
||||
try builder.addAnswer(q.name, .a, .in, ttl, "\x0a\x00\x00\x01");
|
||||
}
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
fn firstAnswerTtl(bytes: []const u8) !u32 {
|
||||
const p = try packet.parse(bytes);
|
||||
var it = packet.answers(p);
|
||||
return (try it.next()).?.ttl;
|
||||
}
|
||||
|
||||
test "S8 case 8: the cache ages a real response and expires it at the boundary" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var response_buf: [512]u8 = undefined;
|
||||
const response = try buildAnswer(&response_buf, &.{ 300, 600 });
|
||||
const class = dns_cache.classify(response, 3600).?;
|
||||
try testing.expectEqual(@as(u32, 300), class.ttl_seconds);
|
||||
try testing.expectEqual(false, class.negative);
|
||||
|
||||
var cache = try dns_cache.DnsCache.init(testing.allocator, .{ .size = 16, .negative_ttl_max = 3600 });
|
||||
defer cache.deinit();
|
||||
|
||||
var key_buf: [dns_cache.max_key_len]u8 = undefined;
|
||||
const key = dns_cache.buildKey(&key_buf, "example.com", 1, 1, false, null);
|
||||
|
||||
try cache.put(1000, key, response, class);
|
||||
try testing.expectEqual(@as(u32, 1), cache.len());
|
||||
|
||||
var out: [512]u8 = undefined;
|
||||
const hit = cache.get(1120, key, &out).?;
|
||||
try testing.expectEqual(response.len, hit.len);
|
||||
try testing.expectEqual(@as(u32, 180), try firstAnswerTtl(hit));
|
||||
|
||||
// The stored copy keeps its own age, so a later hit ages from the same base.
|
||||
const later = cache.get(1290, key, &out).?;
|
||||
try testing.expectEqual(@as(u32, 10), try firstAnswerTtl(later));
|
||||
|
||||
// The transaction ID is the caller's to set, and the aged bytes still parse.
|
||||
packet.setId(later, 0xbeef);
|
||||
try testing.expectEqual(@as(u16, 0xbeef), (try packet.parse(later)).header.id);
|
||||
|
||||
// The entry expires at stored_at + ttl, and that second is already too late.
|
||||
try testing.expectEqual(@as(?[]u8, null), cache.get(1300, key, &out));
|
||||
try testing.expectEqual(@as(u32, 0), cache.len());
|
||||
try testing.expectEqual(@as(u64, 2), cache.stats.hits);
|
||||
try testing.expectEqual(@as(u64, 1), cache.stats.expirations);
|
||||
}
|
||||
|
||||
test "S8 case 9: the limiter refuses the query past the limit and only that client" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var limiter = try rate_limiter.RateLimiter.init(
|
||||
testing.allocator,
|
||||
.{ .limit = 1000, .window_seconds = 60 },
|
||||
);
|
||||
defer limiter.deinit();
|
||||
|
||||
const mapped = address.NetAddress.fromIp(
|
||||
try std.Io.net.IpAddress.parse("::ffff:192.168.1.40", 53),
|
||||
).key();
|
||||
const plain = (try address.NetAddress.parse("192.168.1.40")).key();
|
||||
// One client, whichever family the socket reported it under.
|
||||
try testing.expectEqualSlices(u8, &plain, &mapped);
|
||||
|
||||
const start: std.Io.Timestamp = .{ .nanoseconds = 1 << 80 };
|
||||
for (0..1000) |i| {
|
||||
const now: std.Io.Timestamp = .{ .nanoseconds = start.nanoseconds + @as(i96, @intCast(i)) };
|
||||
try testing.expect(limiter.check(now, mapped));
|
||||
}
|
||||
try testing.expect(!limiter.check(start, plain));
|
||||
|
||||
try testing.expectEqual(@as(u64, 1000), limiter.stats.allowed);
|
||||
try testing.expectEqual(@as(u64, 1), limiter.stats.refused);
|
||||
try testing.expectEqual(@as(u64, 0), limiter.stats.untracked);
|
||||
try testing.expectEqual(@as(u32, 1), limiter.trackedClients());
|
||||
|
||||
const other = (try address.NetAddress.parse("fd00::40")).key();
|
||||
try testing.expect(limiter.check(start, other));
|
||||
try testing.expectEqual(@as(u32, 2), limiter.trackedClients());
|
||||
|
||||
// The next window admits the refused client again.
|
||||
const next: std.Io.Timestamp = .{ .nanoseconds = start.nanoseconds + 60 * std.time.ns_per_s };
|
||||
try testing.expect(limiter.check(next, mapped));
|
||||
try testing.expectEqual(@as(u64, 1), limiter.stats.refused);
|
||||
}
|
||||
Reference in New Issue
Block a user