milestone 13 discrepancies: redact credentials from urls in logs, metrics and cli output

This commit is contained in:
2026-08-07 00:45:17 +02:00
parent 1ff727feb8
commit 8c3328562e
39 changed files with 5734 additions and 510 deletions
+85 -4
View File
@@ -7,7 +7,10 @@
//! The policy is three lines long:
//!
//! - no file → normal steady state, keep the database as it is;
//! - database already configured → the file is ignored, as PLAN §3.5 requires;
//! - database already configured → the file is ignored, as PLAN §3.5 requires.
//! Configured means an operator put something there. A database that has only
//! answered queries is not configured, however many client rows the DNS path
//! materialised into it, and `import.isEmpty` is where that line is drawn;
//! - otherwise → import it, and a file that is unreadable, unparseable or
//! invalid is an error. The operator wrote that file and meant it; starting
//! with silent defaults instead is the exact failure mode PLAN §1.3 exists to
@@ -55,6 +58,84 @@ pub fn bootstrap(
return .seeded;
}
// Every path through `bootstrap` starts with a filesystem access, so all three
// outcomes are exercised in `src/storage/storage_integration_test.zig` (S7)
// against real files. There is nothing here that an in-memory test could reach.
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
//
// All three outcomes are exercised end to end in
// `src/storage/storage_integration_test.zig` (S7) against a real data directory.
// What the two cases below add is the one distinction that decides which outcome
// an operator gets, and it is too important to leave behind a `-Dintegration`
// flag: whether the database has been *configured*, not whether it has been
// *used*.
const testing = std.testing;
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const migrations = @import("../storage/migrations.zig");
const seed_source =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\}
;
/// Unparseable on purpose: a call that succeeds proves the file was never read.
const broken_source = ".{ .groups = ";
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
test "a server that has answered queries still seeds from its configuration file" {
const io = testing.io;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = seed_source });
var database = try openMigrated();
defer database.close();
// The unattended first boot: the server came up on defaults, answered
// traffic, and the operator dropped a config file in afterwards.
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap(io, testing.allocator, &database, tmp.dir, "config.zon", &diags);
try testing.expectEqual(Outcome.seeded, outcome);
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
// Seeding did not cost the operator the device list they had been watching.
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
}
test "a client the operator has customised keeps the configuration file out" {
const io = testing.io;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = broken_source });
var database = try openMigrated();
defer database.close();
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
const id = try database.queryInt("SELECT id FROM clients WHERE ip = '192.168.1.5'");
try clients_repo.updateClient(&database, id, .{ .name = "tv", .group_id = 1 });
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap(io, testing.allocator, &database, tmp.dir, "config.zon", &diags);
try testing.expectEqual(Outcome.db_already_configured, outcome);
try testing.expectEqual(@as(usize, 0), diags.problems.items.len);
// The name and the flag the operator set are still theirs.
try testing.expectEqual(@as(i64, 1), try database.queryInt(
"SELECT count(*) FROM clients WHERE name = 'tv' AND hand_edited = 1",
));
}
+115
View File
@@ -0,0 +1,115 @@
//! One definition of "the operator's configuration is wrong".
//!
//! `run`, `check` and `import` all sort a failure into two buckets: the
//! configuration is wrong and the operator can fix it (exit 2, `nxdns check`
//! is the next step), or something else broke (exit 1). Each subcommand used to
//! carry its own list of which errors meant which, and the lists disagreed —
//! the same seed file exited 1 from `run` and 2 from `check`. There is one list
//! now, and it is this file. Nothing else may keep a second one.
const std = @import("std");
const validate = @import("validate.zig");
/// `ValidateError` enters as a whole set rather than variant by variant, so a
/// variant added to the validator cannot silently fall through to exit 1. The
/// four extras are the configuration faults raised outside the validator: the
/// ZON reader (`ParseZon`), the seed-file size limit (`ConfigTooLarge`), the
/// composition root's upstream build (`NoUsableUpstreams`) and its certificate
/// load (`BadCertificate`).
///
/// Not here on purpose: `error.DatabaseNotEmpty`, which reports the state of
/// the database rather than the content of a file, and is the one config-shaped
/// exit 2 `cli` decides for itself.
const ConfigFault = validate.ValidateError || error{
ParseZon,
ConfigTooLarge,
NoUsableUpstreams,
BadCertificate,
};
const faults: []const anyerror = blk: {
const set = @typeInfo(ConfigFault).error_set.?;
var list: [set.len]anyerror = undefined;
for (set, 0..) |member, i| list[i] = @field(anyerror, member.name);
const frozen = list;
break :blk &frozen;
};
/// True when `err` means the configuration the operator supplied is wrong.
/// Linear over a set of about forty errors, on failure paths only.
pub fn isConfigFault(err: anyerror) bool {
for (faults) |fault| {
if (err == fault) return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const testing = std.testing;
test "every ValidateError variant is a configuration fault, with no exceptions" {
// The guard on the derivation: a variant added to `ValidateError` and left
// out of the classification fails here rather than exiting 1 in the field.
//
// No member is excused. This file used to subtract `error.OutOfMemory`
// here, which made the rule "every ValidateError is a fault, except one" —
// a private exclusion list of exactly the kind this file exists to abolish.
// `validate.ValidateError` no longer carries an allocation failure, so the
// rule is literal again.
inline for (@typeInfo(validate.ValidateError).error_set.?) |member| {
const err = @field(anyerror, member.name);
if (!isConfigFault(err)) {
std.debug.print("isConfigFault(error.{s}) is false, expected true\n", .{member.name});
return error.TestUnexpectedResult;
}
}
}
test "an allocation failure is not a member of the validator's verdict" {
// The root of it: `faults.zig` can only be exception-free while
// `ValidateError` holds nothing that is not a verdict on the file.
inline for (@typeInfo(validate.ValidateError).error_set.?) |member| {
if (std.mem.eql(u8, member.name, "OutOfMemory")) {
std.debug.print("ValidateError carries error.OutOfMemory\n", .{});
return error.TestUnexpectedResult;
}
}
// It is still reachable from `validate`, just not as a finding: the
// allocator can fail and the caller has to handle it.
comptime var reachable = false;
inline for (@typeInfo(validate.Error).error_set.?) |member| {
if (comptime std.mem.eql(u8, member.name, "OutOfMemory")) reachable = true;
}
try testing.expect(reachable);
}
test "the faults raised outside the validator are configuration faults" {
try testing.expect(isConfigFault(error.ParseZon));
try testing.expect(isConfigFault(error.ConfigTooLarge));
try testing.expect(isConfigFault(error.NoUsableUpstreams));
try testing.expect(isConfigFault(error.BadCertificate));
}
test "the seed-file errors that used to exit 1 from run are configuration faults" {
// D1 verbatim: these three reached `run` from a rejected seed file and were
// classified as runtime failures.
try testing.expect(isConfigFault(error.ParseZon));
try testing.expect(isConfigFault(error.MissingDefaultGroup));
try testing.expect(isConfigFault(error.NoUpstreams));
}
test "a runtime failure is not a configuration fault" {
try testing.expect(!isConfigFault(error.OutOfMemory));
try testing.expect(!isConfigFault(error.AccessDenied));
try testing.expect(!isConfigFault(error.FileNotFound));
try testing.expect(!isConfigFault(error.AddressInUse));
// A state conflict, not a bad file: `import` refuses to overwrite a
// configured database and decides that exit code itself.
try testing.expect(!isConfigFault(error.DatabaseNotEmpty));
// Only ever a warning, so it never reaches an exit code by this route.
try testing.expect(!isConfigFault(error.SourceInNoGroup));
}
+363 -15
View File
@@ -1,4 +1,13 @@
//! `nxdns import`: a ZON file becomes the whole content of `config.db`.
//! `nxdns import`: a ZON file becomes the whole configuration of `config.db`.
//!
//! Configuration, not content: the `hand_edited = 0` client rows the DNS path
//! materialises from live traffic are runtime state, they are absent from an
//! export, and an import carries them across rather than deleting them.
//!
//! `clients.first_seen` and `clients.last_seen` are runtime state on every client
//! row, configured ones included, and the config model carries neither. So an
//! address the database already knew keeps both across an import, and only an
//! address it has never seen takes the import's clock.
//!
//! The order is the specification. Nothing reaches the database until the file
//! has been read, parsed and validated, and every write happens inside one
@@ -44,21 +53,31 @@ const canonical_buf_len = 64;
// emptiness
// ---------------------------------------------------------------------------
/// A database is "never configured" when the migrations have run and nothing
/// else has. The migrations themselves create `schema_version` and seed
/// `groups(1, 'default')`, so "no rows anywhere" is the wrong test.
/// A database is "never configured" when the migrations have run and the
/// operator has added nothing. The migrations themselves create
/// `schema_version` and seed `groups(1, 'default')`, so "no rows anywhere" is
/// the wrong test.
///
/// True iff every table in `config_schema.content_tables` is empty, `groups`
/// holds exactly one row, and that row is the seeded `(1, 'default', 0)`.
/// True iff no table in `config_schema.content_tables` holds a row the operator
/// put there, `groups` holds exactly one row, and that row is the seeded
/// `(1, 'default', 0)`.
///
/// The client count here includes auto-materialised rows: a server that has
/// answered one query is configured enough that a bootstrap file must not
/// overwrite it.
/// `clients` is the one table a row can reach without an operator: the DNS path
/// materialises `hand_edited = 0` rows straight from live traffic (PLAN §7.2).
/// Counting those made emptiness a function of traffic — a server that had
/// answered a single query silently ignored the seed file its operator dropped
/// next to it. So only `hand_edited = 1` rows count, which is the predicate
/// `clients_repo.listClients` already exports by: this database is empty exactly
/// when its export is empty.
pub fn isEmpty(database: *db.Db) db.Error!bool {
// `inline for` over a comptime table list: every statement below is a
// compile-time string, so no table name is ever concatenated at run time.
inline for (config_schema.content_tables) |table| {
if (try database.queryInt("SELECT count(*) FROM " ++ table) != 0) return false;
const count_sql = comptime if (std.mem.eql(u8, table, "clients"))
"SELECT count(*) FROM clients WHERE hand_edited = 1"
else
"SELECT count(*) FROM " ++ table;
if (try database.queryInt(count_sql) != 0) return false;
}
if (try database.queryInt("SELECT count(*) FROM groups") != 1) return false;
const seeded = try database.queryInt(
@@ -179,6 +198,8 @@ pub fn applyToDb(
// writes are one atomic unit.
if (!options.force and !try isEmpty(database)) return error.DatabaseNotEmpty;
try liftSavedClients(database);
inline for (config_schema.delete_order) |table| {
try database.exec("DELETE FROM " ++ table ++ ";");
}
@@ -203,6 +224,8 @@ pub fn applyToDb(
canonical.ip = try canonicalIp(client.ip, &buf);
try clients_repo.insertClient(database, canonical, ctx);
}
try restoreSavedClients(database, group_ids.get("default").?);
for (cfg.client_prefixes) |entry| {
var buf: [canonical_buf_len]u8 = undefined;
var canonical = entry;
@@ -238,6 +261,102 @@ pub fn applyToDb(
try tx.commit();
}
const lift_saved_clients_sql: [:0]const u8 =
\\DROP TABLE IF EXISTS temp.saved_clients;
\\CREATE TEMP TABLE saved_clients AS
\\ SELECT ip, name, hand_edited, first_seen, last_seen FROM clients;
;
/// Carries what the wipe must not destroy over the wipe that follows: the
/// auto-materialised client rows themselves, and the observed timestamps of
/// every client row whatever its flag.
///
/// A `hand_edited = 0` row is runtime state, not configuration: the DNS path
/// wrote it from live traffic and `clients_repo.listClients` already keeps it out
/// of an export. Replacing the *configuration* must therefore not delete it —
/// but it cannot survive in place either, because `clients.group_id` references
/// `groups(id)` with no cascade, and the wipe empties `groups`. So the rows step
/// aside into the temp database and come back once `groups` holds `default`
/// again.
///
/// The table takes every row, not only the `hand_edited = 0` ones, because
/// `first_seen` and `last_seen` are runtime state on a configured row too — the
/// tracker keeps writing `last_seen` on the clients the operator named. Saving
/// only the materialised rows would keep the observation history of the devices
/// nobody named and destroy it for the devices somebody did. `hand_edited` rides
/// along so the restore can tell the two apart.
///
/// `IF EXISTS` because a rolled-back import must not poison the next one.
fn liftSavedClients(database: *db.Db) Error!void {
return database.exec(lift_saved_clients_sql);
}
const merge_observed_timestamps_sql: [:0]const u8 =
\\UPDATE clients AS c
\\ SET first_seen = m.first_seen, last_seen = m.last_seen
\\ FROM temp.saved_clients m
\\ WHERE m.ip = c.ip
;
const restore_materialised_clients_sql =
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
\\SELECT m.ip, m.name, ?1, 0, m.first_seen, m.last_seen
\\ FROM temp.saved_clients m
\\ WHERE m.hand_edited = 0
\\ AND NOT EXISTS (SELECT 1 FROM clients c WHERE c.ip = m.ip)
;
/// Puts back what `liftSavedClients` set aside, in two steps that must stay in
/// this order: the restore drops the temp table on its way out, so the merge
/// cannot follow it.
///
/// **The merge.** An address the config declares belongs to the config — but
/// `first_seen` and `last_seen` are not the config's to state. The model carries
/// neither field, so `insertClient` writes `now` into both as a placeholder for a
/// device it knows nothing about. When the database already held that address, the
/// placeholder is the worse of the two values and both columns come from the saved
/// row instead. Everything else on the row stays the config's: the name, the
/// group, and `hand_edited = 1`.
///
/// Not "the earlier `first_seen` and the later `last_seen`": the placeholder is
/// the wall clock at import time and every real observation predates it, so
/// "later" would resolve to the placeholder every time and stamp each named
/// device as seen at the moment of the import. An operator restoring a backup
/// would read that as liveness. `first_seen` and `last_seen` mean "when a query
/// from this address arrived", `pruneStale` and the API both read them that way,
/// and an import is not a query. Taking both from the saved row also keeps
/// `first_seen <= last_seen`, which `upsertSeen` guarantees pairwise.
///
/// Only the config's own clients are in the table at this point, so the update
/// needs no filter of its own, and the saved row's flag does not enter into it: a
/// device keeps its history whether the previous row was materialised or
/// configured. A `--force` re-import of the same file is the case that matters —
/// it deletes and rewrites every configured client, and without the merge each
/// one would come back claiming it was first seen at the moment of the import.
///
/// **The restore.** Only `hand_edited = 0` rows come back. A configured client is
/// configuration, so a file that leaves its address out has removed that client
/// and the row must stay gone; its history was saved for the merge, not for a
/// resurrection. `WHERE NOT EXISTS` rather than `INSERT OR IGNORE`: the only
/// materialised row worth skipping is one whose address the config claims — the
/// operator naming a device the server had already discovered, handled by the
/// merge above — and every other constraint failure stays loud.
///
/// The rows return to `default`, the group `upsertSeen` materialises into.
/// `first_seen` and `last_seen` cross unchanged; the row id does not, because the
/// config's clients went in first and hold the low ids now. Nothing references
/// `clients.id` inside `config.db`, and §3.6 keeps the query log out of it.
fn restoreSavedClients(database: *db.Db, default_group_id: i64) Error!void {
try database.exec(merge_observed_timestamps_sql);
{
var stmt = try database.prepare(restore_materialised_clients_sql);
defer stmt.deinit();
try stmt.bindInt(1, default_group_id);
try stmt.exec();
}
return database.exec("DROP TABLE temp.saved_clients;");
}
/// `default` goes in first and takes rowid 1. §11.2 seeds group 1 as `default`
/// and §7.2's fallback assignment depends on it; letting an import renumber it
/// would silently move every unassigned client.
@@ -303,7 +422,7 @@ fn canonicalPrefix(text: []const u8, buf: []u8) error{BadClientPrefix}![]const u
}
/// argon2id with the OWASP parameters (t=2, m=19 MiB, p=1) rather than the
/// 64 MiB `interactive_2id`, because PLAN §18 budgets under 100 MB total on a
/// 64 MiB `interactive_2id`, because PLAN §18 budgets under 100 MiB total on a
/// Pi 5.
///
/// `strHash`'s error set reaches beyond this module's (it carries
@@ -419,16 +538,45 @@ test "isEmpty is false once a settings row exists" {
try testing.expect(!try isEmpty(&database));
}
test "isEmpty is false once an auto-materialized client exists" {
test "isEmpty ignores the client rows live traffic materialises" {
var database = try openMigrated();
defer database.close();
try database.exec(
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES ('192.168.1.5', NULL, 1, 0, 1, 1);
// The real §7.2 write path, not hand-written SQL: what makes these rows
// ignorable is that `upsertSeen` is the thing that wrote them.
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
try clients_repo.upsertSeen(&database, "192.168.1.6", 1700000100);
try testing.expectEqual(@as(i64, 2), try clients_repo.countClients(&database));
try testing.expect(try isEmpty(&database));
}
test "isEmpty is false once a client carries operator intent" {
var database = try openMigrated();
defer database.close();
_ = try clients_repo.insertClientRow(
&database,
.{ .ip = "192.168.1.7", .name = "printer", .group_id = 1 },
1700000000,
);
try testing.expect(!try isEmpty(&database));
}
test "isEmpty is false once an operator edits a materialised client" {
var database = try openMigrated();
defer database.close();
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
try testing.expect(try isEmpty(&database));
// A PUT through the API is what turns a discovered device into policy, and
// that policy is exactly what a seed would replace.
const id = try database.queryInt("SELECT id FROM clients WHERE ip = '192.168.1.5'");
try clients_repo.updateClient(&database, id, .{ .name = "tv", .group_id = 1 });
try testing.expect(!try isEmpty(&database));
}
test "isEmpty is false once the seeded group is changed" {
var database = try openMigrated();
defer database.close();
@@ -459,6 +607,206 @@ test "importSource seeds a migrated database and group 'default' keeps id 1" {
);
}
test "an import keeps the materialised clients it found and lets the config claim an address" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
// Two devices the server discovered. `full_source` names the second one.
// The first is seen twice, so `first_seen` and `last_seen` differ and the
// assertions below cannot pass by carrying one column into both.
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000100);
try clients_repo.upsertSeen(&database, "fd00::1", 1700000200);
try importText(io, &database, full_source, .{});
try testing.expectEqual(@as(i64, 2), try clients_repo.countClients(&database));
// The device the config says nothing about keeps its flag, its group and
// both timestamps: a seed is not a reason to forget when a device appeared.
var stmt = try database.prepare(
"SELECT hand_edited, group_id, first_seen, last_seen FROM clients WHERE ip = '192.168.1.5'",
);
defer stmt.deinit();
try testing.expect(try stmt.step());
try testing.expectEqual(@as(i64, 0), stmt.columnInt(0));
try testing.expectEqual(@as(i64, 1), stmt.columnInt(1));
try testing.expectEqual(@as(i64, 1700000000), stmt.columnInt(2));
try testing.expectEqual(@as(i64, 1700000100), stmt.columnInt(3));
// The one the config names belongs to the config: named, in `kids`, and
// hand-edited, so a later prune leaves it alone.
try testing.expectEqual(@as(i64, 1), try database.queryInt(
\\SELECT count(*) FROM clients c JOIN groups g ON g.id = c.group_id
\\ WHERE c.ip = 'fd00::1' AND c.hand_edited = 1 AND c.name = 'tablet' AND g.name = 'kids'
));
}
test "the config claiming a discovered address keeps that device's observed timestamps" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
// The device is seen twice, so the two timestamps differ and neither
// assertion below can pass by carrying one column into the other.
// `full_source` names this address.
try clients_repo.upsertSeen(&database, "fd00::1", 1700000200);
try clients_repo.upsertSeen(&database, "fd00::1", 1700000900);
try importText(io, &database, full_source, .{});
var stmt = try database.prepare(
"SELECT hand_edited, name, first_seen, last_seen FROM clients WHERE ip = 'fd00::1'",
);
defer stmt.deinit();
try testing.expect(try stmt.step());
// The row is the config's: named, hand-edited.
try testing.expectEqual(@as(i64, 1), stmt.columnInt(0));
try testing.expectEqualStrings("tablet", stmt.columnText(1));
// The observation history is the tracker's. The import clock is `now`, so
// both columns would hold a value far above these if the import had written
// its own.
try testing.expectEqual(@as(i64, 1700000200), stmt.columnInt(2));
try testing.expectEqual(@as(i64, 1700000900), stmt.columnInt(3));
}
test "a failed import leaves the observed timestamps exactly as they were" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = testing.allocator;
var database = try openMigrated();
defer database.close();
// One address the config below claims, one it says nothing about.
try clients_repo.upsertSeen(&database, "fd00::1", 1700000200);
try clients_repo.upsertSeen(&database, "fd00::1", 1700000900);
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
const before = try dump(&database, gpa);
defer gpa.free(before);
// The clients go in, the timestamps merge, and then two identical local
// records violate `UNIQUE(name, rtype, value)`. Everything the import wrote
// must go with the transaction.
const broken: model.Config = .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.clients = &.{.{ .ip = "fd00::1", .name = "tablet" }},
.local_records = &.{
.{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" },
.{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" },
},
};
try testing.expectError(error.Constraint, applyToDb(io, gpa, &database, broken, 42, .{}));
const after = try dump(&database, gpa);
defer gpa.free(after);
try testing.expectEqualStrings(before, after);
}
test "a forced re-import replaces the configured clients and keeps the materialised ones" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
try importText(io, &database, full_source, .{});
try clients_repo.upsertSeen(&database, "10.0.0.9", 1700000200);
// The device the old file named keeps querying, so its row carries real
// observation history when the wipe reaches it.
try clients_repo.upsertSeen(&database, "fd00::1", 1700003000);
try importText(io, &database, minimal_source, .{ .force = true });
// `full_source`'s hand-edited client went with the rest of the old
// configuration; the discovered one did not.
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, 1700000200), try database.queryInt(
"SELECT first_seen FROM clients WHERE ip = '10.0.0.9' AND hand_edited = 0",
));
// The lift saves a configured client's history so the merge can hand it back
// to the same address. It must never become a reason to resurrect a client
// the new file leaves out: dropping a client from the file removes it.
try testing.expectEqual(@as(i64, 0), try database.queryInt(
"SELECT count(*) FROM clients WHERE ip = 'fd00::1'",
));
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
}
test "a forced re-import keeps the observed timestamps of a client the config names" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
// The device appears in traffic, the operator's file names it, and it keeps
// querying afterwards. The row is now `hand_edited = 1` and carries a real
// `first_seen` and a later `last_seen`.
try clients_repo.upsertSeen(&database, "fd00::1", 1700000200);
try importText(io, &database, full_source, .{});
try clients_repo.upsertSeen(&database, "fd00::1", 1700005000);
try importText(io, &database, full_source, .{ .force = true });
var stmt = try database.prepare(
"SELECT hand_edited, first_seen, last_seen FROM clients WHERE ip = 'fd00::1'",
);
defer stmt.deinit();
try testing.expect(try stmt.step());
try testing.expectEqual(@as(i64, 1), stmt.columnInt(0));
// Re-importing the same file is not an observation of the device.
try testing.expectEqual(@as(i64, 1700000200), stmt.columnInt(1));
try testing.expectEqual(@as(i64, 1700005000), stmt.columnInt(2));
}
test "a failed forced import leaves every client timestamp exactly as it was" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = testing.allocator;
var database = try openMigrated();
defer database.close();
// A configured client with observation history, and a materialised one.
try importText(io, &database, full_source, .{});
try clients_repo.upsertSeen(&database, "fd00::1", 1700005000);
try clients_repo.upsertSeen(&database, "10.0.0.9", 1700000200);
const before = try dump(&database, gpa);
defer gpa.free(before);
const broken: model.Config = .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.clients = &.{.{ .ip = "fd00::1", .name = "tablet" }},
.local_records = &.{
.{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" },
.{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" },
},
};
try testing.expectError(
error.Constraint,
applyToDb(io, gpa, &database, broken, 42, .{ .force = true }),
);
const after = try dump(&database, gpa);
defer gpa.free(after);
try testing.expectEqualStrings(before, after);
}
test "applyToDb without force refuses a configured database and changes nothing" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
+614 -88
View File
File diff suppressed because it is too large Load Diff