//! Milestone-7 integration tests (spec S7): the serving pipeline end to end,
//! over real sockets.
//!
//! 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 case skips at run
//! time unless `-Dintegration` is passed.
//!
//! What separates these cases from `handler.zig`'s own tests is the socket. The
//! handler tests call `handle` directly; here every query travels through a real
//! `UdpServer` on 127.0.0.1, through the real handler with its real cache,
//! limiter, tracker and query log, and the reply is read back off the wire. The
//! upstream is a `transport.Client` fixture, except in the forward-zone case,
//! where the zone resolver has to be a real UDP socket because `ForwardClient`
//! speaks wire DNS to an address.
//!
//! Hermetic: every socket is bound to 127.0.0.1, every database is in memory or
//! inside a `std.testing.tmpDir`, and every wait carries a budget.

const std = @import("std");
const build_options = @import("build_options");
const net = std.Io.net;
const Allocator = std.mem.Allocator;

const app = @import("../app.zig");
const cli = @import("../cli.zig");
const clients = @import("clients.zig");
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const db = @import("../storage/db.zig");
const dns_cache = @import("../cache/dns_cache.zig");
const forward_zones = @import("../local/forward_zones.zig");
const handler = @import("handler.zig");
const header = @import("../dns/header.zig");
const local_tables = @import("local_tables.zig");
const logger_controller = @import("../storage/logger_controller.zig");
const logger_mod = @import("../storage/logger.zig");
const provenance = @import("../storage/provenance.zig");
const manager = @import("../filter/manager.zig");
const matcher = @import("../filter/matcher.zig");
const migrations = @import("../storage/migrations.zig");
const model = @import("../config/model.zig");
const name = @import("../dns/name.zig");
const packet = @import("../dns/packet.zig");
const pause = @import("pause.zig");
const query_sink = @import("query_sink.zig");
const question = @import("../dns/question.zig");
const rate_limiter = @import("rate_limiter.zig");
const record = @import("../dns/record.zig");
const records = @import("../local/records.zig");
const response = @import("../filter/response.zig");
const retention_mod = @import("../storage/retention.zig");
const shutdown = @import("shutdown.zig");
const transport = @import("../upstream/transport.zig");
const upstream_owner = @import("../upstream/owner.zig");
const types = @import("../dns/types.zig");
const udp_server = @import("udp_server.zig");

const testing = std.testing;

// ---------------------------------------------------------------------------
// shared fixtures
// ---------------------------------------------------------------------------

/// Long enough that a loopback round trip cannot lose to scheduling, short
/// enough that a broken server fails the run instead of hanging it.
const budget: std.Io.Timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } };

/// A five-second TTL makes the blocking answer's TTL unmistakable next to the
/// upstream's 300.
const blocking: response.Options = .{ .mode = .zero, .ttl = 5 };

const forward_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(2), .clock = .awake };

/// What every fake upstream answers with, and the TTL it carries.
const upstream_rdata = [4]u8{ 93, 184, 216, 34 };
const upstream_ttl: u32 = 300;

/// The zone resolver's answer, distinct from the pool's so a case can tell
/// which of the two replied.
const zone_rdata = [4]u8{ 10, 0, 0, 7 };
const zone_ttl: u32 = 120;

/// The handler every case starts from: an upstream, the blocking options and
/// the empty local tables. Each case wires in the collaborators it exercises.
fn baseHandler(up: *upstream_owner.Owner) handler.Handler {
    return .{
        .upstream = up,
        .policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout },
    };
}

/// A real listener, a real client socket and the task that serves them.
///
/// Two phases: `bind` produces the value, `start` spawns the serve task against
/// its final address. Nothing may copy a `Loop` after `start`, because the task
/// holds a pointer into it.
const Loop = struct {
    server: udp_server.UdpServer,
    group: std.Io.Group,
    client: net.Socket,
    server_address: net.IpAddress,

    fn bind(gpa: Allocator, io: std.Io, h: *handler.Handler) !Loop {
        const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
        var server = try udp_server.UdpServer.bind(gpa, io, listen_address, h, .{ .max_in_flight = 4 });
        errdefer server.deinit(gpa, io);

        const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
        const client = try client_address.bind(io, .{ .mode = .dgram });

        return .{
            .server = server,
            .group = .init,
            .client = client,
            .server_address = server.boundAddress(),
        };
    }

    fn start(self: *Loop, io: std.Io) !void {
        try self.group.concurrent(io, udp_server.UdpServer.serve, .{ &self.server, io });
    }

    /// One query, one reply. The reply is a prefix of `buf`.
    fn ask(self: *Loop, io: std.Io, query: []const u8, buf: []u8) ![]u8 {
        try self.client.send(io, &self.server_address, query);
        const msg = try self.client.receiveTimeout(io, buf, budget);
        return msg.data;
    }

    fn stop(self: *Loop, gpa: Allocator, io: std.Io) void {
        self.server.deinit(gpa, io);
        self.group.cancel(io);
        self.client.close(io);
    }
};

