Files
nxdns/src/web/handlers/mutations.zig
T
mokhtar ce143d1d87
Gates / frontend (push) Successful in 1m43s
Gates / test (push) Successful in 2m14s
Gates / test-aarch64 (push) Successful in 8m3s
Gates / package (push) Successful in 5m42s
Gates / container (push) Successful in 54s
CI / gates (push) Successful in 50m24s
db-mode config changes apply live in-process
settings and upstream writes now follow a prepare, commit, publish, retire
contract: candidates are built and validated before the database transaction,
published as infallible pointer swaps, and old generations retire after their
readers drain. per-query policy values snapshot once per query; upstream pool,
cache, rate limiter, sessions, api limiter, log sink, blocklist scheduler and
the query-log queue each gained one named live operation. restart_required
shrinks from every scalar key to the bind keys and web.enabled; the admin ui
drops its restart notices for everything else. file mode is unchanged.
2026-08-24 00:04:28 +02:00

715 lines
31 KiB
Zig

//! 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 503 a state with no config connection earns. One constant, because every
/// caller reports the same missing collaborator in the same words.
pub const no_config_db: Failure = .{ .unavailable = "no configuration database" };
/// The config connection, or `error.NoConfigDb` for the caller to turn into
/// `no_config_db` in whatever shape it answers with — a `Failure`, a `Created`,
/// or a written response.
pub fn requireConfigDb(state: *server.WebState) error{NoConfigDb}!*db.Db {
return state.config_db orelse error.NoConfigDb;
}
pub fn nowSeconds(io: std.Io) i64 {
return std.Io.Clock.real.now(io).toSeconds();
}
// ---------------------------------------------------------------------------
// the identical half of an id-addressed resource
// ---------------------------------------------------------------------------
/// The `list`, `get` and `remove` handlers every id-addressed resource in this
/// directory writes the same way: take the config connection or answer 503, call
/// one repository function, and turn what comes back into the response. Nothing
/// a resource decides for itself is here — the create and update bodies, the
/// constraint texts, and the four reload flavors stay hand-written beside the
/// descriptor that names these three.
///
/// `desc` is an anonymous struct literal rather than a typed struct because
/// `anytype` is not legal as a struct *field* type and the members are functions
/// of five signatures. Every member is checked below, so a descriptor that is
/// missing one or spells one wrong is a compile error that names it.
///
/// Members:
///
/// - `Row: type` — what the repository returns for one row.
/// - `list: fn (*db.Db, Allocator) db.Error!std.ArrayList(Row)`.
/// - `get: fn (*db.Db, Allocator, i64) db.Error!?Row`, or `null` for a resource
/// with no `/{id}` route.
/// - `remove: fn (*server.WebState, std.Io, i64) ?Failure`, or the same with an
/// `Allocator` before the id for a delete decision that reads rows, or `null`.
/// - `label: []const u8` — "an upstream": what "reading" and "deleting" take as
/// their object in the log context a 500 carries.
/// - `plural: []const u8` — "upstreams": what "listing" takes as its object.
/// - `envelope: []const u8` — the JSON key the list arrives under.
/// - `view: fn (Row) View` — optional. A resource whose wire shape is not its
/// row spells the difference here; without it the row is serialised as it is.
pub fn Resource(comptime desc: anytype) type {
const Desc = @TypeOf(desc);
for ([_][]const u8{ "Row", "list", "get", "remove", "label", "plural", "envelope" }) |name| {
if (!@hasField(Desc, name)) {
@compileError("resource descriptor has no `" ++ name ++ "`");
}
}
if (@TypeOf(desc.Row) != type) @compileError("resource descriptor `Row` must be a type");
const Row = desc.Row;
expectType("list", @TypeOf(desc.list), fn (*db.Db, Allocator) db.Error!std.ArrayList(Row));
if (!isNull(@TypeOf(desc.get))) {
expectType("get", @TypeOf(desc.get), fn (*db.Db, Allocator, i64) db.Error!?Row);
}
if (!isNull(@TypeOf(desc.remove))) {
const Remove = @TypeOf(desc.remove);
if (removeTakesArena(Remove)) {
if (removeIsFallible(Remove)) {
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) error{OutOfMemory}!?Failure);
} else {
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) ?Failure);
}
} else {
expectType("remove", Remove, fn (*server.WebState, std.Io, i64) ?Failure);
}
}
_ = @as([]const u8, desc.label);
_ = @as([]const u8, desc.plural);
_ = @as([]const u8, desc.envelope);
const has_view = @hasField(Desc, "view");
if (has_view) {
const info = @typeInfo(@TypeOf(desc.view)).@"fn";
if (info.params.len != 1 or info.params[0].type.? != Row) {
@compileError("resource descriptor `view` must take one " ++ @typeName(Row));
}
}
const View = if (has_view) @typeInfo(@TypeOf(desc.view)).@"fn".return_type.? else Row;
const names: [1][:0]const u8 = .{desc.envelope};
const types: [1]type = .{[]const View};
const attrs: [1]std.builtin.Type.StructField.Attributes = .{.{}};
const Envelope = @Struct(.auto, null, &names, &types, &attrs);
const list_what = "listing " ++ desc.plural;
const get_what = "reading " ++ desc.label;
const remove_what = "deleting " ++ desc.label;
return struct {
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = requireConfigDb(state) catch
return respondFailure(request, no_config_db, list_what);
const rows = desc.list(database, request.arena) catch |err|
return respondFailure(request, .{ .internal = err }, list_what);
const items: []const View = if (has_view) views: {
const views = try request.arena.alloc(View, rows.items.len);
for (views, rows.items) |*view, row| view.* = desc.view(row);
break :views views;
} else rows.items;
var payload: Envelope = undefined;
@field(payload, desc.envelope) = items;
return http_util.respondJson(request, .ok, payload, &.{});
}
pub const get = if (isNull(@TypeOf(desc.get))) {} else getRow;
pub const remove = if (isNull(@TypeOf(desc.remove))) {} else removeRow;
fn getRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = requireConfigDb(state) catch
return respondFailure(request, no_config_db, get_what);
const row = desc.get(database, request.arena, request.id.?) catch |err|
return respondFailure(request, .{ .internal = err }, get_what);
const found = row orelse return respondFailure(request, .not_found, "");
const body: View = if (has_view) desc.view(found) else found;
return http_util.respondJson(request, .ok, body, &.{});
}
fn removeRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const failure = if (comptime !removeTakesArena(@TypeOf(desc.remove)))
desc.remove(state, io, request.id.?)
else if (comptime removeIsFallible(@TypeOf(desc.remove)))
try desc.remove(state, io, request.arena, request.id.?)
else
desc.remove(state, io, request.arena, request.id.?);
if (failure) |value| return respondFailure(request, value, remove_what);
return http_util.respondEmpty(request, .no_content);
}
};
}
fn isNull(comptime T: type) bool {
return T == @TypeOf(null);
}
fn removeTakesArena(comptime T: type) bool {
return @typeInfo(T) == .@"fn" and @typeInfo(T).@"fn".params.len == 4;
}
/// A delete decision that builds a candidate for the running server allocates
/// while it does so, so it may run out of memory. The two shapes are checked
/// exactly, so a `remove` that returns some other error set is still a compile
/// error naming what it should have been.
fn removeIsFallible(comptime T: type) bool {
return @typeInfo(@typeInfo(T).@"fn".return_type.?) == .error_union;
}
fn expectType(comptime name: []const u8, comptime Actual: type, comptime Expected: type) void {
if (Actual != Expected) @compileError("resource descriptor `" ++ name ++ "` must be " ++
@typeName(Expected) ++ ", found " ++ @typeName(Actual));
}
// ---------------------------------------------------------------------------
// 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.
///
/// Locking contract, and it is the reason every one of the fourteen call sites
/// in `groups.zig`, `rules.zig`, `clients.zig` and `blocklists.zig` may call
/// this *after* releasing `state.config_lock`: `reload_fn` must re-read all of
/// the state it publishes from the database itself, under the manager's own
/// writer lock. It must never accept rows the caller read. Rows read under
/// `config_lock` and passed across its release are already stale, so a
/// signature change that adds a row parameter here silently breaks the
/// correctness of all fourteen sites — every one of them would have to move
/// the call back inside the lock.
///
/// `swapLocalTables` below is the pre-read shape and is exactly the contrast:
/// it takes the rows, so `local.zig`'s `publish` calls it while it still holds
/// `config_lock`, and the ordering rationale lives on that function.
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 failure's text,
/// or null when the candidate is valid. The text is arena-allocated.
///
/// Failures only: a warning describes a configuration that is legal, and a row
/// this API is about to store cannot be rejected for one. The blocklist source
/// a POST creates is in no group yet — a warning by design, and never a 400.
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 => {},
};
const problem = diags.firstFailure() orelse return null;
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 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 state with no configuration database is a 503, not a crash" {
var bench: Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try testing.expectEqual(&bench.database, try requireConfigDb(&bench.state));
var bare: server.WebState = .{ .gpa = testing.allocator };
try testing.expectError(error.NoConfigDb, requireConfigDb(&bare));
try testing.expectEqualStrings("no configuration database", no_config_db.unavailable);
}
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 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" }),
);
}