Files
nxdns/src/storage/storage_integration_test.zig
T

953 lines
33 KiB
Zig

//! Milestone-4 storage integration tests (spec S7): real directories, real
//! database files, real exports.
//!
//! 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 test skips at run
//! time unless `-Dintegration` is passed. Case 22 additionally needs `-Dlive`.
//!
//! Hermetic: every case works inside one `std.testing.tmpDir`, and no case opens
//! a socket or resolves a name. Case 22 is the single exception and it is
//! guarded separately.
//!
//! Two mechanisms resolve the same paths here. `std.Io.Dir` calls go through the
//! temporary directory handle, while SQLite resolves its filenames through its
//! own VFS, which knows nothing about directory handles. Every path handed to
//! the database layer is therefore built relative to the process working
//! directory, which is what `Fixture.root` is for.
const std = @import("std");
const builtin = @import("builtin");
const build_options = @import("build_options");
const Writer = std.Io.Writer;
const cli = @import("../cli.zig");
const config_export = @import("../config/export.zig");
const import = @import("../config/import.zig");
const model = @import("../config/model.zig");
const validate = @import("../config/validate.zig");
const db = @import("db.zig");
const migrations = @import("migrations.zig");
const querylog_schema = @import("querylog_schema.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.
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 file_limit: std.Io.Limit = .limited(8 * 1024 * 1024);
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 write(self: *const Fixture, name: []const u8, data: []const u8) !void {
return self.tmp.dir.writeFile(io, .{ .sub_path = name, .data = data });
}
fn read(self: *const Fixture, name: []const u8) ![]u8 {
return self.tmp.dir.readFileAlloc(io, name, testing.allocator, file_limit);
}
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;
}
};
/// An open, migrated `config.db` inside the fixture, reached exactly the way the
/// CLI reaches it.
const Data = struct {
dir: cli.DataDir,
database: db.Db,
fn deinit(self: *Data) void {
self.database.close();
self.dir.close(io, testing.allocator);
}
};
fn openDataDir(f: *const Fixture, name: []const u8) !cli.DataDir {
var buf: [path_buf_len]u8 = undefined;
const dir_path = try f.path(&buf, name);
return cli.DataDir.open(io, testing.allocator, dir_path, true);
}
fn openMigrated(f: *const Fixture, name: []const u8) !Data {
var dir = try openDataDir(f, name);
errdefer dir.close(io, testing.allocator);
var database = try dir.openConfigDb(io);
errdefer database.close();
_ = try migrations.migrate(&database);
return .{ .dir = dir, .database = database };
}
fn importInto(f: *const Fixture, data: *Data, file: []const u8, allow_delete: bool) !void {
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
return import.importFile(
io,
testing.allocator,
&data.database,
f.tmp.dir,
file,
.{ .allow_delete = allow_delete },
&diags,
);
}
fn expectMode(f: *const Fixture, name: []const u8, expected: std.posix.mode_t) !void {
const stat = try f.tmp.dir.statFile(io, name, .{});
const mode = stat.permissions.toMode() & 0o777;
if (mode != expected) {
std.debug.print("mode of '{s}' is {o}, expected {o}\n", .{ name, mode, expected });
return error.TestUnexpectedResult;
}
}
/// Running as root defeats a permission test: root bypasses the mode bits, the
/// open succeeds and the case proves nothing. Skipping is honest; asserting
/// would be a false pass.
fn runningAsRoot() bool {
return switch (builtin.os.tag) {
.linux => std.os.linux.geteuid() == 0,
else => false,
};
}
const Captured = struct {
out: Writer.Allocating,
err: Writer.Allocating,
fn init() Captured {
return .{ .out = .init(testing.allocator), .err = .init(testing.allocator) };
}
fn deinit(self: *Captured) void {
self.out.deinit();
self.err.deinit();
}
fn runner(self: *Captured) cli.Runner {
return .{
.io = io,
.gpa = testing.allocator,
.out = &self.out.writer,
.err = &self.err.writer,
};
}
};
fn countLines(text: []const u8) usize {
return std.mem.count(u8, text, "\n");
}
// ---------------------------------------------------------------------------
// querylog aside files
// ---------------------------------------------------------------------------
const aside_prefix = "querylog.db.";
const Names = struct {
items: std.ArrayList([]u8),
fn deinit(self: *Names) void {
for (self.items.items) |name| testing.allocator.free(name);
self.items.deinit(testing.allocator);
}
};
fn collectAsides(f: *const Fixture) !Names {
var names: Names = .{ .items = .empty };
errdefer names.deinit();
var it = f.tmp.dir.iterate();
while (try it.next(io)) |entry| {
if (!std.mem.startsWith(u8, entry.name, aside_prefix)) continue;
try names.items.append(testing.allocator, try testing.allocator.dupe(u8, entry.name));
}
return names;
}
/// Writes `value` into `PRAGMA user_version` without touching anything else, so
/// the file stays a healthy database that merely carries the wrong fingerprint.
fn stampUserVersion(path: [:0]const u8, value: i32) !void {
var database = try db.Db.open(path, .{ .mode = .read_write_existing });
defer database.close();
var buf: [64]u8 = undefined;
const sql = try std.fmt.bufPrintZ(&buf, "PRAGMA user_version = {d};", .{value});
try database.exec(sql);
}
fn createQuerylog(f: *const Fixture) !void {
var buf: [path_buf_len]u8 = undefined;
const path = try f.pathZ(&buf, "querylog.db");
var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
result.database.close();
try testing.expectEqual(querylog_schema.RecreateReason.missing, result.recreated.?);
}
/// Holds `querylog.db` locked against every other connection, the way a second
/// nxdns process running on the same data directory would.
///
/// `BEGIN EXCLUSIVE` on its own does not do this. The file is in WAL mode, where
/// one writer and any number of readers coexist by design, so the probing `open`
/// would read straight past it. `PRAGMA locking_mode = EXCLUSIVE`, set before
/// this connection touches the file, makes SQLite take an exclusive lock on the
/// file itself and keep it until the connection closes. The holder writes
/// nothing, so the bytes on disk are unchanged for the case to compare against.
const LockHolder = struct {
database: db.Db,
held: bool,
fn take(path: [:0]const u8) !LockHolder {
var database = try db.Db.open(path, .{ .mode = .read_write_existing });
errdefer database.close();
try database.exec("PRAGMA locking_mode = EXCLUSIVE;");
try database.exec("BEGIN EXCLUSIVE;");
return .{ .database = database, .held = true };
}
/// Idempotent, so the case can `defer` it and still release early. A failed
/// ROLLBACK is not worth reporting here: closing the connection drops the
/// lock either way, which is the only thing this function owes the case.
fn release(self: *LockHolder) void {
if (!self.held) return;
self.held = false;
self.database.exec("ROLLBACK;") catch {};
self.database.close();
}
};
// ---------------------------------------------------------------------------
// configuration fixtures
// ---------------------------------------------------------------------------
/// The smallest configuration that validates.
const minimal_config =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\}
;
/// A second valid configuration, distinguishable from `minimal_config` by one
/// query on `upstreams`.
const other_config =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://other.example/dns-query" } },
\\}
;
/// Exercises every collection and several non-default scalars, so the byte-
/// stable round trip has something to be stable about.
const rich_config =
\\.{
\\ .dns = .{ .port = 5353 },
\\ .logging = .{ .level = .err, .retention_days = 7 },
\\ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaGhhc2g" },
\\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } },
\\ .upstreams = .{
\\ .{ .url = "https://dns.example/dns-query", .priority = 10 },
\\ .{ .url = "tls://192.0.2.53:853", .priority = 20, .enabled = false },
\\ },
\\ .clients = .{ .{ .ip = "fd00::1", .name = "tablet", .group = "kids" } },
\\ .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } },
\\ .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "ads" } },
\\ .group_sources = .{ .{ .group = "kids", .source_url = "https://lists.example/ads.txt" } },
\\ .rules = .{
\\ .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block },
\\ .{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow },
\\ },
\\ .local_records = .{
\\ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 600 },
\\ .{ .name = "nas.lan", .rtype = .aaaa, .value = "fd00::10" },
\\ },
\\ .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.1:53" } },
\\}
;
/// Valid ZON, two validation problems: `dns.port` is 0 and `blocking.ttl`
/// exceeds a day.
const two_problem_config =
\\.{
\\ .dns = .{ .port = 0 },
\\ .blocking = .{ .ttl = 90000 },
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\}
;
/// The same two problems as a `Config`, for the seeding path that skips the
/// validator.
const two_problem_model: model.Config = .{
.dns = .{ .port = 0 },
.blocking = .{ .ttl = 90000 },
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
};
const broken_zon = ".{ .groups = ";
const export_header_line = "// nxdns configuration\n";
// ---------------------------------------------------------------------------
// case 1-7 and 23: the querylog recreate policy
// ---------------------------------------------------------------------------
test "S7 case 1: querylog open on a fresh directory creates the schema" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
var buf: [path_buf_len]u8 = undefined;
const path = try f.pathZ(&buf, "querylog.db");
var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
defer result.database.close();
try testing.expectEqual(querylog_schema.RecreateReason.missing, result.recreated.?);
try testing.expectEqual(
@as(i64, querylog_schema.fingerprint),
try result.database.queryInt("PRAGMA user_version"),
);
try testing.expectEqual(
@as(i64, 1),
try result.database.queryInt("SELECT count(*) FROM sqlite_schema WHERE name = 'domains'"),
);
try testing.expectEqual(
@as(i64, 1),
try result.database.queryInt("SELECT count(*) FROM sqlite_schema WHERE name = 'query_log'"),
);
}
test "S7 case 2: reopening a healthy querylog recreates nothing" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try createQuerylog(&f);
var buf: [path_buf_len]u8 = undefined;
const path = try f.pathZ(&buf, "querylog.db");
var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
defer result.database.close();
try testing.expectEqual(@as(?querylog_schema.RecreateReason, null), result.recreated);
var asides = try collectAsides(&f);
defer asides.deinit();
try testing.expectEqual(@as(usize, 0), asides.items.items.len);
}
test "S7 case 3: a wrong user_version recreates and keeps the old file aside" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try createQuerylog(&f);
var buf: [path_buf_len]u8 = undefined;
const path = try f.pathZ(&buf, "querylog.db");
try stampUserVersion(path, querylog_schema.fingerprint +% 1);
const original = try f.read("querylog.db");
defer testing.allocator.free(original);
var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
defer result.database.close();
try testing.expectEqual(
querylog_schema.RecreateReason.fingerprint_mismatch,
result.recreated.?,
);
var asides = try collectAsides(&f);
defer asides.deinit();
try testing.expectEqual(@as(usize, 1), asides.items.items.len);
// The file was healthy: this build's schema moved, the database did not rot.
// An operator who reads "corrupt" here deletes a file that was never broken.
try testing.expect(std.mem.startsWith(u8, asides.items.items[0], "querylog.db.schema-changed-"));
const kept = try f.read(asides.items.items[0]);
defer testing.allocator.free(kept);
try testing.expectEqualSlices(u8, original, kept);
}
test "S7 case 4: a garbage file recreates and the garbage is preserved" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try createQuerylog(&f);
var garbage: [4096]u8 = undefined;
@memset(&garbage, 0xab);
try f.write("querylog.db", &garbage);
var buf: [path_buf_len]u8 = undefined;
const path = try f.pathZ(&buf, "querylog.db");
var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
defer result.database.close();
const reason = result.recreated.?;
try testing.expect(reason == .not_a_database or reason == .corrupt);
var asides = try collectAsides(&f);
defer asides.deinit();
try testing.expectEqual(@as(usize, 1), asides.items.items.len);
const expected: []const u8 = if (reason == .corrupt)
"querylog.db.corrupt-"
else
"querylog.db.not-a-database-";
try testing.expect(std.mem.startsWith(u8, asides.items.items[0], expected));
const kept = try f.read(asides.items.items[0]);
defer testing.allocator.free(kept);
try testing.expectEqualSlices(u8, &garbage, kept);
}
test "S7 case 5: two recreates in the same second produce two distinct aside files" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try createQuerylog(&f);
var buf: [path_buf_len]u8 = undefined;
const path = try f.pathZ(&buf, "querylog.db");
var round: usize = 0;
while (round < 2) : (round += 1) {
try stampUserVersion(path, querylog_schema.fingerprint +% 1);
var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
defer result.database.close();
try testing.expectEqual(
querylog_schema.RecreateReason.fingerprint_mismatch,
result.recreated.?,
);
}
var asides = try collectAsides(&f);
defer asides.deinit();
try testing.expectEqual(@as(usize, 2), asides.items.items.len);
try testing.expect(!std.mem.eql(u8, asides.items.items[0], asides.items.items[1]));
}
test "S7 case 6: a stale write-ahead log is removed before the fresh database is created" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try createQuerylog(&f);
var buf: [path_buf_len]u8 = undefined;
const path = try f.pathZ(&buf, "querylog.db");
try stampUserVersion(path, querylog_schema.fingerprint +% 1);
// Existence alone proves nothing: the fresh database turns WAL on again and
// writes its own `-wal`. The marker is what distinguishes the stale file
// from the new one.
const marker = "NXDNS-STALE-WAL-MARKER";
try f.write("querylog.db-wal", marker ** 16);
var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
defer result.database.close();
try testing.expectEqual(
querylog_schema.RecreateReason.fingerprint_mismatch,
result.recreated.?,
);
if (try f.exists("querylog.db-wal")) {
const wal = try f.read("querylog.db-wal");
defer testing.allocator.free(wal);
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, wal, marker));
}
}
test "S7 case 7: an unreadable querylog propagates the error and is never destroyed" {
if (!build_options.integration) return error.SkipZigTest;
if (runningAsRoot()) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try createQuerylog(&f);
const original = try f.read("querylog.db");
defer testing.allocator.free(original);
var buf: [path_buf_len]u8 = undefined;
const path = try f.pathZ(&buf, "querylog.db");
try f.tmp.dir.setFilePermissions(io, "querylog.db", .fromMode(0o000), .{});
const result = querylog_schema.open(io, std.Io.Dir.cwd(), path);
try f.tmp.dir.setFilePermissions(io, "querylog.db", .fromMode(0o600), .{});
try testing.expectError(error.CantOpen, result);
const after = try f.read("querylog.db");
defer testing.allocator.free(after);
try testing.expectEqualSlices(u8, original, after);
var asides = try collectAsides(&f);
defer asides.deinit();
try testing.expectEqual(@as(usize, 0), asides.items.items.len);
}
test "S7 case 23: a locked querylog propagates Busy and is never destroyed" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try createQuerylog(&f);
const original = try f.read("querylog.db");
defer testing.allocator.free(original);
var buf: [path_buf_len]u8 = undefined;
const path = try f.pathZ(&buf, "querylog.db");
var holder = try LockHolder.take(path);
defer holder.release();
// The conflict surfaces inside `applyPragmas`, on `PRAGMA journal_mode =
// WAL`. `querylog_schema.open` takes `db.OpenOptions`' default 5000 ms busy
// timeout, so the call waits the timeout out before it reports the
// conflict. Waiting is the behaviour under test, and shortening it would
// mean adding a timeout knob to production code for the test's benefit, so
// this case costs about five seconds.
if (querylog_schema.open(io, std.Io.Dir.cwd(), path)) |result| {
var opened = result;
opened.database.close();
return error.TestUnexpectedResult;
} else |e| {
try testing.expect(e == error.Busy or e == error.Locked);
}
try testing.expect(try f.exists("querylog.db"));
const after = try f.read("querylog.db");
defer testing.allocator.free(after);
try testing.expectEqualSlices(u8, original, after);
var asides = try collectAsides(&f);
defer asides.deinit();
try testing.expectEqual(@as(usize, 0), asides.items.items.len);
// The same file, once the lock is gone, is opened without a recreate: the
// failure above was transient and left nothing behind that would force one.
holder.release();
var reopened = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
defer reopened.database.close();
try testing.expectEqual(@as(?querylog_schema.RecreateReason, null), reopened.recreated);
try testing.expectEqual(
@as(i64, querylog_schema.fingerprint),
try reopened.database.queryInt("PRAGMA user_version"),
);
var asides_after = try collectAsides(&f);
defer asides_after.deinit();
try testing.expectEqual(@as(usize, 0), asides_after.items.items.len);
}
// ---------------------------------------------------------------------------
// case 8-10: config.db, permissions and the schema stamp
// ---------------------------------------------------------------------------
test "S7 case 8: DataDir.open creates the data directory at mode 0700" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
var data = try openDataDir(&f, "data");
defer data.close(io, testing.allocator);
try expectMode(&f, "data", 0o700);
}
test "S7 case 9: config.db and its write-ahead log are mode 0600" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
var data = try openMigrated(&f, "data");
defer data.deinit();
try expectMode(&f, "data/config.db", 0o600);
if (try f.exists("data/config.db-wal")) {
try expectMode(&f, "data/config.db-wal", 0o600);
}
}
test "S7 case 10: a config.db stamped one version ahead is refused and left alone" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
{
var data = try openMigrated(&f, "data");
defer data.deinit();
var stmt = try data.database.prepare("UPDATE schema_version SET version = ?1");
defer stmt.deinit();
try stmt.bindInt(1, @as(i64, migrations.target_version) + 1);
try stmt.exec();
}
const before = try f.read("data/config.db");
defer testing.allocator.free(before);
{
var dir = try openDataDir(&f, "data");
defer dir.close(io, testing.allocator);
var database = try dir.openConfigDb(io);
defer database.close();
try testing.expectError(error.SchemaTooNew, migrations.migrate(&database));
}
const after = try f.read("data/config.db");
defer testing.allocator.free(after);
try testing.expectEqualSlices(u8, before, after);
}
// ---------------------------------------------------------------------------
// case 11-19: export and import on real files
// ---------------------------------------------------------------------------
test "S7 case 11: an exported file is mode 0600 and starts with the header comment" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try f.write("config.zon", rich_config);
var data = try openMigrated(&f, "data");
defer data.deinit();
try importInto(&f, &data, "config.zon", false);
try config_export.writeToFile(io, testing.allocator, &data.database, f.tmp.dir, "out.zon");
try expectMode(&f, "out.zon", 0o600);
const text = try f.read("out.zon");
defer testing.allocator.free(text);
try testing.expect(std.mem.startsWith(u8, text, export_header_line));
}
test "S7 case 12: export, import and export again are byte-identical files" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try f.write("config.zon", rich_config);
{
var first = try openMigrated(&f, "one");
defer first.deinit();
try importInto(&f, &first, "config.zon", false);
try config_export.writeToFile(io, testing.allocator, &first.database, f.tmp.dir, "a.zon");
}
{
var second = try openMigrated(&f, "two");
defer second.deinit();
try importInto(&f, &second, "a.zon", false);
try config_export.writeToFile(io, testing.allocator, &second.database, f.tmp.dir, "b.zon");
}
const a = try f.read("a.zon");
defer testing.allocator.free(a);
const b = try f.read("b.zon");
defer testing.allocator.free(b);
try testing.expectEqualStrings(a, b);
}
test "S7 case 13: import refuses a diff that deletes rows unless --allow-delete is given" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try f.write("first.zon", minimal_config);
try f.write("second.zon", other_config);
var data = try openMigrated(&f, "data");
defer data.deinit();
try importInto(&f, &data, "first.zon", false);
// The two files name different upstream urls, and a url is the upstream's
// identity: applying the second deletes the first's row, which is what the
// gate exists to stop.
try testing.expectError(error.DestructiveImport, importInto(&f, &data, "second.zon", false));
try testing.expectEqual(
@as(i64, 1),
try data.database.queryInt(
"SELECT count(*) FROM upstreams WHERE url = 'https://dns.example/dns-query'",
),
);
try importInto(&f, &data, "second.zon", true);
try testing.expectEqual(
@as(i64, 1),
try data.database.queryInt(
"SELECT count(*) FROM upstreams WHERE url = 'https://other.example/dns-query'",
),
);
try testing.expectEqual(@as(i64, 1), try data.database.queryInt("SELECT count(*) FROM upstreams"));
}
test "S7 case 14: an invalid import reports every problem and writes nothing" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try f.write("config.zon", two_problem_config);
var data = try openMigrated(&f, "data");
defer data.deinit();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
if (import.importFile(
io,
testing.allocator,
&data.database,
f.tmp.dir,
"config.zon",
.{ .allow_delete = false },
&diags,
)) |_| {
return error.TestUnexpectedResult;
} else |_| {}
try testing.expectEqual(@as(usize, 2), diags.problems.items.len);
try testing.expectEqual(@as(i64, 0), try data.database.queryInt("SELECT count(*) FROM upstreams"));
try testing.expectEqual(@as(i64, 0), try data.database.queryInt("SELECT count(*) FROM settings"));
// Nothing beyond the database and its sidecars was created.
var dir = try f.tmp.dir.openDir(io, "data", .{ .iterate = true });
defer dir.close(io);
var it = dir.iterate();
while (try it.next(io)) |entry| {
try testing.expect(std.mem.startsWith(u8, entry.name, cli.config_db_name));
}
}
test "S7 case 19: writeToFile replaces an existing file and restores mode 0600" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try f.write("config.zon", rich_config);
var data = try openMigrated(&f, "data");
defer data.deinit();
try importInto(&f, &data, "config.zon", false);
const stale = "stale content that must not survive\n";
try f.write("out.zon", stale);
try config_export.writeToFile(io, testing.allocator, &data.database, f.tmp.dir, "out.zon");
const text = try f.read("out.zon");
defer testing.allocator.free(text);
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, text, stale));
try testing.expect(std.mem.startsWith(u8, text, export_header_line));
try expectMode(&f, "out.zon", 0o600);
}
// ---------------------------------------------------------------------------
// case 20-21: the CLI entry functions end to end
// ---------------------------------------------------------------------------
test "S7 case 20: runImport and runExport reproduce the byte-stable round trip" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try f.write("config.zon", rich_config);
var captured: Captured = .init();
defer captured.deinit();
const r = captured.runner();
var one_buf: [path_buf_len]u8 = undefined;
const one = try f.path(&one_buf, "one");
var two_buf: [path_buf_len]u8 = undefined;
const two = try f.path(&two_buf, "two");
var config_buf: [path_buf_len]u8 = undefined;
const config_path = try f.path(&config_buf, "config.zon");
var a_buf: [path_buf_len]u8 = undefined;
const a_path = try f.path(&a_buf, "a.zon");
var b_buf: [path_buf_len]u8 = undefined;
const b_path = try f.path(&b_buf, "b.zon");
try testing.expectEqual(
cli.exit_ok,
cli.runImport(r, .{ .paths = .{ .data_dir = one }, .file = config_path }),
);
try testing.expectEqual(
cli.exit_ok,
cli.runExport(r, .{ .paths = .{ .data_dir = one }, .out = a_path }),
);
try testing.expectEqual(
cli.exit_ok,
cli.runImport(r, .{ .paths = .{ .data_dir = two }, .file = a_path }),
);
try testing.expectEqual(
cli.exit_ok,
cli.runExport(r, .{ .paths = .{ .data_dir = two }, .out = b_path }),
);
try testing.expectEqualStrings("", captured.err.written());
const a = try f.read("a.zon");
defer testing.allocator.free(a);
const b = try f.read("b.zon");
defer testing.allocator.free(b);
try testing.expectEqualStrings(a, b);
try expectMode(&f, "a.zon", 0o600);
try expectMode(&f, "one", 0o700);
}
test "S7 case 21: runCheck passes a seeded database and reports two stored problems" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try f.write("config.zon", rich_config);
var good_buf: [path_buf_len]u8 = undefined;
const good = try f.path(&good_buf, "good");
var bad_buf: [path_buf_len]u8 = undefined;
const bad = try f.path(&bad_buf, "bad");
{
var data = try openMigrated(&f, "good");
defer data.deinit();
try importInto(&f, &data, "config.zon", false);
}
{
// `apply` rather than an import: the validator would refuse this
// configuration, and the case needs the problems to reach the database.
var data = try openMigrated(&f, "bad");
defer data.deinit();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
try import.apply(io, testing.allocator, &data.database, two_problem_model, 42, .{}, &diags);
}
{
var captured: Captured = .init();
defer captured.deinit();
try testing.expectEqual(
cli.exit_ok,
cli.runCheck(captured.runner(), .{ .paths = .{ .data_dir = good } }, false),
);
try testing.expect(std.mem.count(u8, captured.out.written(), "OK: no problems found") == 1);
}
{
var captured: Captured = .init();
defer captured.deinit();
try testing.expectEqual(
cli.exit_check,
cli.runCheck(captured.runner(), .{ .paths = .{ .data_dir = bad } }, false),
);
const text = captured.out.written();
// One "checking database …" line plus one line per problem.
try testing.expectEqual(@as(usize, 3), countLines(text));
try testing.expect(std.mem.count(u8, text, "dns.port:") == 1);
try testing.expect(std.mem.count(u8, text, "blocking.ttl:") == 1);
}
}
// ---------------------------------------------------------------------------
// case 22: live
// ---------------------------------------------------------------------------
// Leaves the machine, so it is `-Dlive` only. A failure here is an environment
// finding, not a gate (the milestone-1 and milestone-3 convention).
test "S7 case 22: runCheck probes a real upstream and prints an OK line" {
if (!build_options.live) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try f.write("config.zon",
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
\\}
);
var config_buf: [path_buf_len]u8 = undefined;
const config_path = try f.path(&config_buf, "config.zon");
var captured: Captured = .init();
defer captured.deinit();
const code = cli.runCheck(
captured.runner(),
.{ .config = config_path },
true,
);
try testing.expectEqual(cli.exit_ok, code);
// Milestone 13 changed the probe line to the redacted `OK upstreams[i]`
// form; this expectation went stale unnoticed because nothing ran -Dlive
// between then and milestone 20.
try testing.expect(std.mem.count(
u8,
captured.out.written(),
"OK upstreams[0] https://cloudflare-dns.com\n",
) == 1);
}