/// A query for `domain`, RD set, one question, no OPT.
fn queryFor(buf: []u8, id: u16, domain: []const u8, qtype: types.Type) []const u8 {
    var w: std.Io.Writer = .fixed(buf);
    var encoded: [types.header_len]u8 = undefined;
    header.encode(.{
        .id = id,
        .flags = .{
            .rcode = .no_error,
            .z = 0,
            .ra = false,
            .rd = true,
            .tc = false,
            .aa = false,
            .opcode = .query,
            .qr = false,
        },
        .qdcount = 1,
        .ancount = 0,
        .nscount = 0,
        .arcount = 0,
    }, &encoded);
    w.writeAll(&encoded) catch unreachable;
    question.encode(.{
        .name = name.fromText(domain) catch unreachable,
        .qtype = qtype,
        .qclass = .in,
    }, &w) catch unreachable;
    return w.buffered();
}

/// The pool stand-in. It answers the question it is given rather than a fixed
/// byte string, because the safe-search and uncloaking cases both change the
/// question on the way out.
///
/// `calls` is atomic: the listener task runs on another thread than the one
/// asserting.
const FakeUpstream = struct {
    reply: Reply,
    calls: std.atomic.Value(u64) = .init(0),

    const Reply = union(enum) {
        /// One A record for the queried name.
        a,
        /// One CNAME record for the queried name, pointing at this target.
        cname: []const u8,
    };

    fn exchangeFn(
        ptr: *anyopaque,
        io: std.Io,
        query: []const u8,
        response_buf: []u8,
        selected: *?[]const u8,
    ) transport.ExchangeError![]u8 {
        _ = io;
        selected.* = "fake://phase7-upstream";
        const self: *FakeUpstream = @ptrCast(@alignCast(ptr));
        _ = self.calls.fetchAdd(1, .monotonic);

        const request = packet.parse(query) catch return error.BadResponse;
        const q = packet.firstQuestion(request) orelse return error.BadResponse;

        var b = packet.ResponseBuilder.init(response_buf, request.header, q) catch
            return error.ResponseTooLarge;
        switch (self.reply) {
            .a => b.addAnswer(q.name, .a, .in, upstream_ttl, &upstream_rdata) catch
                return error.ResponseTooLarge,
            .cname => |target| {
                const t = name.fromText(target) catch return error.BadResponse;
                b.addAnswer(q.name, .cname, .in, upstream_ttl, t.wire()) catch
                    return error.ResponseTooLarge;
            },
        }
        return b.finish();
    }

    fn client(self: *FakeUpstream) transport.Client {
        return .{ .ptr = self, .exchangeFn = exchangeFn };
    }
};

/// The forward zone's resolver: a real UDP socket, because `ForwardClient`
/// speaks wire DNS to an address and nothing smaller would prove it did.
///
/// The loop ends when the receive is canceled, which is what `group.cancel`
/// does at the end of the case.
fn zoneResolver(io: std.Io, socket: *const net.Socket, calls: *std.atomic.Value(u64)) void {
    var buf: [udp_server.max_datagram]u8 = undefined;
    while (true) {
        const msg = socket.receive(io, &buf) catch return;
        _ = calls.fetchAdd(1, .monotonic);

        const request = packet.parse(msg.data) catch continue;
        const q = packet.firstQuestion(request) orelse continue;

        var reply_buf: [512]u8 = undefined;
        var b = packet.ResponseBuilder.init(&reply_buf, request.header, q) catch continue;
        b.addAnswer(q.name, .a, .in, zone_ttl, &zone_rdata) catch continue;
        socket.send(io, &msg.from, b.finish()) catch return;
    }
}

const SnapshotFixture = struct {
    groups: []const model.Group = &.{.{ .name = "default" }},
    rules: []const model.Rule = &.{},
};

fn buildSnapshot(gpa: Allocator, fixture: SnapshotFixture) !matcher.Snapshot {
    return matcher.Snapshot.build(gpa, .{
        .groups = fixture.groups,
        .group_ids = &.{1},
        .group_sources = &.{},
        .sources = &.{},
        .source_ids = &.{},
        .rules = fixture.rules,
        .clients = &.{},
        .prefixes = &.{},
        .compiled = &.{},
        .seed = 0x5eed,
        .generation = 1,
    });
}

/// `Manager.acquire` reads the manager's lock and its current snapshot and
/// nothing else, so a manager that publishes one hand-built snapshot needs
/// none of the database, fetcher or blocklist directory the real one owns.
fn fixtureManager(m: *manager.Manager, snapshot: *matcher.Snapshot) void {
    m.* = .{
        .gpa = testing.allocator,
        .database = undefined,
        .paths = undefined,
        .fetcher = undefined,
        .update = .{},
        .schedule_mutex = .init,
        .schedule_version = 0,
        .schedule_anchor_s = null,
        .schedule_event = .unset,
        .schedule_clock = .real,
        .total_budget = forward_timeout,
        .lock = .init,
        .writer_lock = .init,
        .refresh_lock = .init,
        .current = snapshot,
        .generation = 1,
        .statuses = &.{},
        .status_arena = .init(testing.allocator),
    };
}

