953 lines
33 KiB
Zig
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);
|
|
}
|