Files
nxdns/src/storage/repositories/clients_repo.zig
T

961 lines
38 KiB
Zig

//! `clients` and `client_prefixes`.
//!
//! `listClients` returns only `hand_edited = 1` rows. A client the server
//! materialised from live traffic is runtime state, not configuration, and must
//! not appear in an export. `hand_edited` is the only marker of operator intent
//! in this table, so it also decides what the reconcile engine may delete: a
//! declared row the file drops is removed, an observed row is kept whatever the
//! file says, and declaring an observed address promotes that row in place.
//! `countClients` counts **all** rows and is a test helper.
//!
//! The configuration path is list / insert / update / delete / count, plus the
//! two runtime calls `upsertSeen` and `pruneStale` that `server/clients.zig`'s
//! tracker owns.
//! The REST surface is the third section: it speaks row ids and shows
//! every client, materialised ones included.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const crud = @import("crud.zig");
const IdMap = context.IdMap;
const InsertContext = context.InsertContext;
// ---------------------------------------------------------------------------
// clients
// ---------------------------------------------------------------------------
const list_clients_sql =
\\SELECT c.ip, c.name, g.name FROM clients c
\\ JOIN groups g ON g.id = c.group_id
\\ WHERE c.hand_edited = 1
\\ ORDER BY c.ip
;
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listClients(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Client) {
return crud.listRows(model.Client, database, gpa, list_clients_sql, readClient);
}
fn readClient(stmt: *db.Stmt, gpa: Allocator) db.Error!model.Client {
const ip = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(ip);
// `clients.name` is nullable; `columnTextAlloc` reads NULL as "", which is
// exactly the model's default.
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
const group = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(group);
return .{ .ip = ip, .name = name, .group = group };
}
pub fn freeClients(gpa: Allocator, items: []const model.Client) void {
crud.freeRows(model.Client, gpa, items);
}
const insert_client_sql =
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES (?1, ?2, ?3, 1, ?4, ?4)
;
/// `hand_edited` is 1: a client that reached a repository through the config
/// model came from an operator's file, by definition.
pub fn insertClient(database: *db.Db, item: model.Client, ctx: InsertContext) db.Error!void {
const group_id = try ctx.groupId(item.group);
var stmt = try database.prepare(insert_client_sql);
defer stmt.deinit();
try stmt.bindText(1, item.ip);
try stmt.bindText(2, item.name);
try stmt.bindInt(3, group_id);
try stmt.bindInt(4, ctx.now);
try stmt.exec();
}
const upsert_seen_sql =
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES (?1, NULL, (SELECT id FROM groups WHERE name = 'default'), 0, ?2, ?2)
\\ON CONFLICT(ip) DO UPDATE SET last_seen = excluded.last_seen
;
/// Materialises a client seen in live traffic (PLAN §7.2), or touches
/// `last_seen` on the row that already holds `ip`.
///
/// The conflict target is `clients.ip`, which the schema declares UNIQUE. Only
/// `last_seen` is updated: `name`, `group_id` and `hand_edited` are the
/// operator's, and a device that keeps querying must not overwrite them. A
/// hand-edited row is touched too, so the operator sees liveness for the
/// clients they named.
///
/// A new row lands in the `default` group. `groupForClient` resolves the real
/// group from the prefix rules at query time, so the column here only decides
/// what the operator sees before they assign the device themselves.
///
/// A database with no group named `default` fails the insert with
/// `error.Constraint` rather than writing a dangling row. `validate.zig`
/// rejects such a configuration long before a server serves from it.
pub fn upsertSeen(database: *db.Db, ip: []const u8, now_s: i64) db.Error!void {
var stmt = try database.prepare(upsert_seen_sql);
defer stmt.deinit();
try stmt.bindText(1, ip);
try stmt.bindInt(2, now_s);
try stmt.exec();
}
/// Deletes the auto-materialised clients whose last query predates `cutoff_s`,
/// and returns how many rows went. `hand_edited = 1` rows are configuration and
/// survive any silence.
///
/// `last_seen` is the only signal, because §3.6 forbids joining `config.db`
/// against the query log.
pub fn pruneStale(database: *db.Db, cutoff_s: i64) db.Error!u32 {
var stmt = try database.prepare("DELETE FROM clients WHERE hand_edited = 0 AND last_seen < ?1");
defer stmt.deinit();
try stmt.bindInt(1, cutoff_s);
try stmt.exec();
const deleted = database.changes();
return @intCast(@min(deleted, std.math.maxInt(u32)));
}
pub fn deleteAllClients(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM clients;");
}
/// Counts every row, including the materialised ones `listClients` filters out.
/// Used by tests.
pub fn countClients(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM clients");
}
// ---------------------------------------------------------------------------
// client_prefixes
// ---------------------------------------------------------------------------
const list_client_prefixes_sql =
\\SELECT p.prefix, g.name, p.priority FROM client_prefixes p
\\ JOIN groups g ON g.id = p.group_id
\\ ORDER BY p.prefix
;
pub fn listClientPrefixes(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ClientPrefix) {
return crud.listRows(model.ClientPrefix, database, gpa, list_client_prefixes_sql, readClientPrefix);
}
fn readClientPrefix(stmt: *db.Stmt, gpa: Allocator) db.Error!model.ClientPrefix {
const prefix = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(prefix);
const group = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(group);
// The column is a 64-bit integer; the model field is `i32`. A value outside
// that range means something other than nxdns wrote the row.
const priority = std.math.cast(i32, stmt.columnInt(2)) orelse return error.Mismatch;
return .{ .prefix = prefix, .group = group, .priority = priority };
}
pub fn freeClientPrefixes(gpa: Allocator, items: []const model.ClientPrefix) void {
crud.freeRows(model.ClientPrefix, gpa, items);
}
pub fn insertClientPrefix(database: *db.Db, item: model.ClientPrefix, ctx: InsertContext) db.Error!void {
const group_id = try ctx.groupId(item.group);
var stmt = try database.prepare("INSERT INTO client_prefixes (prefix, group_id, priority) VALUES (?1, ?2, ?3)");
defer stmt.deinit();
try stmt.bindText(1, item.prefix);
try stmt.bindInt(2, group_id);
try stmt.bindInt(3, item.priority);
try stmt.exec();
}
pub fn deleteAllClientPrefixes(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM client_prefixes;");
}
pub fn countClientPrefixes(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM client_prefixes");
}
// ---------------------------------------------------------------------------
// REST surface (milestone 8)
// ---------------------------------------------------------------------------
//
// `/api/clients` shows every row — a device the server materialised from live
// traffic is exactly what the operator wants to name — so these reads carry no
// `hand_edited` filter and report the flag instead. The write shapes take
// `group_id`, not a group name: the REST layer identifies every resource by row
// id, and a `group_id` no group holds must surface as the foreign-key violation
// it is.
pub const ClientRow = struct {
id: i64,
ip: []const u8,
/// `clients.name` is nullable; a NULL reads as `""`, as it does on the
/// import path.
name: []const u8,
group_id: i64,
group: []const u8,
hand_edited: bool,
first_seen: i64,
last_seen: i64,
};
/// What creating a client by hand needs. `first_seen` and `last_seen` are the
/// caller's clock, so this shape does not carry them.
pub const ClientInput = struct {
ip: []const u8,
name: []const u8 = "",
group_id: i64,
};
/// What editing a client may change (ruling 9). `ip` is absent on purpose: it is
/// the identity live traffic matches a row by, and rewriting it would collide
/// with the row the tracker materialises for the device that still holds it.
pub const ClientEdit = struct {
name: []const u8 = "",
group_id: i64,
};
const list_client_rows_sql =
\\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen
\\ FROM clients c
\\ JOIN groups g ON g.id = c.group_id
\\ ORDER BY c.ip
;
const get_client_sql =
\\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen
\\ FROM clients c
\\ JOIN groups g ON g.id = c.group_id
\\ WHERE c.id = ?1
;
/// Every client, materialised ones included. Every string is a heap copy owned
/// by `gpa`.
pub fn listClientRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientRow) {
return crud.listRows(ClientRow, database, gpa, list_client_rows_sql, readClientRow);
}
pub fn freeClientRow(gpa: Allocator, row: ClientRow) void {
crud.freeRow(ClientRow, gpa, row);
}
pub fn freeClientRows(gpa: Allocator, items: []const ClientRow) void {
crud.freeRows(ClientRow, gpa, items);
}
pub fn getClient(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ClientRow {
var stmt = try database.prepare(get_client_sql);
defer stmt.deinit();
try stmt.bindInt(1, id);
if (!try stmt.step()) return null;
return try readClientRow(&stmt, gpa);
}
fn readClientRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ClientRow {
const ip = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(ip);
const name = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(name);
const group = try stmt.columnTextAlloc(gpa, 4);
errdefer gpa.free(group);
return .{
.id = stmt.columnInt(0),
.ip = ip,
.name = name,
.group_id = stmt.columnInt(3),
.group = group,
.hand_edited = stmt.columnBool(5),
.first_seen = stmt.columnInt(6),
.last_seen = stmt.columnInt(7),
};
}
const insert_client_row_sql =
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES (?1, ?2, ?3, 1, ?4, ?4)
;
/// Creates a client the operator typed, so `hand_edited` is 1 — the difference
/// from `upsertSeen`, which materialises what the DNS path saw and never claims
/// a row is configuration.
///
/// `now_s` is unix epoch seconds, from `std.Io.Clock.real`; it seeds both
/// timestamps, exactly as `insertClient` does on the import path.
///
/// `error.Constraint`: `clients.ip` is UNIQUE, or `group_id` names no group.
pub fn insertClientRow(database: *db.Db, item: ClientInput, now_s: i64) db.Error!i64 {
var stmt = try database.prepare(insert_client_row_sql);
defer stmt.deinit();
try stmt.bindText(1, item.ip);
try stmt.bindText(2, item.name);
try stmt.bindInt(3, item.group_id);
try stmt.bindInt(4, now_s);
try stmt.exec();
return database.lastInsertRowid();
}
/// An edit is what makes a client configuration, so this sets `hand_edited` to
/// 1 on every call (ruling 9) and `pruneStale` stops considering the row.
/// `first_seen` and `last_seen` stay the tracker's.
///
/// `error.NotFound`: no client holds `id`. `error.Constraint`: `group_id` names
/// no group.
pub fn updateClient(database: *db.Db, id: i64, item: ClientEdit) db.Error!void {
var stmt = try database.prepare(
"UPDATE clients SET name = ?2, group_id = ?3, hand_edited = 1 WHERE id = ?1",
);
defer stmt.deinit();
try stmt.bindInt(1, id);
try stmt.bindText(2, item.name);
try stmt.bindInt(3, item.group_id);
return crud.execStrict(database, &stmt);
}
/// `error.NotFound`: no client holds `id`. Nothing references `clients`, so a
/// delete cannot violate a constraint — and a device that keeps querying
/// re-materialises through `upsertSeen`.
pub fn deleteClient(database: *db.Db, id: i64) db.Error!void {
var stmt = try database.prepare("DELETE FROM clients WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
return crud.execStrict(database, &stmt);
}
pub const ClientPrefixRow = struct {
id: i64,
prefix: []const u8,
group_id: i64,
group: []const u8,
priority: i32,
};
pub const ClientPrefixInput = struct {
prefix: []const u8,
group_id: i64,
priority: i32 = 100,
};
const list_client_prefix_rows_sql =
\\SELECT p.id, p.prefix, p.group_id, g.name, p.priority FROM client_prefixes p
\\ JOIN groups g ON g.id = p.group_id
\\ ORDER BY p.prefix
;
/// Same order as `listClientPrefixes`; every string is a heap copy owned by
/// `gpa`.
pub fn listClientPrefixRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientPrefixRow) {
return crud.listRows(ClientPrefixRow, database, gpa, list_client_prefix_rows_sql, readClientPrefixRow);
}
fn readClientPrefixRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ClientPrefixRow {
const prefix = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(prefix);
const group = try stmt.columnTextAlloc(gpa, 3);
errdefer gpa.free(group);
// The column is a 64-bit integer; the row field is `i32`. A value outside
// that range means something other than nxdns wrote the row.
const priority = std.math.cast(i32, stmt.columnInt(4)) orelse return error.Mismatch;
return .{
.id = stmt.columnInt(0),
.prefix = prefix,
.group_id = stmt.columnInt(2),
.group = group,
.priority = priority,
};
}
pub fn freeClientPrefixRow(gpa: Allocator, row: ClientPrefixRow) void {
crud.freeRow(ClientPrefixRow, gpa, row);
}
pub fn freeClientPrefixRows(gpa: Allocator, items: []const ClientPrefixRow) void {
crud.freeRows(ClientPrefixRow, gpa, items);
}
/// Replaces the whole prefix table inside a transaction (ruling 9 makes
/// `/api/client-prefixes` one atomic list resource). Row ids do not survive the
/// call: every row is written fresh.
///
/// `error.Constraint`: `client_prefixes.prefix` is UNIQUE, so a prefix repeated
/// in `items` is rejected rather than collapsed — two rows for one prefix with
/// different groups or priorities is a contradiction, not a set. Also fires when
/// a `group_id` names no group. Either way the old table survives untouched.
pub fn replaceClientPrefixes(database: *db.Db, items: []const ClientPrefixInput) db.Error!void {
var tx = try db.Tx.begin(database);
errdefer tx.rollback();
try deleteAllClientPrefixes(database);
var stmt = try database.prepare(
"INSERT INTO client_prefixes (prefix, group_id, priority) VALUES (?1, ?2, ?3)",
);
defer stmt.deinit();
for (items) |item| {
// `reset` clears the bindings too, so all three are bound again on
// every pass.
try stmt.reset();
try stmt.bindText(1, item.prefix);
try stmt.bindInt(2, item.group_id);
try stmt.bindInt(3, item.priority);
try stmt.exec();
}
try tx.commit();
}
// ---------------------------------------------------------------------------
// reconcile surface (milestone 20)
// ---------------------------------------------------------------------------
//
// `replaceClientPrefixes` above is the REST list resource: one atomic swap of
// the whole table, in a transaction of its own. The reconcile engine cannot use
// it — it runs inside a transaction already, and rewriting every row would
// forfeit the row ids and the zero-writes property the engine exists for — so
// it edits and removes prefixes one at a time instead.
/// Writes the two columns a prefix row carries besides its identity.
///
/// `error.NotFound`: no prefix holds `id`. `error.Constraint`:
/// `client_prefixes.prefix` is UNIQUE, or `group_id` names no group.
pub fn updateClientPrefix(database: *db.Db, id: i64, item: ClientPrefixInput) db.Error!void {
var stmt = try database.prepare(
"UPDATE client_prefixes SET prefix = ?2, group_id = ?3, priority = ?4 WHERE id = ?1",
);
defer stmt.deinit();
try stmt.bindInt(1, id);
try stmt.bindText(2, item.prefix);
try stmt.bindInt(3, item.group_id);
try stmt.bindInt(4, item.priority);
return crud.execStrict(database, &stmt);
}
/// `error.NotFound`: no prefix holds `id`. Nothing references
/// `client_prefixes`, so a delete cannot violate a constraint.
pub fn deleteClientPrefix(database: *db.Db, id: i64) db.Error!void {
var stmt = try database.prepare("DELETE FROM client_prefixes WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
return crud.execStrict(database, &stmt);
}
/// Moves the observed clients of one group to another, and reports how many
/// rows moved.
///
/// `clients.group_id` references `groups(id)` with no `ON DELETE` action
/// (config_schema.zig:26), so a group that any client still sits in cannot be
/// deleted. When a configuration stops declaring a group, its *declared*
/// clients go with it, but the devices the DNS path materialised into it did
/// not come from the configuration and must not be deleted for a decision that
/// was never about them. They move to the default group, which is also the
/// semantics the operator asked for: they un-declared the group, not the
/// devices.
///
/// `hand_edited = 1` rows are untouched — those are configuration, and the
/// reconcile engine has already accounted for them.
pub fn reassignObservedClients(database: *db.Db, from_group_id: i64, to_group_id: i64) db.Error!u32 {
var stmt = try database.prepare(
"UPDATE clients SET group_id = ?2 WHERE hand_edited = 0 AND group_id = ?1",
);
defer stmt.deinit();
try stmt.bindInt(1, from_group_id);
try stmt.bindInt(2, to_group_id);
try stmt.exec();
const moved = database.changes();
return @intCast(@min(moved, std.math.maxInt(u32)));
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
/// Migration step 1 seeds `(1, 'default')`; `kids` is added here so the join
/// has two distinct groups to resolve.
fn seedGroups(database: *db.Db) !IdMap {
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
var ids: IdMap = .empty;
errdefer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
try ids.put(testing.allocator, "kids", 2);
return ids;
}
fn seedClients(database: *db.Db, ids: *const IdMap) !void {
const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids };
try insertClient(database, .{ .ip = "192.168.1.20", .name = "laptop", .group = "kids" }, ctx);
try insertClient(database, .{ .ip = "192.168.1.10", .name = "desk" }, ctx);
try insertClient(database, .{ .ip = "fd00::1", .group = "kids" }, ctx);
}
fn seedClientPrefixes(database: *db.Db, ids: *const IdMap) !void {
const ctx: InsertContext = .{ .group_ids = ids };
try insertClientPrefix(database, .{ .prefix = "192.168.2.0/24", .group = "kids", .priority = 10 }, ctx);
try insertClientPrefix(database, .{ .prefix = "192.168.1.0/24", .priority = 50 }, ctx);
try insertClientPrefix(database, .{ .prefix = "fd00::/48", .group = "kids" }, ctx);
}
test "clients round-trip in ip order with group names resolved" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClients(&database, &ids);
var items = try listClients(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeClients(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("192.168.1.10", items.items[0].ip);
try testing.expectEqualStrings("desk", items.items[0].name);
try testing.expectEqualStrings("default", items.items[0].group);
try testing.expectEqualStrings("192.168.1.20", items.items[1].ip);
try testing.expectEqualStrings("laptop", items.items[1].name);
try testing.expectEqualStrings("kids", items.items[1].group);
try testing.expectEqualStrings("fd00::1", items.items[2].ip);
try testing.expectEqualStrings("", items.items[2].name);
try testing.expectEqualStrings("kids", items.items[2].group);
try testing.expectEqual(
@as(i64, 1700000000),
try database.queryInt("SELECT first_seen FROM clients WHERE ip = '192.168.1.10'"),
);
try testing.expectEqual(
@as(i64, 1700000000),
try database.queryInt("SELECT last_seen FROM clients WHERE ip = '192.168.1.10'"),
);
}
test "a hand_edited = 0 client is absent from listClients but counted by countClients" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClients(&database, &ids);
try database.exec(
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES ('10.0.0.5', 'auto', 1, 0, 1, 1);
);
try testing.expectEqual(@as(i64, 4), try countClients(&database));
var items = try listClients(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeClients(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
for (items.items) |item| {
try testing.expect(!std.mem.eql(u8, item.ip, "10.0.0.5"));
}
}
test "deleteAllClients empties the table and countClients reflects it" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClients(&database, &ids);
try testing.expectEqual(@as(i64, 3), try countClients(&database));
try deleteAllClients(&database);
try testing.expectEqual(@as(i64, 0), try countClients(&database));
}
test "insertClient reports a group the caller's map does not hold" {
var database = try openMigrated();
defer database.close();
const ctx: InsertContext = .{};
try testing.expectError(
error.NotFound,
insertClient(&database, .{ .ip = "192.168.1.1" }, ctx),
);
}
fn listClientsUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
var database = try openMigrated();
defer database.close();
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
try seedClients(&database, ids);
var items = try listClients(&database, gpa);
defer items.deinit(gpa);
defer freeClients(gpa, items.items);
}
test "listClients is leak-safe under allocation failure" {
var ids: IdMap = .empty;
defer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
try ids.put(testing.allocator, "kids", 2);
try testing.checkAllAllocationFailures(testing.allocator, listClientsUnderFailure, .{&ids});
}
fn seenRow(database: *db.Db, ip: []const u8) !struct { hand_edited: i64, first_seen: i64, last_seen: i64, group_id: i64 } {
var stmt = try database.prepare(
"SELECT hand_edited, first_seen, last_seen, group_id FROM clients WHERE ip = ?1",
);
defer stmt.deinit();
try stmt.bindText(1, ip);
try testing.expect(try stmt.step());
return .{
.hand_edited = stmt.columnInt(0),
.first_seen = stmt.columnInt(1),
.last_seen = stmt.columnInt(2),
.group_id = stmt.columnInt(3),
};
}
test "upsertSeen materialises an unseen client in the default group" {
var database = try openMigrated();
defer database.close();
try upsertSeen(&database, "192.168.1.50", 1700000000);
try testing.expectEqual(@as(i64, 1), try countClients(&database));
const row = try seenRow(&database, "192.168.1.50");
try testing.expectEqual(@as(i64, 0), row.hand_edited);
try testing.expectEqual(@as(i64, 1700000000), row.first_seen);
try testing.expectEqual(@as(i64, 1700000000), row.last_seen);
try testing.expectEqual(@as(i64, 1), row.group_id);
// Materialised clients are runtime state, so an export must not see them.
var items = try listClients(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeClients(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 0), items.items.len);
}
test "upsertSeen touches last_seen and leaves first_seen alone" {
var database = try openMigrated();
defer database.close();
try upsertSeen(&database, "192.168.1.50", 1700000000);
try upsertSeen(&database, "192.168.1.50", 1700000600);
try testing.expectEqual(@as(i64, 1), try countClients(&database));
const row = try seenRow(&database, "192.168.1.50");
try testing.expectEqual(@as(i64, 1700000000), row.first_seen);
try testing.expectEqual(@as(i64, 1700000600), row.last_seen);
}
test "upsertSeen keeps a hand-edited row's name, group and flag" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClients(&database, &ids);
try upsertSeen(&database, "192.168.1.20", 1700009999);
try testing.expectEqual(@as(i64, 3), try countClients(&database));
const row = try seenRow(&database, "192.168.1.20");
try testing.expectEqual(@as(i64, 1), row.hand_edited);
try testing.expectEqual(@as(i64, 2), row.group_id);
try testing.expectEqual(@as(i64, 1700000000), row.first_seen);
try testing.expectEqual(@as(i64, 1700009999), row.last_seen);
var items = try listClients(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeClients(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("laptop", items.items[1].name);
try testing.expectEqualStrings("kids", items.items[1].group);
}
test "upsertSeen reports a database with no default group" {
var database = try openMigrated();
defer database.close();
try database.exec("UPDATE groups SET name = 'renamed' WHERE id = 1;");
try testing.expectError(error.Constraint, upsertSeen(&database, "192.168.1.50", 1700000000));
try testing.expectEqual(@as(i64, 0), try countClients(&database));
}
test "pruneStale removes only stale auto-materialised rows" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
// A hand-edited row far older than the cutoff.
try seedClients(&database, &ids);
try upsertSeen(&database, "10.0.0.1", 1700000000);
try upsertSeen(&database, "10.0.0.2", 1700000199);
// Exactly at the cutoff: the comparison is strict, so it stays.
try upsertSeen(&database, "10.0.0.3", 1700000200);
try upsertSeen(&database, "10.0.0.4", 1700000300);
try testing.expectEqual(@as(u32, 2), try pruneStale(&database, 1700000200));
try testing.expectEqual(@as(i64, 5), try countClients(&database));
try testing.expectEqual(
@as(i64, 0),
try database.queryInt("SELECT count(*) FROM clients WHERE ip IN ('10.0.0.1', '10.0.0.2')"),
);
try testing.expectEqual(@as(i64, 3), try database.queryInt("SELECT count(*) FROM clients WHERE hand_edited = 1"));
// A second pass over the same cutoff finds nothing left to do.
try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1700000200));
}
test "pruneStale spares hand-edited rows however stale" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClients(&database, &ids);
try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1800000000));
try testing.expectEqual(@as(i64, 3), try countClients(&database));
}
test "client_prefixes round-trip in prefix order with group names resolved" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClientPrefixes(&database, &ids);
var items = try listClientPrefixes(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeClientPrefixes(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("192.168.1.0/24", items.items[0].prefix);
try testing.expectEqualStrings("default", items.items[0].group);
try testing.expectEqual(@as(i32, 50), items.items[0].priority);
try testing.expectEqualStrings("192.168.2.0/24", items.items[1].prefix);
try testing.expectEqualStrings("kids", items.items[1].group);
try testing.expectEqual(@as(i32, 10), items.items[1].priority);
try testing.expectEqualStrings("fd00::/48", items.items[2].prefix);
try testing.expectEqualStrings("kids", items.items[2].group);
try testing.expectEqual(@as(i32, 100), items.items[2].priority);
}
test "deleteAllClientPrefixes empties the table and countClientPrefixes reflects it" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClientPrefixes(&database, &ids);
try testing.expectEqual(@as(i64, 3), try countClientPrefixes(&database));
try deleteAllClientPrefixes(&database);
try testing.expectEqual(@as(i64, 0), try countClientPrefixes(&database));
}
fn listClientPrefixesUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
var database = try openMigrated();
defer database.close();
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
try seedClientPrefixes(&database, ids);
var items = try listClientPrefixes(&database, gpa);
defer items.deinit(gpa);
defer freeClientPrefixes(gpa, items.items);
}
test "listClientPrefixes is leak-safe under allocation failure" {
var ids: IdMap = .empty;
defer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
try ids.put(testing.allocator, "kids", 2);
try testing.checkAllAllocationFailures(testing.allocator, listClientPrefixesUnderFailure, .{&ids});
}
// --- REST surface ----------------------------------------------------------
test "a client round-trips through insert, get, list, update and delete" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
const id = try insertClientRow(&database, .{
.ip = "192.168.1.7",
.name = "printer",
.group_id = 2,
}, 1700000000);
const fetched = (try getClient(&database, testing.allocator, id)).?;
defer freeClientRow(testing.allocator, fetched);
try testing.expectEqual(id, fetched.id);
try testing.expectEqualStrings("192.168.1.7", fetched.ip);
try testing.expectEqualStrings("printer", fetched.name);
try testing.expectEqual(@as(i64, 2), fetched.group_id);
try testing.expectEqualStrings("kids", fetched.group);
try testing.expect(fetched.hand_edited);
try testing.expectEqual(@as(i64, 1700000000), fetched.first_seen);
try testing.expectEqual(@as(i64, 1700000000), fetched.last_seen);
try updateClient(&database, id, .{ .name = "label printer", .group_id = 1 });
const updated = (try getClient(&database, testing.allocator, id)).?;
defer freeClientRow(testing.allocator, updated);
try testing.expectEqualStrings("label printer", updated.name);
try testing.expectEqualStrings("default", updated.group);
try testing.expectEqualStrings("192.168.1.7", updated.ip);
// The tracker's timestamps are not the editor's to move.
try testing.expectEqual(@as(i64, 1700000000), updated.first_seen);
try deleteClient(&database, id);
try testing.expectEqual(@as(?ClientRow, null), try getClient(&database, testing.allocator, id));
try testing.expectEqual(@as(i64, 0), try countClients(&database));
}
test "listClientRows shows materialised clients with hand_edited false" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
_ = try insertClientRow(&database, .{ .ip = "192.168.1.10", .name = "desk", .group_id = 1 }, 1700000000);
try upsertSeen(&database, "192.168.1.99", 1700000500);
var rows = try listClientRows(&database, testing.allocator);
defer rows.deinit(testing.allocator);
defer freeClientRows(testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 2), rows.items.len);
try testing.expectEqualStrings("192.168.1.10", rows.items[0].ip);
try testing.expect(rows.items[0].hand_edited);
try testing.expectEqualStrings("192.168.1.99", rows.items[1].ip);
try testing.expect(!rows.items[1].hand_edited);
// A materialised row carries no name; NULL reads as the empty string.
try testing.expectEqualStrings("", rows.items[1].name);
try testing.expectEqualStrings("default", rows.items[1].group);
}
test "an edited client stops being a candidate for pruneStale" {
var database = try openMigrated();
defer database.close();
try upsertSeen(&database, "192.168.1.99", 1700000000);
const id = (try database.queryInt("SELECT id FROM clients WHERE ip = '192.168.1.99'"));
try updateClient(&database, id, .{ .name = "tv", .group_id = 1 });
try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1800000000));
const row = (try getClient(&database, testing.allocator, id)).?;
defer freeClientRow(testing.allocator, row);
try testing.expect(row.hand_edited);
}
test "client update and delete report NotFound for an id no row holds" {
var database = try openMigrated();
defer database.close();
try testing.expectError(error.NotFound, updateClient(&database, 404, .{ .group_id = 1 }));
try testing.expectError(error.NotFound, deleteClient(&database, 404));
try testing.expectEqual(@as(?ClientRow, null), try getClient(&database, testing.allocator, 404));
}
test "a duplicate ip and an unknown group both surface as error.Constraint" {
var database = try openMigrated();
defer database.close();
const id = try insertClientRow(&database, .{ .ip = "192.168.1.7", .group_id = 1 }, 1);
try testing.expectError(
error.Constraint,
insertClientRow(&database, .{ .ip = "192.168.1.7", .group_id = 1 }, 1),
);
try testing.expectError(
error.Constraint,
insertClientRow(&database, .{ .ip = "192.168.1.8", .group_id = 404 }, 1),
);
try testing.expectError(error.Constraint, updateClient(&database, id, .{ .group_id = 404 }));
try testing.expectEqual(@as(i64, 1), try countClients(&database));
}
test "client prefixes replace as one atomic list" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try replaceClientPrefixes(&database, &.{
.{ .prefix = "192.168.2.0/24", .group_id = 2, .priority = 10 },
.{ .prefix = "192.168.1.0/24", .group_id = 1, .priority = 50 },
});
var rows = try listClientPrefixRows(&database, testing.allocator);
defer rows.deinit(testing.allocator);
defer freeClientPrefixRows(testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 2), rows.items.len);
try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix);
try testing.expectEqual(@as(i64, 1), rows.items[0].group_id);
try testing.expectEqualStrings("default", rows.items[0].group);
try testing.expectEqual(@as(i32, 50), rows.items[0].priority);
try testing.expectEqualStrings("192.168.2.0/24", rows.items[1].prefix);
try testing.expectEqualStrings("kids", rows.items[1].group);
try testing.expectEqual(@as(i32, 10), rows.items[1].priority);
try testing.expect(rows.items[0].id != rows.items[1].id);
// The replacement is total, and the empty list clears the table.
try replaceClientPrefixes(&database, &.{.{ .prefix = "fd00::/48", .group_id = 2 }});
try testing.expectEqual(@as(i64, 1), try countClientPrefixes(&database));
try replaceClientPrefixes(&database, &.{});
try testing.expectEqual(@as(i64, 0), try countClientPrefixes(&database));
}
test "replaceClientPrefixes rolls back on a duplicate prefix or an unknown group" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try replaceClientPrefixes(&database, &.{.{ .prefix = "192.168.1.0/24", .group_id = 1 }});
try testing.expectError(error.Constraint, replaceClientPrefixes(&database, &.{
.{ .prefix = "10.0.0.0/8", .group_id = 2 },
.{ .prefix = "10.0.0.0/8", .group_id = 1 },
}));
try testing.expectError(error.Constraint, replaceClientPrefixes(&database, &.{
.{ .prefix = "10.0.0.0/8", .group_id = 404 },
}));
// Both failures left the previous list in place.
var rows = try listClientPrefixRows(&database, testing.allocator);
defer rows.deinit(testing.allocator);
defer freeClientPrefixRows(testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 1), rows.items.len);
try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix);
}
fn clientRowsUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
var database = try openMigrated();
defer database.close();
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
try seedClients(&database, ids);
try seedClientPrefixes(&database, ids);
var rows = try listClientRows(&database, gpa);
defer rows.deinit(gpa);
defer freeClientRows(gpa, rows.items);
const one = (try getClient(&database, gpa, rows.items[0].id)).?;
defer freeClientRow(gpa, one);
var prefixes = try listClientPrefixRows(&database, gpa);
defer prefixes.deinit(gpa);
defer freeClientPrefixRows(gpa, prefixes.items);
}
test "the client read surface is leak-safe under allocation failure" {
var ids: IdMap = .empty;
defer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
try ids.put(testing.allocator, "kids", 2);
try testing.checkAllAllocationFailures(testing.allocator, clientRowsUnderFailure, .{&ids});
}