//! 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); }