fn blockRule(pattern: []const u8) model.Rule {
    return .{ .group = "default", .pattern = pattern, .kind = .exact, .action = .block };
}

fn allowRule(pattern: []const u8) model.Rule {
    return .{ .group = "default", .pattern = pattern, .kind = .exact, .action = .allow };
}

fn firstAnswer(p: packet.Packet) !record.Record {
    var it = packet.answers(p);
    return (try it.next()) orelse error.TestExpectedAnswer;
}

fn drainLog(lg: *logger_mod.Logger, io: std.Io, out: []logger_mod.Entry) []logger_mod.Entry {
    const n = lg.queue.getUncancelable(io, out, 0) catch 0;
    return out[0..n];
}

/// Every case that asserts on the query log wants the same shape: a queue big
/// enough to hold the whole case, drained once at the end.
const log_queue_len = 8;

// ---------------------------------------------------------------------------
// case 1: blocked domain
// ---------------------------------------------------------------------------

test "S7 case 1: a blocked domain is answered with the zero address and logged" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var snapshot = try buildSnapshot(gpa, .{ .rules = &.{blockRule("ads.example.com")} });
    defer snapshot.deinit();
    var mgr: manager.Manager = undefined;
    fixtureManager(&mgr, &snapshot);

    var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
    var lg: logger_mod.Logger = .init(.{}, &queue_buf);
    var log_owner: logger_controller.Borrowed = .{};
    var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null);

    var fake: FakeUpstream = .{ .reply = .a };
    var h_owner: upstream_owner.Borrowed = .{};
    var h = baseHandler(h_owner.client(fake.client()));
    h.manager = &mgr;
    h.sink = &sink;

    var loop = try Loop.bind(gpa, io, &h);
    defer loop.stop(gpa, io);
    try loop.start(io);

    var query_buf: [512]u8 = undefined;
    var reply_buf: [udp_server.max_datagram]u8 = undefined;
    const reply = try loop.ask(io, queryFor(&query_buf, 0x1111, "ads.example.com", .a), &reply_buf);

    const p = try packet.parse(reply);
    try testing.expectEqual(@as(u16, 0x1111), p.header.id);
    try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
    try testing.expectEqual(@as(u16, 1), p.header.ancount);

    const answer = try firstAnswer(p);
    try testing.expectEqual(@as(u32, blocking.ttl), answer.ttl);
    try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(p.bytes, answer));

    try testing.expectEqual(@as(u64, 0), fake.calls.load(.monotonic));
    try testing.expectEqual(@as(u64, 1), h.stats.blocked.load(.monotonic));

    var entries: [log_queue_len]logger_mod.Entry = undefined;
    const logged = drainLog(&lg, io, &entries);
    try testing.expectEqual(@as(usize, 1), logged.len);
    try testing.expectEqual(true, logged[0].blocked);
    try testing.expectEqualStrings("ads.example.com", logged[0].domain());
    try testing.expectEqual(provenance.PolicyAction.block, logged[0].policy_action);
    try testing.expectEqual(provenance.PolicyReason.rule_block_exact, logged[0].policy_reason);
    try testing.expectEqualStrings("127.0.0.1", logged[0].clientIp());
}

// ---------------------------------------------------------------------------
// case 2: allow over block
// ---------------------------------------------------------------------------

test "S7 case 2: an allow rule beats the blocklist and the upstream answers" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var snapshot = try buildSnapshot(gpa, .{
        .rules = &.{ blockRule("com"), allowRule("example.com") },
    });
    defer snapshot.deinit();
    var mgr: manager.Manager = undefined;
    fixtureManager(&mgr, &snapshot);

    var fake: FakeUpstream = .{ .reply = .a };
    var h_owner: upstream_owner.Borrowed = .{};
    var h = baseHandler(h_owner.client(fake.client()));
    h.manager = &mgr;

    var loop = try Loop.bind(gpa, io, &h);
    defer loop.stop(gpa, io);
    try loop.start(io);

    var query_buf: [512]u8 = undefined;
    var reply_buf: [udp_server.max_datagram]u8 = undefined;
    const reply = try loop.ask(io, queryFor(&query_buf, 0x2222, "example.com", .a), &reply_buf);

    const p = try packet.parse(reply);
    const answer = try firstAnswer(p);
    try testing.expectEqual(upstream_rdata, try record.rdataA(p.bytes, answer));
    try testing.expectEqual(upstream_ttl, answer.ttl);
    try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic));
    try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic));
}

// ---------------------------------------------------------------------------
// case 3: local records
// ---------------------------------------------------------------------------

