milestone 8: web server, rest api, sse, auth, metrics and static assets
This commit is contained in:
@@ -0,0 +1,538 @@
|
||||
//! What every mutation handler shares: the collaborator checks, the database
|
||||
//! error mapping, the per-row validation, and the two ways a change is applied
|
||||
//! to the running server.
|
||||
//!
|
||||
//! Three conventions hold across `web/handlers/`:
|
||||
//!
|
||||
//! - Every repository call in this layer allocates from the per-request arena,
|
||||
//! so the repositories' `freeX` helpers are deliberately not called: the
|
||||
//! arena is reset when the response is written. Nothing read here outlives
|
||||
//! the request.
|
||||
//! - A collaborator this layer needs and does not have is a 503, never a crash
|
||||
//! and never a silent success. `web.enabled = false` opens no database at
|
||||
//! all, and a half-wired `WebState` must fail the same way.
|
||||
//! - Domain outcomes are status codes (ruling 8): `error.NotFound` is 404,
|
||||
//! `error.Constraint` is 409 with the constraint named in words, a value the
|
||||
//! validator rejects is 400, and everything else is a 500 whose cause is
|
||||
//! logged at `warn` and never sent to the client (PLAN §19).
|
||||
//!
|
||||
//! `WebState.config_lock` exists because `std.http.Server` connections are served
|
||||
//! concurrently while all of them share one config connection. SQLite is built
|
||||
//! in serialized mode, so the connection is safe — but `changes()` and
|
||||
//! `lastInsertRowid()` describe *the connection's* last statement, and those
|
||||
//! are exactly what `crud.execStrict` and every `insertXRow` read. Two
|
||||
//! concurrent writers would read each other's answer. One lock around the
|
||||
//! database work of a mutation makes the read-back belong to the writer that
|
||||
//! caused it.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const db = @import("../../storage/db.zig");
|
||||
const forward_zones = @import("../../local/forward_zones.zig");
|
||||
const local_repo = @import("../../storage/repositories/local_repo.zig");
|
||||
const local_records = @import("../../local/records.zig");
|
||||
const local_tables = @import("../../server/local_tables.zig");
|
||||
const migrations = @import("../../storage/migrations.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const server = @import("../server.zig");
|
||||
const settings_repo = @import("../../storage/repositories/settings_repo.zig");
|
||||
const validate = @import("../../config/validate.zig");
|
||||
|
||||
const clients_repo = @import("../../storage/repositories/clients_repo.zig");
|
||||
const groups_repo = @import("../../storage/repositories/groups_repo.zig");
|
||||
const rules_repo = @import("../../storage/repositories/rules_repo.zig");
|
||||
const sources_repo = @import("../../storage/repositories/sources_repo.zig");
|
||||
const upstreams_repo = @import("../../storage/repositories/upstreams_repo.zig");
|
||||
|
||||
const log = std.log.scoped(.web_api);
|
||||
|
||||
pub const Request = http_util.Request;
|
||||
pub const HandlerError = http_util.HandlerError;
|
||||
|
||||
/// Why a request did not succeed. Every handler in this directory decides in a
|
||||
/// function that takes no `std.http.Server.Request`, returns one of these, and
|
||||
/// leaves the response to `respondFailure` — so the decision is testable
|
||||
/// against an in-memory database, with no socket anywhere.
|
||||
pub const Failure = union(enum) {
|
||||
/// The id names no row: 404.
|
||||
not_found,
|
||||
/// A constraint of the schema or of the configuration: 409. The text names
|
||||
/// which one, because the client can only fix what it is told.
|
||||
conflict: []const u8,
|
||||
/// A value the validator refused: 400, with the validator's own text.
|
||||
invalid: []const u8,
|
||||
/// A collaborator this request needs is not wired: 503.
|
||||
unavailable: []const u8,
|
||||
/// Anything else the database reported: 500, cause logged, not sent.
|
||||
internal: db.Error,
|
||||
/// The write landed and the running server could not be told about it.
|
||||
/// A 500 that says exactly that, because retrying the write would not help
|
||||
/// and reporting success would leave the operator with a stale server.
|
||||
not_applied,
|
||||
};
|
||||
|
||||
pub fn respondFailure(request: *Request, failure: Failure, what: []const u8) HandlerError!void {
|
||||
return switch (failure) {
|
||||
.not_found => http_util.respondError(request, .not_found, "not found"),
|
||||
.conflict => |message| http_util.respondError(request, .conflict, message),
|
||||
.invalid => |message| http_util.respondError(request, .bad_request, message),
|
||||
.unavailable => |message| http_util.respondError(request, .service_unavailable, message),
|
||||
.internal => |err| {
|
||||
log.warn("{s} failed: {t}", .{ what, err });
|
||||
return http_util.respondError(request, .internal_server_error, "internal error");
|
||||
},
|
||||
.not_applied => http_util.respondError(
|
||||
request,
|
||||
.internal_server_error,
|
||||
"the change was saved but could not be applied; restart nxdns",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Turns a repository error into a `Failure`. `conflict` names the constraint
|
||||
/// that can fire for this statement (W2 documents one per function).
|
||||
pub fn dbFailure(err: db.Error, conflict: []const u8) Failure {
|
||||
return switch (err) {
|
||||
error.NotFound => .not_found,
|
||||
error.Constraint => .{ .conflict = conflict },
|
||||
else => .{ .internal = err },
|
||||
};
|
||||
}
|
||||
|
||||
/// The config connection, or the 503 a state without one earns.
|
||||
pub fn configDb(state: *server.WebState) union(enum) { database: *db.Db, fail: Failure } {
|
||||
if (state.config_db) |database| return .{ .database = database };
|
||||
return .{ .fail = .{ .unavailable = "no configuration database" } };
|
||||
}
|
||||
|
||||
pub fn nowSeconds(io: std.Io) i64 {
|
||||
return std.Io.Clock.real.now(io).toSeconds();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applying a change to the running server (ruling 12)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Rebuilds the blocklist snapshot so the change is live on the next query.
|
||||
///
|
||||
/// A state with no `reload_fn` has nothing to reload — that is the shape of a
|
||||
/// web layer under test, and of one whose composition root wired no manager.
|
||||
pub fn reload(state: *server.WebState, io: std.Io) ?Failure {
|
||||
const reload_fn = state.reload_fn orelse return null;
|
||||
reload_fn(state, io) catch |err| {
|
||||
log.warn("applying a configuration change failed: {s}", .{@errorName(err)});
|
||||
return .not_applied;
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Rebuilds the local records and the forward zones from the database and
|
||||
/// publishes both (ruling 12). Local answers therefore change live, without the
|
||||
/// blocklist snapshot being rebuilt.
|
||||
///
|
||||
/// Both tables are built before either is published, so a failure leaves the
|
||||
/// running server with the generation it already had.
|
||||
pub fn swapLocalTables(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
database: *db.Db,
|
||||
) ?Failure {
|
||||
const tables = state.local_tables orelse return null;
|
||||
const gpa = state.gpa;
|
||||
|
||||
const record_rows = local_repo.listLocalRecords(database, arena) catch |err|
|
||||
return rebuildFailed("reading the local records", @errorName(err));
|
||||
|
||||
const zone_rows = local_repo.listForwardZones(database, arena) catch |err|
|
||||
return rebuildFailed("reading the forward zones", @errorName(err));
|
||||
|
||||
var built_records = local_records.Records.build(gpa, record_rows.items) catch |err|
|
||||
return rebuildFailed("building the local records", @errorName(err));
|
||||
errdefer built_records.deinit(gpa);
|
||||
|
||||
const built_zones = forward_zones.Zones.build(gpa, zone_rows.items) catch |err|
|
||||
return rebuildFailed("building the forward zones", @errorName(err));
|
||||
|
||||
tables.swap(io, gpa, built_records, built_zones);
|
||||
return null;
|
||||
}
|
||||
|
||||
fn rebuildFailed(what: []const u8, cause: []const u8) Failure {
|
||||
log.warn("{s} after a change failed: {s}", .{ what, cause });
|
||||
return .not_applied;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// per-row validation
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// `config/validate.zig` validates a whole configuration and is not this
|
||||
// session's to split, so a candidate row is checked by handing the real
|
||||
// validator a configuration that holds the skeleton it insists on (one default
|
||||
// group, one upstream) plus the one row under test. The row's own rules —
|
||||
// domain syntax, record values, rule patterns, CIDR prefixes, source urls, TTL
|
||||
// ranges — are then exactly the shipped ones, with no second copy to drift.
|
||||
//
|
||||
// Cross-row facts are deliberately NOT checked here: a duplicate is the
|
||||
// database's UNIQUE constraint and answers 409 (ruling 9), and a group that
|
||||
// does not exist is a foreign key and answers 409 too. Reporting either as a
|
||||
// 400 would be a second, weaker opinion about the same fact.
|
||||
|
||||
const skeleton_group = "default";
|
||||
const skeleton_upstream: model.UpstreamServer = .{ .url = "https://dns.example/dns-query" };
|
||||
|
||||
/// Runs the shipped validator over `cfg` and returns the first problem's text,
|
||||
/// or null when the candidate is valid. The text is arena-allocated.
|
||||
pub fn firstProblem(arena: Allocator, cfg: model.Config) error{OutOfMemory}!?[]const u8 {
|
||||
var diags: validate.Diagnostics = .init(arena);
|
||||
defer diags.deinit();
|
||||
|
||||
validate.validate(cfg, &diags) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => {},
|
||||
};
|
||||
if (diags.problems.items.len == 0) return null;
|
||||
const problem = diags.problems.items[0];
|
||||
return try std.fmt.allocPrint(arena, "{s}: {s}", .{ problem.path, problem.message });
|
||||
}
|
||||
|
||||
/// The configuration skeleton every candidate is validated inside.
|
||||
fn skeleton(groups: []const model.Group) model.Config {
|
||||
return .{
|
||||
.upstreams = &.{skeleton_upstream},
|
||||
.groups = groups,
|
||||
};
|
||||
}
|
||||
|
||||
const default_groups = [_]model.Group{.{ .name = skeleton_group }};
|
||||
|
||||
pub fn checkLocalRecord(arena: Allocator, record: model.LocalRecord) error{OutOfMemory}!?[]const u8 {
|
||||
var cfg = skeleton(&default_groups);
|
||||
cfg.local_records = &.{record};
|
||||
return firstProblem(arena, cfg);
|
||||
}
|
||||
|
||||
pub fn checkForwardZone(arena: Allocator, zone: model.ForwardZone) error{OutOfMemory}!?[]const u8 {
|
||||
var cfg = skeleton(&default_groups);
|
||||
cfg.forward_zones = &.{zone};
|
||||
return firstProblem(arena, cfg);
|
||||
}
|
||||
|
||||
pub fn checkRule(arena: Allocator, pattern: []const u8, kind: model.RuleKind) error{OutOfMemory}!?[]const u8 {
|
||||
var cfg = skeleton(&default_groups);
|
||||
cfg.rules = &.{.{ .group = skeleton_group, .pattern = pattern, .kind = kind, .action = .block }};
|
||||
return firstProblem(arena, cfg);
|
||||
}
|
||||
|
||||
pub fn checkSource(arena: Allocator, source: model.BlocklistSource) error{OutOfMemory}!?[]const u8 {
|
||||
var cfg = skeleton(&default_groups);
|
||||
cfg.blocklist_sources = &.{source};
|
||||
return firstProblem(arena, cfg);
|
||||
}
|
||||
|
||||
pub fn checkClientIp(arena: Allocator, ip: []const u8) error{OutOfMemory}!?[]const u8 {
|
||||
var cfg = skeleton(&default_groups);
|
||||
cfg.clients = &.{.{ .ip = ip, .group = skeleton_group }};
|
||||
return firstProblem(arena, cfg);
|
||||
}
|
||||
|
||||
pub fn checkClientPrefix(arena: Allocator, prefix: []const u8, priority: i32) error{OutOfMemory}!?[]const u8 {
|
||||
var cfg = skeleton(&default_groups);
|
||||
cfg.client_prefixes = &.{.{ .prefix = prefix, .group = skeleton_group, .priority = priority }};
|
||||
return firstProblem(arena, cfg);
|
||||
}
|
||||
|
||||
/// A group name is checked inside a configuration that already holds the
|
||||
/// default group, so a candidate named anything else is still complete.
|
||||
pub fn checkGroupName(arena: Allocator, name: []const u8) error{OutOfMemory}!?[]const u8 {
|
||||
if (std.mem.eql(u8, name, skeleton_group)) return firstProblem(arena, skeleton(&default_groups));
|
||||
const groups = [_]model.Group{ .{ .name = skeleton_group }, .{ .name = name } };
|
||||
return firstProblem(arena, skeleton(&groups));
|
||||
}
|
||||
|
||||
/// An upstream candidate is validated next to one known-good enabled upstream,
|
||||
/// so a disabled candidate does not trip the whole-config rule that at least
|
||||
/// one upstream must be enabled — whether the stored set satisfies that rule is
|
||||
/// the handler's own guard, not this row check's. The companion's url moves out
|
||||
/// of the way of a candidate that holds the skeleton url, because a duplicate
|
||||
/// is the database's answer, not the validator's.
|
||||
pub fn checkUpstream(arena: Allocator, upstream: model.UpstreamServer) error{OutOfMemory}!?[]const u8 {
|
||||
const companion: model.UpstreamServer = if (std.mem.eql(u8, upstream.url, skeleton_upstream.url))
|
||||
.{ .url = "https://dns-b.example/dns-query" }
|
||||
else
|
||||
skeleton_upstream;
|
||||
var cfg = skeleton(&default_groups);
|
||||
cfg.upstreams = &.{ upstream, companion };
|
||||
return firstProblem(arena, cfg);
|
||||
}
|
||||
|
||||
/// The 400 a malformed or unparseable body earns.
|
||||
pub fn respondBadBody(request: *Request, err: anyerror) HandlerError!void {
|
||||
return switch (err) {
|
||||
error.TooLarge => http_util.respondError(request, .payload_too_large, "request body too large"),
|
||||
error.OutOfMemory => error.OutOfMemory,
|
||||
error.WriteFailed => error.WriteFailed,
|
||||
error.HttpExpectationFailed => error.HttpExpectationFailed,
|
||||
// A vanished peer mid-body is the same event as a vanished peer
|
||||
// mid-response, and ends the connection the same way (ruling 28).
|
||||
error.ReadFailed => error.WriteFailed,
|
||||
else => http_util.respondError(request, .bad_request, "malformed request body"),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reading the stored configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Every settings row and every collection, as one `model.Config`. The settings
|
||||
/// PUT validates against this (ruling 16), so the check sees the same
|
||||
/// configuration the next start would.
|
||||
///
|
||||
/// Every string belongs to `arena`.
|
||||
pub fn loadConfig(arena: Allocator, database: *db.Db) db.Error!model.Config {
|
||||
var cfg: model.Config = .{};
|
||||
|
||||
const pairs = try settings_repo.listSettings(database, arena);
|
||||
var unknown: usize = 0;
|
||||
model.fromSettings(pairs.items, &cfg, &unknown) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
// A stored value this build cannot decode is a corrupt row, not a
|
||||
// client error: the caller reports 500 and the operator sees the log.
|
||||
error.BadSettingValue => return error.Mismatch,
|
||||
};
|
||||
|
||||
const groups = try groups_repo.listGroups(database, arena);
|
||||
cfg.groups = groups.items;
|
||||
const upstreams = try upstreams_repo.listUpstreams(database, arena);
|
||||
cfg.upstreams = upstreams.items;
|
||||
const clients = try clients_repo.listClients(database, arena);
|
||||
cfg.clients = clients.items;
|
||||
const prefixes = try clients_repo.listClientPrefixes(database, arena);
|
||||
cfg.client_prefixes = prefixes.items;
|
||||
const sources = try sources_repo.listBlocklistSources(database, arena);
|
||||
cfg.blocklist_sources = sources.items;
|
||||
const group_sources = try groups_repo.listGroupSources(database, arena);
|
||||
cfg.group_sources = group_sources.items;
|
||||
const rules = try rules_repo.listRules(database, arena);
|
||||
cfg.rules = rules.items;
|
||||
const records = try local_repo.listLocalRecords(database, arena);
|
||||
cfg.local_records = records.items;
|
||||
const zones = try local_repo.listForwardZones(database, arena);
|
||||
cfg.forward_zones = zones.items;
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the test bench every handler in this directory shares
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A running web layer with no sockets in it: an in-memory config database at
|
||||
/// the current schema, the local-table holder, a request arena, and a
|
||||
/// `reload_fn` that counts instead of rebuilding a snapshot.
|
||||
///
|
||||
/// `state` is a field rather than a pointer so the reload seam can find the
|
||||
/// bench through `@fieldParentPtr` — a `WebState` carries no user data, and a
|
||||
/// global counter would make two tests in one binary share it.
|
||||
///
|
||||
/// Referenced only by this directory's tests; nothing in a shipped build calls
|
||||
/// `init`, so it costs nothing there.
|
||||
pub const Bench = struct {
|
||||
threaded: std.Io.Threaded,
|
||||
database: db.Db,
|
||||
tables: local_tables.LocalTables,
|
||||
arena_state: std.heap.ArenaAllocator,
|
||||
state: server.WebState,
|
||||
reloads: usize,
|
||||
reload_fails: bool,
|
||||
|
||||
/// Initialises in place: `state` points at fields of `self`.
|
||||
pub fn init(self: *Bench, gpa: Allocator) !void {
|
||||
self.threaded = .init(gpa, .{});
|
||||
errdefer self.threaded.deinit();
|
||||
|
||||
self.database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
errdefer self.database.close();
|
||||
try db.applyPragmas(&self.database, .{});
|
||||
_ = try migrations.migrate(&self.database);
|
||||
|
||||
self.tables = .empty;
|
||||
self.arena_state = .init(gpa);
|
||||
self.reloads = 0;
|
||||
self.reload_fails = false;
|
||||
self.state = .{
|
||||
.gpa = gpa,
|
||||
.config_db = &self.database,
|
||||
.local_tables = &self.tables,
|
||||
.reload_fn = countingReload,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Bench, gpa: Allocator) void {
|
||||
self.state.live_hash.deinit(gpa);
|
||||
self.tables.deinit(gpa);
|
||||
self.arena_state.deinit();
|
||||
self.database.close();
|
||||
self.threaded.deinit();
|
||||
}
|
||||
|
||||
pub fn io(self: *Bench) std.Io {
|
||||
return self.threaded.io();
|
||||
}
|
||||
|
||||
pub fn arena(self: *Bench) Allocator {
|
||||
return self.arena_state.allocator();
|
||||
}
|
||||
|
||||
/// One statement of setup, for the rows a case needs before it starts.
|
||||
pub fn exec(self: *Bench, sql: [:0]const u8) !void {
|
||||
try self.database.exec(sql);
|
||||
}
|
||||
|
||||
pub fn queryInt(self: *Bench, sql: []const u8) !i64 {
|
||||
return self.database.queryInt(sql);
|
||||
}
|
||||
|
||||
fn countingReload(state: *server.WebState, io_unused: std.Io) anyerror!void {
|
||||
_ = io_unused;
|
||||
const self: *Bench = @alignCast(@fieldParentPtr("state", state));
|
||||
self.reloads += 1;
|
||||
if (self.reload_fails) return error.ReloadFailed;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn arenaFor(state: *std.heap.ArenaAllocator) Allocator {
|
||||
return state.allocator();
|
||||
}
|
||||
|
||||
test "the bench wires a state whose reload seam counts" {
|
||||
var bench: Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
try testing.expect(bench.state.config_db != null);
|
||||
try testing.expectEqual(@as(?Failure, null), reload(&bench.state, bench.io()));
|
||||
try testing.expectEqual(@as(usize, 1), bench.reloads);
|
||||
|
||||
bench.reload_fails = true;
|
||||
try testing.expectEqual(Failure.not_applied, reload(&bench.state, bench.io()).?);
|
||||
try testing.expectEqual(@as(usize, 2), bench.reloads);
|
||||
}
|
||||
|
||||
test "the schema the bench opens already holds the default group" {
|
||||
var bench: Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT id FROM groups WHERE name = 'default'"));
|
||||
}
|
||||
|
||||
test "a database error maps to the status its cause deserves" {
|
||||
try testing.expectEqual(Failure.not_found, dbFailure(error.NotFound, "x"));
|
||||
try testing.expectEqualStrings("taken", dbFailure(error.Constraint, "taken").conflict);
|
||||
try testing.expectEqual(db.Error.Busy, dbFailure(error.Busy, "x").internal);
|
||||
}
|
||||
|
||||
test "a valid candidate row reports no problem" {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arenaFor(&arena_state);
|
||||
|
||||
try testing.expectEqual(
|
||||
@as(?[]const u8, null),
|
||||
try checkLocalRecord(arena, .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 }),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(?[]const u8, null),
|
||||
try checkForwardZone(arena, .{ .zone = "lan", .resolver = "udp://10.0.0.1:53" }),
|
||||
);
|
||||
try testing.expectEqual(@as(?[]const u8, null), try checkRule(arena, "*.ads.example", .wildcard));
|
||||
try testing.expectEqual(@as(?[]const u8, null), try checkClientIp(arena, "192.168.1.10"));
|
||||
try testing.expectEqual(@as(?[]const u8, null), try checkClientPrefix(arena, "192.168.1.0/24", 100));
|
||||
try testing.expectEqual(@as(?[]const u8, null), try checkGroupName(arena, "kids"));
|
||||
try testing.expectEqual(@as(?[]const u8, null), try checkGroupName(arena, "default"));
|
||||
try testing.expectEqual(
|
||||
@as(?[]const u8, null),
|
||||
try checkSource(arena, .{ .url = "https://example.test/list.txt", .name = "list" }),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(?[]const u8, null),
|
||||
try checkUpstream(arena, .{ .url = "tls://1.1.1.1:853", .tls_name = "one.one.one.one" }),
|
||||
);
|
||||
}
|
||||
|
||||
test "a disabled upstream candidate is valid on its own merits" {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arenaFor(&arena_state);
|
||||
|
||||
try testing.expectEqual(
|
||||
@as(?[]const u8, null),
|
||||
try checkUpstream(arena, .{ .url = "https://dns.other/dns-query", .enabled = false }),
|
||||
);
|
||||
// The skeleton's own url must not read as a duplicate of the companion.
|
||||
try testing.expectEqual(
|
||||
@as(?[]const u8, null),
|
||||
try checkUpstream(arena, .{ .url = skeleton_upstream.url, .enabled = false }),
|
||||
);
|
||||
// A disabled row's other fields are still judged.
|
||||
const bad = try checkUpstream(arena, .{ .url = "udp://1.1.1.1:53", .enabled = false });
|
||||
try testing.expect(bad != null);
|
||||
}
|
||||
|
||||
test "an invalid candidate row names the field that failed" {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arenaFor(&arena_state);
|
||||
|
||||
const bad_value = try checkLocalRecord(
|
||||
arena,
|
||||
.{ .name = "nas.lan", .rtype = .a, .value = "not-an-ip", .ttl = 60 },
|
||||
);
|
||||
try testing.expect(bad_value != null);
|
||||
try testing.expect(std.mem.startsWith(u8, bad_value.?, "local_records[0].value:"));
|
||||
|
||||
const bad_ttl = try checkLocalRecord(
|
||||
arena,
|
||||
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 0 },
|
||||
);
|
||||
try testing.expect(bad_ttl != null);
|
||||
|
||||
const bad_resolver = try checkForwardZone(arena, .{ .zone = "lan", .resolver = "http://10.0.0.1" });
|
||||
try testing.expect(bad_resolver != null);
|
||||
|
||||
const bad_pattern = try checkRule(arena, "ads.*.example", .exact);
|
||||
try testing.expect(bad_pattern != null);
|
||||
|
||||
const bad_prefix = try checkClientPrefix(arena, "192.168.1.0", 100);
|
||||
try testing.expect(bad_prefix != null);
|
||||
|
||||
const empty_group = try checkGroupName(arena, "");
|
||||
try testing.expect(empty_group != null);
|
||||
|
||||
const bad_source = try checkSource(arena, .{ .url = "ftp://example.test/list", .name = "list" });
|
||||
try testing.expect(bad_source != null);
|
||||
}
|
||||
|
||||
test "a candidate is judged alone, so a duplicate is left to the database" {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arenaFor(&arena_state);
|
||||
|
||||
// The same zone twice would be `DuplicateForwardZone` in a whole config;
|
||||
// one candidate row cannot collide with itself, and the UNIQUE constraint
|
||||
// is what answers 409.
|
||||
try testing.expectEqual(
|
||||
@as(?[]const u8, null),
|
||||
try checkForwardZone(arena, .{ .zone = "lan", .resolver = "udp://10.0.0.1:53" }),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user