test "S7 case 3: a local record answers authoritatively without an upstream" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var table = try records.Records.build(gpa, &.{
        .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 },
    });
    defer table.deinit(gpa);

    var fake: FakeUpstream = .{ .reply = .a };
    var h_owner: upstream_owner.Borrowed = .{};
    var h = baseHandler(h_owner.client(fake.client()));
    var tables: local_tables.LocalTables = .{ .records = table };
    h.local_tables = &tables;

    var loop = try Loop.bind(gpa, io, &h);
    defer loop.stop(gpa, io);
    try loop.start(io);

    var query_buf: [512]u8 = undefined;
    var reply_buf: [udp_server.max_datagram]u8 = undefined;
    const reply = try loop.ask(io, queryFor(&query_buf, 0x3333, "nas.lan", .a), &reply_buf);

    const p = try packet.parse(reply);
    try testing.expectEqual(true, p.header.flags.aa);
    try testing.expectEqual(@as(u16, 1), p.header.ancount);

    const answer = try firstAnswer(p);
    try testing.expectEqual(@as(u32, 60), answer.ttl);
    try testing.expectEqual([4]u8{ 192, 168, 1, 10 }, try record.rdataA(p.bytes, answer));

    try testing.expectEqual(@as(u64, 0), fake.calls.load(.monotonic));
    try testing.expectEqual(@as(u64, 1), h.stats.local_answers.load(.monotonic));
}

// ---------------------------------------------------------------------------
// case 4: forward zones
// ---------------------------------------------------------------------------

test "S7 case 4: a forward zone reaches its resolver, bypasses the blocklist and caches" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    // The zone resolver is a socket of its own, so the case can tell a query
    // that reached it from one the pool answered.
    const resolver_bind: net.IpAddress = try .parse("127.0.0.1", 0);
    const resolver_socket = try resolver_bind.bind(io, .{ .mode = .dgram });
    defer resolver_socket.close(io);

    var resolver_calls: std.atomic.Value(u64) = .init(0);
    var resolver_group: std.Io.Group = .init;
    defer resolver_group.cancel(io);
    try resolver_group.concurrent(io, zoneResolver, .{ io, &resolver_socket, &resolver_calls });

    var resolver_text: [64]u8 = undefined;
    const resolver_url = try std.fmt.bufPrint(&resolver_text, "udp://127.0.0.1:{d}", .{
        resolver_socket.address.ip4.port,
    });

    var zones = try forward_zones.Zones.build(gpa, &.{
        .{ .zone = "lan.home", .resolver = resolver_url },
    });
    defer zones.deinit(gpa);

    // The name is blocklisted, so an answer from the zone resolver is proof the
    // bypass (ruling 7) holds over the wire.
    var snapshot = try buildSnapshot(gpa, .{ .rules = &.{blockRule("nas.lan.home")} });
    defer snapshot.deinit();
    var mgr: manager.Manager = undefined;
    fixtureManager(&mgr, &snapshot);

    var cache: dns_cache.DnsCache = try .init(gpa, .{ .size = 8, .negative_ttl_max = 3600 });
    defer cache.deinit();

    var fake: FakeUpstream = .{ .reply = .a };
    var h_owner: upstream_owner.Borrowed = .{};
    var h = baseHandler(h_owner.client(fake.client()));
    h.manager = &mgr;
    var tables: local_tables.LocalTables = .{ .zones = zones };
    h.local_tables = &tables;
    h.cache = &cache;
    h.policy.negative_ttl_max = 3600;

    var loop = try Loop.bind(gpa, io, &h);
    defer loop.stop(gpa, io);
    try loop.start(io);

    var query_buf: [512]u8 = undefined;
    var reply_buf: [udp_server.max_datagram]u8 = undefined;
    const query = queryFor(&query_buf, 0x4444, "nas.lan.home", .a);

    const first = try loop.ask(io, query, &reply_buf);
    const p = try packet.parse(first);
    try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
    try testing.expectEqual(zone_rdata, try record.rdataA(p.bytes, try firstAnswer(p)));
    try testing.expectEqual(@as(u64, 1), resolver_calls.load(.monotonic));
    try testing.expectEqual(@as(u64, 0), fake.calls.load(.monotonic));
    try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic));
    try testing.expectEqual(@as(u32, 1), cache.len());

    // The second query is answered from the cache: the resolver socket sees
    // nothing more (PLAN §6.5).
    var second_buf: [512]u8 = undefined;
    const second_query = queryFor(&second_buf, 0x4455, "nas.lan.home", .a);
    const second = try loop.ask(io, second_query, &reply_buf);

    const second_p = try packet.parse(second);
    try testing.expectEqual(@as(u16, 0x4455), second_p.header.id);
    try testing.expectEqual(zone_rdata, try record.rdataA(second_p.bytes, try firstAnswer(second_p)));
    try testing.expectEqual(@as(u64, 1), resolver_calls.load(.monotonic));
    try testing.expectEqual(@as(u64, 1), h.stats.cache_hits.load(.monotonic));
}

// ---------------------------------------------------------------------------
// case 5: cache
// ---------------------------------------------------------------------------

test "S7 case 5: a cached answer comes back with a fresh id, an aged ttl and a logged hit" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var cache: dns_cache.DnsCache = try .init(gpa, .{ .size = 8, .negative_ttl_max = 3600 });
    defer cache.deinit();

    var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
    var lg: logger_mod.Logger = .init(.{}, &queue_buf);
    var log_owner: logger_controller.Borrowed = .{};
    var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null);

    var fake: FakeUpstream = .{ .reply = .a };
    var h_owner: upstream_owner.Borrowed = .{};
    var h = baseHandler(h_owner.client(fake.client()));
    h.cache = &cache;
    h.policy.negative_ttl_max = 3600;
    h.sink = &sink;

    var loop = try Loop.bind(gpa, io, &h);
    defer loop.stop(gpa, io);
    try loop.start(io);

    var reply_buf: [udp_server.max_datagram]u8 = undefined;

    // Miss, then hit under a different transaction ID.
    var miss_buf: [512]u8 = undefined;
    _ = try loop.ask(io, queryFor(&miss_buf, 0x5501, "example.com", .a), &reply_buf);
    try testing.expectEqual(@as(u32, 1), cache.len());
    try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic));

    var hit_buf: [512]u8 = undefined;
    const hit = try loop.ask(io, queryFor(&hit_buf, 0x5502, "example.com", .a), &reply_buf);
    const p = try packet.parse(hit);
    try testing.expectEqual(@as(u16, 0x5502), p.header.id);
    try testing.expectEqual(upstream_rdata, try record.rdataA(p.bytes, try firstAnswer(p)));
    try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic));
    try testing.expectEqual(@as(u64, 1), h.stats.cache_hits.load(.monotonic));

    // Ageing needs elapsed time, and a test cannot wait 10 seconds for it. The
    // entry is therefore planted with a stored-at stamp 10 seconds in the past,
    // under exactly the key the handler builds for this query.
    var aged_query_buf: [512]u8 = undefined;
    const aged_query = queryFor(&aged_query_buf, 0x5503, "aged.example.com", .a);
    var stored_buf: [512]u8 = undefined;
    const aged_p = try packet.parse(aged_query);
    var b = try packet.ResponseBuilder.init(&stored_buf, aged_p.header, packet.firstQuestion(aged_p).?);
    try b.addAnswer(try name.fromText("aged.example.com"), .a, .in, upstream_ttl, &upstream_rdata);

    var key_buf: [dns_cache.max_key_len]u8 = undefined;
    const key = dns_cache.buildKey(
        &key_buf,
        "aged.example.com",
        @intFromEnum(types.Type.a),
        @intFromEnum(types.Class.in),
        false,
        null,
    );
    const aged_by = 10;
    try cache.put(
        std.Io.Clock.real.now(io).toSeconds() - aged_by,
        key,
        b.finish(),
        .{ .ttl_seconds = upstream_ttl, .negative = false },
    );

    const aged = try loop.ask(io, aged_query, &reply_buf);
    const aged_reply = try packet.parse(aged);
    try testing.expectEqual(upstream_ttl - aged_by, (try firstAnswer(aged_reply)).ttl);
    try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic));

    var entries: [log_queue_len]logger_mod.Entry = undefined;
    const logged = drainLog(&lg, io, &entries);
    try testing.expectEqual(@as(usize, 3), logged.len);
    try testing.expectEqual(@as(?bool, false), logged[0].cache_hit);
    try testing.expectEqualStrings("fake://phase7-upstream", logged[0].upstream());
    try testing.expectEqual(@as(?bool, true), logged[1].cache_hit);
    try testing.expectEqualStrings("", logged[1].upstream());
    try testing.expectEqual(@as(?bool, true), logged[2].cache_hit);
}

// ---------------------------------------------------------------------------
// case 6: CNAME uncloaking
// ---------------------------------------------------------------------------

test "S7 case 6: a cname into a blocked target blocks the original question" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var snapshot = try buildSnapshot(gpa, .{ .rules = &.{blockRule("tracker.example.org")} });
    defer snapshot.deinit();
    var mgr: manager.Manager = undefined;
    fixtureManager(&mgr, &snapshot);

    var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
    var lg: logger_mod.Logger = .init(.{}, &queue_buf);
    var log_owner: logger_controller.Borrowed = .{};
    var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null);

    var fake: FakeUpstream = .{ .reply = .{ .cname = "tracker.example.org" } };
    var h_owner: upstream_owner.Borrowed = .{};
    var h = baseHandler(h_owner.client(fake.client()));
    h.manager = &mgr;
    h.sink = &sink;

    var loop = try Loop.bind(gpa, io, &h);
    defer loop.stop(gpa, io);
    try loop.start(io);

    var query_buf: [512]u8 = undefined;
    var reply_buf: [udp_server.max_datagram]u8 = undefined;
    const reply = try loop.ask(io, queryFor(&query_buf, 0x6666, "cdn.example.com", .a), &reply_buf);

    const p = try packet.parse(reply);
    try testing.expectEqual(@as(u16, 1), p.header.ancount);

    // The answer is about the name the client asked for, not the target.
    const answer = try firstAnswer(p);
    try testing.expectEqual(types.Type.a, answer.rtype);
    try testing.expectEqualSlices(
        u8,
        (try name.fromText("cdn.example.com")).wire(),
        answer.name.wire(),
    );
    try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(p.bytes, answer));
    try testing.expectEqual(@as(u64, 1), h.stats.uncloak_blocked.load(.monotonic));

    var entries: [log_queue_len]logger_mod.Entry = undefined;
    const logged = drainLog(&lg, io, &entries);
    try testing.expectEqual(@as(usize, 1), logged.len);
    try testing.expectEqual(true, logged[0].blocked);
    // The reason describes the target's own decision; milestone 28 S3 adds the
    // target name that says a CNAME chain was followed.
    try testing.expectEqual(provenance.PolicyReason.rule_block_exact, logged[0].policy_reason);
    try testing.expectEqualStrings("cdn.example.com", logged[0].domain());
}

// ---------------------------------------------------------------------------
// case 7: safe search
// ---------------------------------------------------------------------------

test "S7 case 7: safe search answers the original question with a cname to the target" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var snapshot = try buildSnapshot(gpa, .{
        .groups = &.{.{ .name = "default", .safe_search = true }},
    });
    defer snapshot.deinit();
    var mgr: manager.Manager = undefined;
    fixtureManager(&mgr, &snapshot);

    var fake: FakeUpstream = .{ .reply = .a };
    var h_owner: upstream_owner.Borrowed = .{};
    var h = baseHandler(h_owner.client(fake.client()));
    h.manager = &mgr;

    var loop = try Loop.bind(gpa, io, &h);
    defer loop.stop(gpa, io);
    try loop.start(io);

    var query_buf: [512]u8 = undefined;
    var reply_buf: [udp_server.max_datagram]u8 = undefined;
    const reply = try loop.ask(io, queryFor(&query_buf, 0x7777, "www.google.com", .a), &reply_buf);

    const p = try packet.parse(reply);
    const target = try name.fromText("forcesafesearch.google.com");

    // The reply keeps the question the client asked.
    try testing.expectEqualSlices(
        u8,
        (try name.fromText("www.google.com")).wire(),
        packet.firstQuestion(p).?.name.wire(),
    );
    try testing.expectEqual(@as(u16, 2), p.header.ancount);

    var it = packet.answers(p);
    const cname = (try it.next()).?;
    try testing.expectEqual(types.Type.cname, cname.rtype);
    try testing.expectEqualSlices(u8, target.wire(), (try record.rdataCname(p.bytes, cname)).wire());

    const a = (try it.next()).?;
    try testing.expectEqual(types.Type.a, a.rtype);
    try testing.expectEqualSlices(u8, target.wire(), a.name.wire());
    try testing.expectEqual(upstream_rdata, try record.rdataA(p.bytes, a));

    try testing.expectEqual(@as(u64, 1), h.stats.safesearch_rewrites.load(.monotonic));
}

// ---------------------------------------------------------------------------
// case 8: rate limit
// ---------------------------------------------------------------------------

test "S7 case 8: the third query inside the window is refused" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var limiter: rate_limiter.RateLimiter = try .init(gpa, .{ .limit = 2, .window_seconds = 60 });
    defer limiter.deinit();

    var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
    var lg: logger_mod.Logger = .init(.{}, &queue_buf);
    var log_owner: logger_controller.Borrowed = .{};
    var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null);

    var fake: FakeUpstream = .{ .reply = .a };
    var h_owner: upstream_owner.Borrowed = .{};
    var h = baseHandler(h_owner.client(fake.client()));
    h.limiter = &limiter;
    h.sink = &sink;

    var loop = try Loop.bind(gpa, io, &h);
    defer loop.stop(gpa, io);
    try loop.start(io);

    var reply_buf: [udp_server.max_datagram]u8 = undefined;
    for ([_]u16{ 0x8801, 0x8802 }) |id| {
        var query_buf: [512]u8 = undefined;
        const reply = try loop.ask(io, queryFor(&query_buf, id, "example.com", .a), &reply_buf);
        try testing.expectEqual(types.Rcode.no_error, (try packet.parse(reply)).header.flags.rcode);
    }

    var third_buf: [512]u8 = undefined;
    const refused = try loop.ask(io, queryFor(&third_buf, 0x8803, "example.com", .a), &reply_buf);
    const p = try packet.parse(refused);
    try testing.expectEqual(types.Rcode.refused, p.header.flags.rcode);
    try testing.expectEqual(@as(u16, 0x8803), p.header.id);
    try testing.expectEqual(@as(u64, 1), h.stats.refused.load(.monotonic));
    try testing.expectEqual(@as(u64, 2), fake.calls.load(.monotonic));

    // Ruling 8: a refused query is never query-logged.
    var entries: [log_queue_len]logger_mod.Entry = undefined;
    try testing.expectEqual(@as(usize, 2), drainLog(&lg, io, &entries).len);
}

// ---------------------------------------------------------------------------
// case 9: pause
// ---------------------------------------------------------------------------

test "S7 case 9: pause lifts filtering and unpause restores it" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var snapshot = try buildSnapshot(gpa, .{ .rules = &.{blockRule("ads.example.com")} });
    defer snapshot.deinit();
    var mgr: manager.Manager = undefined;
    fixtureManager(&mgr, &snapshot);

    var paused: pause.Pause = .{};
    paused.pauseFor(std.Io.Clock.real.now(io).toSeconds(), null);

    var fake: FakeUpstream = .{ .reply = .a };
    var h_owner: upstream_owner.Borrowed = .{};
    var h = baseHandler(h_owner.client(fake.client()));
    h.manager = &mgr;
    h.pause = &paused;

    var loop = try Loop.bind(gpa, io, &h);
    defer loop.stop(gpa, io);
    try loop.start(io);

    var reply_buf: [udp_server.max_datagram]u8 = undefined;

    var paused_buf: [512]u8 = undefined;
    const while_paused = try loop.ask(io, queryFor(&paused_buf, 0x9901, "ads.example.com", .a), &reply_buf);
    const p = try packet.parse(while_paused);
    try testing.expectEqual(upstream_rdata, try record.rdataA(p.bytes, try firstAnswer(p)));
    try testing.expectEqual(@as(u64, 1), h.stats.paused_queries.load(.monotonic));
    try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic));

    // Resuming puts the block back without a restart (ruling 18).
    paused.unpause();

    var resumed_buf: [512]u8 = undefined;
    const after = try loop.ask(io, queryFor(&resumed_buf, 0x9902, "ads.example.com", .a), &reply_buf);
    const after_p = try packet.parse(after);
    try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(after_p.bytes, try firstAnswer(after_p)));
    try testing.expectEqual(@as(u64, 1), h.stats.blocked.load(.monotonic));
}

// ---------------------------------------------------------------------------
// case 10: client tracking
// ---------------------------------------------------------------------------

test "S7 case 10: the querying client is materialised as a row" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    // The tracker's own connection (ruling 21), in memory here: the flush is
    // what this case asserts on, not where the file lives.
    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();
    try db.applyPragmas(&database, .{});
    _ = try migrations.migrate(&database);

    var retention_days: retention_mod.RetentionDays = .init(30);
    var tracker: clients.Tracker = .init(&retention_days);

    var fake: FakeUpstream = .{ .reply = .a };
    var h_owner: upstream_owner.Borrowed = .{};
    var h = baseHandler(h_owner.client(fake.client()));
    h.tracker = &tracker;

    var loop = try Loop.bind(gpa, io, &h);
    defer loop.stop(gpa, io);
    try loop.start(io);

    var reply_buf: [udp_server.max_datagram]u8 = undefined;
    for ([_]u16{ 0xa001, 0xa002 }) |id| {
        var query_buf: [512]u8 = undefined;
        _ = try loop.ask(io, queryFor(&query_buf, id, "example.com", .a), &reply_buf);
    }

    // Two queries from one client are one pending entry, and the forced pass
    // stands in for the 60-second flush interval (S4 As-built seam).
    try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
    tracker.flushOnce(io, &database, true, null);

    try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
    try testing.expectEqual(@as(u32, 0), tracker.pendingClients(io));
    try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).flushed);

    var stmt = try database.prepare("SELECT ip, hand_edited FROM clients");
    defer stmt.deinit();
    try testing.expect(try stmt.step());
    try testing.expectEqualStrings("127.0.0.1", stmt.columnText(0));
    try testing.expectEqual(@as(i64, 0), stmt.columnInt(1));
}

// ---------------------------------------------------------------------------
// case 11: the whole application
// ---------------------------------------------------------------------------

/// `std.testing.tmpDir` creates its directory against `std.testing.io`, so the
/// application under test runs on the same `Io` instance the fixture used. The
/// other cases build an `Io.Threaded` of their own, the way the listener tests
/// do; this one cannot, because the temporary directory is already bound to
/// this instance.
const test_io = testing.io;

/// 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;

/// High enough to need no privilege, and not the 15353/15354 pair the milestone
/// smoke test used, so a stray smoke process cannot make this case pass.
const app_port = 15455;

/// The unreachable upstream the seed configuration names. Nothing in this case
/// needs it: the query it resolves is a local record, and a dead upstream is
/// what proves the fail-open design still serves.
const dead_upstream = "https://127.0.0.1:9/dns-query";

const app_config =
    \\.{
    \\    .dns = .{
    \\        .bind_ipv4 = "127.0.0.1",
    \\        .bind_ipv6 = "::1",
    \\        .port = 15455,
    \\        .rate_limit = 1000,
    \\        .rate_window_seconds = 60,
    \\    },
    \\    .logging = .{ .level = .info, .output = .stderr },
    \\    .web = .{ .enabled = false },
    \\    .groups = .{ .{ .name = "default" } },
    \\    .upstreams = .{ .{ .url = "https://127.0.0.1:9/dns-query" } },
    \\    .local_records = .{
    \\        .{ .name = "boot.test", .rtype = .a, .value = "10.9.8.7", .ttl = 60 },
    \\    },
    \\}
    \\
;

comptime {
    // The port and the upstream appear in the configuration text as literals,
    // because a `.zon` file is data and not a format string.
    std.debug.assert(std.mem.containsAtLeast(u8, app_config, 1, std.fmt.comptimePrint("{d}", .{app_port})));
    std.debug.assert(std.mem.containsAtLeast(u8, app_config, 1, dead_upstream));
}

/// How long one attempt at reaching the booting server waits, and how many
/// attempts it gets. The product is the time the application has to bind.
const boot_attempt: std.Io.Timeout = .{ .duration = .{ .raw = .fromMilliseconds(200), .clock = .awake } };
const boot_attempts = 100;

/// Queries the booting server until it answers. A server that has not bound yet
/// either swallows the datagram or answers it with an ICMP rejection, and both
/// arrive here as an error worth retrying.
fn askUntilAnswered(
    socket: *const net.Socket,
    dest: net.IpAddress,
    query: []const u8,
    buf: []u8,
) ![]u8 {
    var attempt: usize = 0;
    while (attempt < boot_attempts) : (attempt += 1) {
        socket.send(test_io, &dest, query) catch continue;
        const msg = socket.receiveTimeout(test_io, buf, boot_attempt) catch continue;
        return msg.data;
    }
    return error.TestAppNeverAnswered;
}

test "S7 case 11: the app boots, serves a query and exits zero on shutdown" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;

    var tmp = testing.tmpDir(.{ .iterate = true });
    defer tmp.cleanup();
    try tmp.dir.writeFile(test_io, .{ .sub_path = "config.zon", .data = app_config });

    var root_buf: [tmp_prefix.len + sub_path_len]u8 = undefined;
    @memcpy(root_buf[0..tmp_prefix.len], tmp_prefix);
    @memcpy(root_buf[tmp_prefix.len..], &tmp.sub_path);
    const root: []const u8 = &root_buf;

    var config_buf: [root_buf.len + "/config.zon".len]u8 = undefined;
    const config_path = try std.fmt.bufPrint(&config_buf, "{s}/config.zon", .{root});

    var out: std.Io.Writer.Allocating = .init(gpa);
    defer out.deinit();
    var err: std.Io.Writer.Allocating = .init(gpa);
    defer err.deinit();

    const runner: cli.Runner = .{
        .io = test_io,
        .gpa = gpa,
        .out = &out.writer,
        .err = &err.writer,
    };

    // The shutdown event is process-global, and another case in this binary may
    // have left it set.
    shutdown.reset();
    defer shutdown.reset();

    // `--config` present: the file is authority and the database is converged
    // onto it before the listeners bind (milestone-20 ruling 1).
    var future = try test_io.concurrent(app.run, .{ runner, cli.RunArgs{
        .paths = .{ .data_dir = root },
        .config = config_path,
    } });

    const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
    const client = try client_address.bind(test_io, .{ .mode = .dgram });
    defer client.close(test_io);

    const server_address: net.IpAddress = try .parse("127.0.0.1", app_port);

    var query_buf: [512]u8 = undefined;
    const query = queryFor(&query_buf, 0xb001, "boot.test", .a);
    var reply_buf: [udp_server.max_datagram]u8 = undefined;

    const reply = askUntilAnswered(&client, server_address, query, &reply_buf) catch |e| {
        shutdown.trigger(test_io);
        _ = future.await(test_io);
        return e;
    };

    const p = try packet.parse(reply);
    try testing.expectEqual(@as(u16, 0xb001), p.header.id);
    try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
    try testing.expectEqual(true, p.header.flags.aa);
    try testing.expectEqual([4]u8{ 10, 9, 8, 7 }, try record.rdataA(p.bytes, try firstAnswer(p)));

    shutdown.trigger(test_io);
    try testing.expectEqual(cli.exit_ok, future.await(test_io));

    // The lifecycle proof is the exit code, and a clean exit prints nothing.
    try testing.expectEqualStrings("", err.written());
}
