diff --git a/AGENTS.md b/AGENTS.md index b09b4fd..0f57791 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,9 @@ architecture work. Before committing code changes: ```sh -zig build test # unit +zig build test # unit + harness self-tests (L1) +zig build scenario-self # harness vs scripted fakes (L2; no zds build) +zig build scenario-isolation # concurrent-run isolation proof (L3) zig build scenario-space # member-only space scenario suite zig build scenario-serve # BFF/curl-driven scenario suite cd web && bun run test # PWA behavior tests — NOT `bun test` diff --git a/build.zig b/build.zig index 2a2c05d..e33c4f9 100644 --- a/build.zig +++ b/build.zig @@ -66,6 +66,23 @@ pub fn build(b: *std.Build) void { const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_tests.step); + // Shared scenario harness (L0) plus its own unit tests (L1: the + // gate's gate — every assertion helper in both polarities, the + // parsers, and the curl API driver against an in-process stub). + const harness_mod = b.addModule("harness", .{ + .root_source_file = b.path("tests/harness.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .imports = &.{ + .{ .name = "zat", .module = zat_mod }, + .{ .name = "httpz", .module = httpz.module("httpz") }, + }, + }); + const harness_tests = b.addTest(.{ .root_module = harness_mod }); + const run_harness_tests = b.addRunArtifact(harness_tests); + test_step.dependOn(&run_harness_tests.step); + const scenario_space = b.addExecutable(.{ .name = "linji-scenario-space", .root_module = b.createModule(.{ @@ -76,6 +93,7 @@ pub fn build(b: *std.Build) void { .imports = &.{ .{ .name = "linji", .module = mod }, .{ .name = "zat", .module = zat_mod }, + .{ .name = "harness", .module = harness_mod }, }, }), }); @@ -94,7 +112,10 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, .link_libc = true, - .imports = &.{.{ .name = "zat", .module = zat_mod }}, + .imports = &.{ + .{ .name = "zat", .module = zat_mod }, + .{ .name = "harness", .module = harness_mod }, + }, }), }); b.installArtifact(scenario_serve); @@ -104,4 +125,146 @@ pub fn build(b: *std.Build) void { const scenario_serve_step = b.step("scenario-serve", "Run the BFF server API scenario (needs ../zds built)"); scenario_serve_step.dependOn(&run_scenario_serve.step); scenario_serve_step.dependOn(&b.addInstallArtifact(exe, .{}).step); + + // --- fake rig for the L2 self-tests (scripted stand-ins, no zds) --- + const fake_zds = b.addExecutable(.{ + .name = "fake-zds", + .root_module = b.createModule(.{ + .root_source_file = b.path("tests/fake_zds.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .imports = &.{.{ .name = "httpz", .module = httpz.module("httpz") }}, + }), + }); + b.installArtifact(fake_zds); + const fake_linji = b.addExecutable(.{ + .name = "fake-linji", + .root_module = b.createModule(.{ + .root_source_file = b.path("tests/fake_linji.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .imports = &.{.{ .name = "harness", .module = harness_mod }}, + }), + }); + b.installArtifact(fake_linji); + const fake_serve = b.addExecutable(.{ + .name = "fake-serve", + .root_module = b.createModule(.{ + .root_source_file = b.path("tests/fake_serve.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .imports = &.{ + .{ .name = "httpz", .module = httpz.module("httpz") }, + .{ .name = "harness", .module = harness_mod }, + }, + }), + }); + b.installArtifact(fake_serve); + + const fake_zds_path = b.getInstallPath(.bin, "fake-zds"); + const fake_linji_path = b.getInstallPath(.bin, "fake-linji"); + const fake_serve_path = b.getInstallPath(.bin, "fake-serve"); + const install_fake_zds = b.addInstallArtifact(fake_zds, .{}); + const install_fake_linji = b.addInstallArtifact(fake_linji, .{}); + const install_fake_serve = b.addInstallArtifact(fake_serve, .{}); + const space_good = b.path("tests/fixtures/space_good.tsv").getPath(b); + const space_bad = b.path("tests/fixtures/space_bad.tsv").getPath(b); + const space_hang = b.path("tests/fixtures/space_hang.tsv").getPath(b); + const serve_cli_good = b.path("tests/fixtures/serve_cli_good.tsv").getPath(b); + const serve_cli_bad = b.path("tests/fixtures/serve_cli_bad.tsv").getPath(b); + const serve_http_good = b.path("tests/fixtures/serve_http_good.tsv").getPath(b); + const serve_http_bad = b.path("tests/fixtures/serve_http_bad.tsv").getPath(b); + + // --- L2: the real scenarios against scripted fakes --- + // Good polarity: the real scenario must PASS against the known-good + // fixture. Bad polarity: a broken fixture must make the real scenario + // FAIL (proves the gate detects failures). Hang: a stuck CLI must be + // reaped by the harness timeout, not wedge the suite. + const scenario_self = b.step("scenario-self", "Self-test the scenario harness against scripted fakes (no zds build needed)"); + + const self_space_good = b.addRunArtifact(scenario_space); + self_space_good.setEnvironmentVariable("LINJI_ZDS_BIN", fake_zds_path); + self_space_good.setEnvironmentVariable("LINJI_BIN", fake_linji_path); + self_space_good.setEnvironmentVariable("LINJI_FAKE_FIXTURE", space_good); + self_space_good.setEnvironmentVariable("LINJI_ZDS_PORT", "2651"); + self_space_good.step.dependOn(&install_fake_zds.step); + self_space_good.step.dependOn(&install_fake_linji.step); + scenario_self.dependOn(&self_space_good.step); + + const self_space_bad = b.addRunArtifact(scenario_space); + self_space_bad.setEnvironmentVariable("LINJI_ZDS_BIN", fake_zds_path); + self_space_bad.setEnvironmentVariable("LINJI_BIN", fake_linji_path); + self_space_bad.setEnvironmentVariable("LINJI_FAKE_FIXTURE", space_bad); + self_space_bad.setEnvironmentVariable("LINJI_ZDS_PORT", "2652"); + self_space_bad.expectExitCode(1); + self_space_bad.step.dependOn(&install_fake_zds.step); + self_space_bad.step.dependOn(&install_fake_linji.step); + scenario_self.dependOn(&self_space_bad.step); + + const self_space_hang = b.addRunArtifact(scenario_space); + self_space_hang.setEnvironmentVariable("LINJI_ZDS_BIN", fake_zds_path); + self_space_hang.setEnvironmentVariable("LINJI_BIN", fake_linji_path); + self_space_hang.setEnvironmentVariable("LINJI_FAKE_FIXTURE", space_hang); + self_space_hang.setEnvironmentVariable("LINJI_ZDS_PORT", "2653"); + self_space_hang.setEnvironmentVariable("LINJI_CMD_TIMEOUT_MS", "2000"); + self_space_hang.expectExitCode(1); + self_space_hang.step.dependOn(&install_fake_zds.step); + self_space_hang.step.dependOn(&install_fake_linji.step); + scenario_self.dependOn(&self_space_hang.step); + + const self_serve_good = b.addRunArtifact(scenario_serve); + self_serve_good.setEnvironmentVariable("LINJI_ZDS_BIN", fake_zds_path); + self_serve_good.setEnvironmentVariable("LINJI_BIN", fake_linji_path); + self_serve_good.setEnvironmentVariable("LINJI_SERVE_BIN", fake_serve_path); + self_serve_good.setEnvironmentVariable("LINJI_FAKE_FIXTURE", serve_cli_good); + self_serve_good.setEnvironmentVariable("LINJI_FAKE_SERVE_FIXTURE", serve_http_good); + self_serve_good.setEnvironmentVariable("LINJI_ZDS_PORT", "2654"); + self_serve_good.setEnvironmentVariable("LINJI_API_PORT", "8754"); + self_serve_good.step.dependOn(&install_fake_zds.step); + self_serve_good.step.dependOn(&install_fake_linji.step); + self_serve_good.step.dependOn(&install_fake_serve.step); + scenario_self.dependOn(&self_serve_good.step); + + const self_serve_bad = b.addRunArtifact(scenario_serve); + self_serve_bad.setEnvironmentVariable("LINJI_ZDS_BIN", fake_zds_path); + self_serve_bad.setEnvironmentVariable("LINJI_BIN", fake_linji_path); + self_serve_bad.setEnvironmentVariable("LINJI_SERVE_BIN", fake_serve_path); + self_serve_bad.setEnvironmentVariable("LINJI_FAKE_FIXTURE", serve_cli_bad); + self_serve_bad.setEnvironmentVariable("LINJI_FAKE_SERVE_FIXTURE", serve_http_bad); + self_serve_bad.setEnvironmentVariable("LINJI_ZDS_PORT", "2655"); + self_serve_bad.setEnvironmentVariable("LINJI_API_PORT", "8755"); + self_serve_bad.expectExitCode(1); + self_serve_bad.step.dependOn(&install_fake_zds.step); + self_serve_bad.step.dependOn(&install_fake_linji.step); + self_serve_bad.step.dependOn(&install_fake_serve.step); + scenario_self.dependOn(&self_serve_bad.step); + + scenario_self.dependOn(&install_fake_zds.step); + scenario_self.dependOn(&install_fake_linji.step); + scenario_self.dependOn(&install_fake_serve.step); + + // --- L3: isolation proof (concurrent runs, distinct ports) --- + const self_isolation = b.addExecutable(.{ + .name = "self-isolation", + .root_module = b.createModule(.{ + .root_source_file = b.path("tests/self_isolation.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }), + }); + b.installArtifact(self_isolation); + const run_isolation = b.addRunArtifact(self_isolation); + run_isolation.setEnvironmentVariable("SELF_SCENARIO", b.getInstallPath(.bin, "linji-scenario-space")); + run_isolation.setEnvironmentVariable("SELF_FAKE_ZDS", fake_zds_path); + run_isolation.setEnvironmentVariable("SELF_FAKE_LINJI", fake_linji_path); + run_isolation.setEnvironmentVariable("SELF_FIXTURE", space_good); + const scenario_isolation = b.step("scenario-isolation", "Prove scenario runs are isolated: concurrent runs, distinct ports, teardown"); + scenario_isolation.dependOn(&run_isolation.step); + run_isolation.step.dependOn(&install_fake_zds.step); + run_isolation.step.dependOn(&install_fake_linji.step); + run_isolation.step.dependOn(&b.addInstallArtifact(scenario_space, .{}).step); } diff --git a/tests/fake_linji.zig b/tests/fake_linji.zig new file mode 100644 index 0000000..22ea305 --- /dev/null +++ b/tests/fake_linji.zig @@ -0,0 +1,99 @@ +//! Fake linji CLI for the L2 self-tests: replays a fixture, one row per +//! invocation, in order. The fixture is the hand-verified oracle of what +//! the real CLI prints; see tests/fixtures/*.tsv. +//! +//! Fixture format (tab-separated, one row per CLI call): +//! \t exit code + stdout (`\n` literal = newline) +//! hang\t sleep forever (harness timeout kills us) +//! +//! The row cursor persists in $LINJI_HOME/.fake-linji-idx, so each +//! invocation (a fresh process) continues where the last stopped. The +//! work dir is per-run, so concurrent runs never share a cursor. +//! +//! Env: LINJI_FAKE_FIXTURE (required), LINJI_HOME (cursor). + +const std = @import("std"); +const harness = @import("harness"); + +var app_threaded_io: std.Io.Threaded = undefined; +pub const std_options_debug_threaded_io: ?*std.Io.Threaded = &app_threaded_io; + +pub fn main(init: std.process.Init) !void { + _ = init; + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + app_threaded_io = std.Io.Threaded.init(allocator, .{ + .async_limit = .limited(4), + .concurrent_limit = .limited(4), + }); + const io = app_threaded_io.io(); + + const fixture = std.c.getenv("LINJI_FAKE_FIXTURE") orelse { + std.debug.print("fake-linji: LINJI_FAKE_FIXTURE not set\n", .{}); + std.process.exit(98); + }; + const fixture_path = std.mem.span(fixture); + const data = std.Io.Dir.cwd().readFileAlloc(io, fixture_path, allocator, .limited(1 << 20)) catch { + std.debug.print("fake-linji: cannot read fixture {s}\n", .{fixture_path}); + std.process.exit(98); + }; + + // --- advance the per-run cursor --- + const home = if (std.c.getenv("LINJI_HOME")) |h| std.mem.span(h) else ""; + // The scenario never creates $LINJI_HOME (the real CLI does on first + // use); the cursor file needs the dir to exist. + harness.mkdirs(home) catch {}; + const idx_path = try std.fmt.allocPrint(allocator, "{s}/.fake-linji-idx", .{home}); + var idx: usize = 0; + if (std.Io.Dir.cwd().readFileAlloc(io, idx_path, allocator, .limited(1 << 10))) |s| { + idx = std.fmt.parseInt(usize, std.mem.trim(u8, s, " \r\n"), 10) catch 0; + } else |_| {} + std.Io.Dir.cwd().writeFile(io, .{ + .sub_path = idx_path, + .data = try std.fmt.allocPrint(allocator, "{d}\n", .{idx + 1}), + }) catch {}; + + // --- pick row idx --- + var line_it = std.mem.splitScalar(u8, data, '\n'); + var row: []const u8 = ""; + var i: usize = 0; + while (line_it.next()) |line| { + if (line.len == 0) continue; // trailing newline + if (i == idx) { + row = line; + break; + } + i += 1; + } + if (row.len == 0) { + std.debug.print("fake-linji: fixture {s} exhausted at row {d}\n", .{ fixture_path, idx }); + std.process.exit(99); + } + + var col = std.mem.splitScalar(u8, row, '\t'); + const exit_str = col.next() orelse { + std.debug.print("fake-linji: malformed fixture row {d}\n", .{idx}); + std.process.exit(98); + }; + const stdout_escaped = col.next() orelse ""; + + if (std.mem.eql(u8, exit_str, "hang")) { + const ms = std.fmt.parseInt(i64, stdout_escaped, 10) catch 60_000; + app_threaded_io.io().sleep(std.Io.Duration.fromMilliseconds(ms), .awake) catch {}; + std.process.exit(97); // only if the harness failed to kill us + } + const code = std.fmt.parseInt(u8, exit_str, 10) catch 98; + const stdout = try std.mem.replaceOwned(u8, allocator, stdout_escaped, "\\n", "\n"); + if (stdout.len > 0) writeStdout(stdout); + std.process.exit(code); +} + +/// The harness captures the fake's stdout as the CLI's stdout, so real +/// newlines must go to fd 1 (std.debug.print goes to stderr). +fn writeStdout(bytes: []const u8) void { + var buf: [8192]u8 = undefined; + var w = std.Io.File.stdout().writer(std.Options.debug_io, &buf); + w.interface.writeAll(bytes) catch return; + w.interface.flush() catch return; +} diff --git a/tests/fake_serve.zig b/tests/fake_serve.zig new file mode 100644 index 0000000..89cb460 --- /dev/null +++ b/tests/fake_serve.zig @@ -0,0 +1,181 @@ +//! Fake `linji serve` for the L2 self-tests: replays HTTP responses, one +//! fixture row per request, in order. Row kinds: +//! +//! ok\t\t normal response (`\n` literal = newline) +//! subs respond 200, write push-subscriptions.json with +//! the scripted endpoint (upsert = same content) +//! prune respond 200, rewrite subscriptions without the +//! endpoint (the BFF's dead-endpoint cleanup) +//! sse hold the connection until the next trigger row +//! trigger respond 200, fire the pending SSE event +//! +//! Env: LINJI_FAKE_SERVE_FIXTURE (required), LINJI_HOME (side-effect +//! files). argv: --port N. + +const std = @import("std"); +const httpz = @import("httpz"); +const harness = @import("harness"); + +var app_threaded_io: std.Io.Threaded = undefined; +pub const std_options_debug_threaded_io: ?*std.Io.Threaded = &app_threaded_io; + +const Row = struct { + kind: Kind, + code: u16, + body: []const u8, + + const Kind = enum { ok, subs, prune, sse, trigger }; +}; + +const App = struct { + allocator: std.mem.Allocator, + io: std.Io, + rows: []const Row, + home: []const u8, + port: u16, + next_row: std.atomic.Value(usize) = std.atomic.Value(usize).init(0), + sse_triggered: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + + /// Every request lands here: the fixture is positional, so routes + /// don't matter (httpz sends unmatched paths to the handler's + /// `notFound`). + pub fn notFound(app: *App, _: *httpz.Request, res: *httpz.Response) !void { + const idx = app.next_row.fetchAdd(1, .monotonic); + const row = if (idx < app.rows.len) app.rows[idx] else { + res.status = 500; + res.body = "fixture exhausted"; + return; + }; + switch (row.kind) { + .ok => { + res.status = row.code; + res.body = row.body; + }, + .subs => { + res.status = 200; + res.body = "{}"; + try app.writeSubs(); + }, + .prune => { + res.status = 200; + res.body = "{}"; + try app.pruneSubs(); + }, + .sse => { + const ctx = try app.allocator.create(EventsCtx); + ctx.* = .{ .app = app }; + try res.startEventStream(ctx, EventsCtx.handle); + }, + .trigger => { + app.sse_triggered.store(true, .release); + res.status = 200; + res.body = "{}"; + }, + } + } + + /// The scenario checks the subscriptions file directly: one row with + /// the scripted endpoint (the endpoint string appears exactly twice: + /// as the JSON key and inside the URL path). + fn writeSubs(app: *App) !void { + const endpoint = try std.fmt.allocPrint(app.allocator, "http://127.0.0.1:{d}/api/no-such-push-endpoint", .{app.port}); + const body = try std.fmt.allocPrint(app.allocator, + "{{\"endpoint\":\"{s}\",\"keys\":{{\"p256dh\":\"BGsX0fLhLEJH-Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWT-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU\",\"auth\":\"MDEyMzQ1Njc4OWFiY2RlZg\"}}}}", + .{endpoint}); + const path = try std.fmt.allocPrint(app.allocator, "{s}/push-subscriptions.json", .{app.home}); + try std.Io.Dir.cwd().writeFile(app.io, .{ .sub_path = path, .data = body }); + } + + fn pruneSubs(app: *App) !void { + const path = try std.fmt.allocPrint(app.allocator, "{s}/push-subscriptions.json", .{app.home}); + try std.Io.Dir.cwd().writeFile(app.io, .{ .sub_path = path, .data = "{\"pruned\":true}" }); + } +}; + +/// Waits for the next `trigger` row (the "another member posts" moment), +/// then writes the SSE event and closes. Times out after 30s so a dead +/// run never wedges a connection. +const EventsCtx = struct { + app: *App, + + fn handle(self: *EventsCtx, stream: std.Io.net.Stream) void { + defer self.app.allocator.destroy(self); + const app = self.app; + var waited: u32 = 0; + while (!app.sse_triggered.load(.acquire)) { + if (waited >= 300) return; + app.io.sleep(std.Io.Duration.fromMilliseconds(100), .awake) catch return; + waited += 1; + } + var buf: [256]u8 = undefined; + var sw = stream.writer(app.io, &buf); + const w = &sw.interface; + w.writeAll("event: update\ndata: {}\n\n") catch return; + w.flush() catch return; + } +}; + +pub fn main(init: std.process.Init) !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + app_threaded_io = std.Io.Threaded.init(allocator, .{ + .async_limit = .limited(8), + .concurrent_limit = .limited(8), + }); + const io = app_threaded_io.io(); + + var port: u16 = 8791; + var args = std.process.Args.Iterator.init(init.minimal.args); + _ = args.next(); // argv[0] + while (args.next()) |arg| { + if (std.mem.eql(u8, arg, "--port")) { + port = std.fmt.parseInt(u16, args.next() orelse return error.MissingArg, 10) catch return error.BadPort; + } + } + + const fixture = std.c.getenv("LINJI_FAKE_SERVE_FIXTURE") orelse { + std.debug.print("fake-serve: LINJI_FAKE_SERVE_FIXTURE not set\n", .{}); + std.process.exit(98); + }; + const fixture_path = std.mem.span(fixture); + const data = std.Io.Dir.cwd().readFileAlloc(io, fixture_path, allocator, .limited(1 << 20)) catch { + std.debug.print("fake-serve: cannot read fixture {s}\n", .{fixture_path}); + std.process.exit(98); + }; + + var rows: std.ArrayList(Row) = .empty; + var line_it = std.mem.splitScalar(u8, data, '\n'); + while (line_it.next()) |line| { + if (line.len == 0) continue; // trailing newline + var col = std.mem.splitScalar(u8, line, '\t'); + const kind_str = col.next() orelse continue; + if (std.mem.eql(u8, kind_str, "sse") or std.mem.eql(u8, kind_str, "subs") or + std.mem.eql(u8, kind_str, "prune") or std.mem.eql(u8, kind_str, "trigger")) + { + try rows.append(allocator, .{ .kind = std.meta.stringToEnum(Row.Kind, kind_str).?, .code = 200, .body = "{}" }); + continue; + } + const code = std.fmt.parseInt(u16, col.next() orelse continue, 10) catch continue; + const body_escaped = col.next() orelse ""; + const body = try std.mem.replaceOwned(u8, allocator, body_escaped, "\\n", "\n"); + try rows.append(allocator, .{ .kind = .ok, .code = code, .body = body }); + } + + const home = if (std.c.getenv("LINJI_HOME")) |h| std.mem.span(h) else ""; + // The scenario never creates $LINJI_HOME; the push-subscriptions file + // side effects need the dir to exist. + harness.mkdirs(home) catch {}; + var app: App = .{ + .allocator = allocator, + .io = io, + .rows = try rows.toOwnedSlice(allocator), + .home = home, + .port = port, + }; + var server = try httpz.Server(*App).init(io, allocator, .{ + .address = .{ .ip = .{ .ip4 = try std.Io.net.Ip4Address.parse("127.0.0.1", port) } }, + }, &app); + _ = try server.router(.{}); // no routes: everything hits App.notFound + try server.listen(); +} diff --git a/tests/fake_zds.zig b/tests/fake_zds.zig new file mode 100644 index 0000000..74a639f --- /dev/null +++ b/tests/fake_zds.zig @@ -0,0 +1,57 @@ +//! Fake zds for the L2 self-tests. The harness boots a "zds" and waits +//! for /xrpc/_health, then seeds accounts straight into the sqlite db — +//! so this fake creates the db + accounts table and serves the health +//! endpoint. It never talks product: the scenario's XRPC traffic goes +//! through the fake linji, which is scripted. +//! +//! argv: --port N, --db PATH (other zds flags ignored). + +const std = @import("std"); +const httpz = @import("httpz"); + +var app_threaded_io: std.Io.Threaded = undefined; +pub const std_options_debug_threaded_io: ?*std.Io.Threaded = &app_threaded_io; + +const App = struct { + fn health(_: *App, _: *httpz.Request, res: *httpz.Response) !void { + res.body = "{\"version\":\"fake-zds\"}"; + } +}; + +pub fn main(init: std.process.Init) !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + app_threaded_io = std.Io.Threaded.init(allocator, .{ + .async_limit = .limited(8), + .concurrent_limit = .limited(8), + }); + const io = app_threaded_io.io(); + + var port: u16 = 2591; + var db_path: ?[]const u8 = null; + var args = std.process.Args.Iterator.init(init.minimal.args); + _ = args.next(); // argv[0] + while (args.next()) |arg| { + if (std.mem.eql(u8, arg, "--port")) { + port = std.fmt.parseInt(u16, args.next() orelse return error.MissingArg, 10) catch return error.BadPort; + } else if (std.mem.eql(u8, arg, "--db")) { + db_path = args.next() orelse return error.MissingArg; + } + } + const db = db_path orelse return error.MissingDb; + + // The harness seeds accounts by inserting into the db directly; a + // zero-length file is a valid empty sqlite db, but the accounts table + // must exist for the inserts to land. + const create_sql = "create table if not exists accounts (did text primary key, handle text, email text, password_hash text, activated_at integer, email_confirmed_at integer)"; + _ = std.process.run(allocator, io, .{ .argv = &.{ "sqlite3", db, create_sql } }) catch {}; + + var app: App = .{}; + var server = try httpz.Server(*App).init(io, allocator, .{ + .address = .{ .ip = .{ .ip4 = try std.Io.net.Ip4Address.parse("127.0.0.1", port) } }, + }, &app); + const router = try server.router(.{}); + router.get("/xrpc/_health", App.health, .{}); + try server.listen(); +} diff --git a/tests/fixtures/serve_cli_bad.tsv b/tests/fixtures/serve_cli_bad.tsv new file mode 100644 index 0000000..cb5473f --- /dev/null +++ b/tests/fixtures/serve_cli_bad.tsv @@ -0,0 +1,11 @@ +0 logged in as community.test +0 logged in as alice.test +0 logged in as bob.test +0 logged in as carol.test +0 logged in as dave.test +0 using community.test +0 at://did:plc:communitytest/space/at.linji.space/webboard +0 added did:plc:alicetest +0 added did:plc:bobtest +0 using community.test +0 added did:plc:caroltest diff --git a/tests/fixtures/serve_cli_good.tsv b/tests/fixtures/serve_cli_good.tsv new file mode 100644 index 0000000..cb5473f --- /dev/null +++ b/tests/fixtures/serve_cli_good.tsv @@ -0,0 +1,11 @@ +0 logged in as community.test +0 logged in as alice.test +0 logged in as bob.test +0 logged in as carol.test +0 logged in as dave.test +0 using community.test +0 at://did:plc:communitytest/space/at.linji.space/webboard +0 added did:plc:alicetest +0 added did:plc:bobtest +0 using community.test +0 added did:plc:caroltest diff --git a/tests/fixtures/serve_http_bad.tsv b/tests/fixtures/serve_http_bad.tsv new file mode 100644 index 0000000..5ee6e08 --- /dev/null +++ b/tests/fixtures/serve_http_bad.tsv @@ -0,0 +1,62 @@ +ok 200 linji pwa placeholder +ok 200 {"handleDomain":"test","vapidKey":"abc"} +ok 200 {"did":"did:plc:alicetest","handle":"alice.test"} +ok 401 not logged in +ok 200 {"did":"did:plc:alicetest","handle":"alice.test"} +ok 200 {"did":"did:plc:alicetest","handle":"alice.test","pds":"http://127.0.0.1:2592"} +ok 200 {"uri":"at://did:plc:alicetest/at.linji.thread/3jzfq0201"} +ok 200 {"uri":"at://did:plc:alicetest/at.linji.post/3jzfq0202"} +ok 200 {"space":"at://did:plc:communitytest/space/at.linji.space/webboard","threads":[{"title":"服务器上的线程"}],"posts":[{"text":"API 帖一"}]} +ok 200 {"did":"did:plc:bobtest","handle":"bob.test"} +ok 200 {"uri":"at://did:plc:bobtest/at.linji.post/3jzfq0203"} +ok 200 {"ok":true} +ok 200 ## 服务器上的线程\n**alice.test** API 帖一 +ok 200 linji pwa placeholder +ok 401 not logged in +subs +subs +prune +ok 200 {"ok":true} +ok 401 not logged in +sse +trigger +ok 200 {"spaces":[{"uri":"at://did:plc:communitytest/space/at.linji.space/webboard","isOwner":false}]} +ok 200 {"did":"did:plc:communitytest","handle":"community.test"} +ok 200 {"spaces":[{"uri":"at://did:plc:communitytest/space/at.linji.space/webboard","isOwner":true}]} +ok 400 bad skey +ok 200 {"uri":"at://did:plc:alicetest/space/at.linji.space/api-made"} +ok 200 {"members":[{"did":"did:plc:alicetest","handle":"alice.test"}]} +ok 403 {"error":"denied"} +ok 200 {"ok":true} +ok 200 {"members":[{"did":"did:plc:alicetest","handle":"alice.test"},{"did":"did:plc:bobtest","handle":"bob.test"}]} +ok 200 {"ok":true} +ok 200 {"members":[{"did":"did:plc:alicetest","handle":"alice.test"}]} +ok 200 {"uri":"at://did:plc:alicetest/at.linji.post/3jzfq0210"} +ok 200 {"ok":true} +ok 200 {"space":"at://did:plc:communitytest/space/at.linji.space/webboard","posts":[{"text":"改后"}]} +ok 200 {"uri":"at://did:plc:alicetest/at.linji.thread/3jzfq0204"} +ok 200 {"ok":true} +ok 200 ## 移动目标\n**alice.test** 改后 +ok 200 {"ok":true} +ok 200 {"space":"at://did:plc:communitytest/space/at.linji.space/webboard","posts":[{"text":"API 帖一"}]} +ok 200 {"did":"did:plc:alicetest","posts":[{"text":"API 帖一"}],"threads":[{"title":"服务器上的线程"}]} +ok 200 {"did":"did:plc:caroltest","handle":"carol.test"} +ok 200 {"uri":"at://did:plc:caroltest/at.linji.post/3jzfq0211"} +ok 200 {"posts":1,"spaces":0} +ok 401 not logged in +ok 200 {"space":"at://did:plc:communitytest/space/at.linji.space/webboard","posts":[{"text":"API 帖一"},{"text":"bob 的帖子"}]} +ok 403 {"error":"denied"} +ok 200 {"uri":"at://did:plc:communitytest/at.linji.invite/3jzfq0301","url":"http://127.0.0.1:8791/#/join/at%3A%2F%2Fdid%3Aplc%3Acommunitytest%2Fat.linji.invite%2F3jzfq0301","expiresAt":"2030-01-01T00:00:00Z"} +ok 200 {"did":"did:plc:davetest","handle":"dave.test"} +ok 200 {"spaces":[]} +ok 403 not allowed +ok 200 {"valid":true,"space":"at://did:plc:communitytest/space/at.linji.space/webboard","inviter":"community.test"} +ok 200 {"valid":false} +ok 403 {"error":"denied"} +ok 200 {"ok":true} +ok 200 {"spaces":[{"uri":"at://did:plc:communitytest/space/at.linji.space/webboard"}]} +ok 200 {"space":"at://did:plc:communitytest/space/at.linji.space/webboard","posts":[{"text":"API 帖一"}]} +ok 200 {"uri":"at://did:plc:davetest/at.linji.post/3jzfq0212"} +ok 200 {"alreadyMember":true} +ok 200 {"ok":true} +ok 401 not logged in diff --git a/tests/fixtures/serve_http_good.tsv b/tests/fixtures/serve_http_good.tsv new file mode 100644 index 0000000..30dc8cd --- /dev/null +++ b/tests/fixtures/serve_http_good.tsv @@ -0,0 +1,62 @@ +ok 200 linji pwa placeholder +ok 200 {"handleDomain":"test","vapidKey":"abc"} +ok 200 {"did":"did:plc:alicetest","handle":"alice.test"} +ok 401 not logged in +ok 200 {"did":"did:plc:alicetest","handle":"alice.test"} +ok 200 {"did":"did:plc:alicetest","handle":"alice.test","pds":"http://127.0.0.1:2592"} +ok 200 {"uri":"at://did:plc:alicetest/at.linji.thread/3jzfq0201"} +ok 200 {"uri":"at://did:plc:alicetest/at.linji.post/3jzfq0202"} +ok 200 {"space":"at://did:plc:communitytest/space/at.linji.space/webboard","threads":[{"title":"服务器上的线程"}],"posts":[{"text":"API 帖一"}]} +ok 200 {"did":"did:plc:bobtest","handle":"bob.test"} +ok 200 {"uri":"at://did:plc:bobtest/at.linji.post/3jzfq0203"} +ok 403 {"error":"denied"} +ok 200 ## 服务器上的线程\n**alice.test** API 帖一 +ok 200 linji pwa placeholder +ok 401 not logged in +subs +subs +prune +ok 200 {"ok":true} +ok 401 not logged in +sse +trigger +ok 200 {"spaces":[{"uri":"at://did:plc:communitytest/space/at.linji.space/webboard","isOwner":false}]} +ok 200 {"did":"did:plc:communitytest","handle":"community.test"} +ok 200 {"spaces":[{"uri":"at://did:plc:communitytest/space/at.linji.space/webboard","isOwner":true}]} +ok 400 bad skey +ok 200 {"uri":"at://did:plc:alicetest/space/at.linji.space/api-made"} +ok 200 {"members":[{"did":"did:plc:alicetest","handle":"alice.test"}]} +ok 403 {"error":"denied"} +ok 200 {"ok":true} +ok 200 {"members":[{"did":"did:plc:alicetest","handle":"alice.test"},{"did":"did:plc:bobtest","handle":"bob.test"}]} +ok 200 {"ok":true} +ok 200 {"members":[{"did":"did:plc:alicetest","handle":"alice.test"}]} +ok 200 {"uri":"at://did:plc:alicetest/at.linji.post/3jzfq0210"} +ok 200 {"ok":true} +ok 200 {"space":"at://did:plc:communitytest/space/at.linji.space/webboard","posts":[{"text":"改后"}]} +ok 200 {"uri":"at://did:plc:alicetest/at.linji.thread/3jzfq0204"} +ok 200 {"ok":true} +ok 200 ## 移动目标\n**alice.test** 改后 +ok 200 {"ok":true} +ok 200 {"space":"at://did:plc:communitytest/space/at.linji.space/webboard","posts":[{"text":"API 帖一"}]} +ok 200 {"did":"did:plc:alicetest","posts":[{"text":"API 帖一"}],"threads":[{"title":"服务器上的线程"}]} +ok 200 {"did":"did:plc:caroltest","handle":"carol.test"} +ok 200 {"uri":"at://did:plc:caroltest/at.linji.post/3jzfq0211"} +ok 200 {"posts":1,"spaces":0} +ok 401 not logged in +ok 200 {"space":"at://did:plc:communitytest/space/at.linji.space/webboard","posts":[{"text":"API 帖一"},{"text":"bob 的帖子"}]} +ok 403 {"error":"denied"} +ok 200 {"uri":"at://did:plc:communitytest/at.linji.invite/3jzfq0301","url":"http://127.0.0.1:8791/#/join/at%3A%2F%2Fdid%3Aplc%3Acommunitytest%2Fat.linji.invite%2F3jzfq0301","expiresAt":"2030-01-01T00:00:00Z"} +ok 200 {"did":"did:plc:davetest","handle":"dave.test"} +ok 200 {"spaces":[]} +ok 403 not allowed +ok 200 {"valid":true,"space":"at://did:plc:communitytest/space/at.linji.space/webboard","inviter":"community.test"} +ok 200 {"valid":false} +ok 403 {"error":"denied"} +ok 200 {"ok":true} +ok 200 {"spaces":[{"uri":"at://did:plc:communitytest/space/at.linji.space/webboard"}]} +ok 200 {"space":"at://did:plc:communitytest/space/at.linji.space/webboard","posts":[{"text":"API 帖一"}]} +ok 200 {"uri":"at://did:plc:davetest/at.linji.post/3jzfq0212"} +ok 200 {"alreadyMember":true} +ok 200 {"ok":true} +ok 401 not logged in diff --git a/tests/fixtures/space_bad.tsv b/tests/fixtures/space_bad.tsv new file mode 100644 index 0000000..f628bcf --- /dev/null +++ b/tests/fixtures/space_bad.tsv @@ -0,0 +1,41 @@ +0 logged in as community.test +0 logged in as alice.test +0 logged in as bob.test +0 logged in as eve.test +0 using community.test +0 at://did:plc:communitytest/space/at.linji.space/general +0 added did:plc:alicetest +0 added did:plc:bobtest +0 did:plc:alicetest alice.test\ndid:plc:bobtest bob.test +0 at://did:plc:alicetest/at.linji.thread/3jzfq0001 +0 at://did:plc:bobtest/at.linji.thread/3jzfq0002 +0 == 课文讨论 ==\n== 随喜 == +0 at://did:plc:alicetest/at.linji.post/3jzfq0101 +0 at://did:plc:bobtest/at.linji.post/3jzfq0102 +0 at://did:plc:bobtest/at.linji.post/3jzfq0103 +0 == 课文讨论 ==\n空间里的第一条,alice。\n空间里的第二条,bob。 #BlueSky\n岔出去的一句。 +0 == 课文讨论 ==\n空间里的第一条,alice。\n空间里的第二条,bob。 #BlueSky +0 moved +0 == 课文讨论 ==\n空间里的第一条,alice。\n空间里的第二条,bob。 #BlueSky\n岔出去的一句。\n== 随喜 ==\n↦ 一条消息被移动到「课文讨论」 +0 moved +1 denied +1 denied +1 denied +0 edited +0 edited +0 == 课文讨论 ==\n空间里的第一条,alice。\n空间里的第二条,bob。(社区修订) #BlueSky\n岔出去的一句。\n== 随喜 == +0 at://did:plc:bobtest/at.linji.post/3jzfq0104 +0 deleted +0 == 课文讨论 ==\n空间里的第一条,alice。\n空间里的第二条,bob。(社区修订) #BlueSky\n岔出去的一句。\n== 随喜 == +1 denied +1 denied +1 denied +0 removed +1 denied +1 denied +0 added +0 at://did:plc:bobtest/at.linji.post/3jzfq0105 +0 == 课文讨论 ==\n空间里的第一条,alice。\n空间里的第二条,bob。(社区修订) #BlueSky\n岔出去的一句。\n回来后的第一条。\n== 随喜 == +0 ## 课文讨论\n**alice.test** 空间里的第一条,alice。\n**bob.test** 空间里的第二条,bob。(社区修订) #BlueSky\n**bob.test** 回来后的第一条。 +0 ## 随喜\n**bob.test** 岔出去的一句。 +1 denied diff --git a/tests/fixtures/space_good.tsv b/tests/fixtures/space_good.tsv new file mode 100644 index 0000000..ecdc76e --- /dev/null +++ b/tests/fixtures/space_good.tsv @@ -0,0 +1,41 @@ +0 logged in as community.test +0 logged in as alice.test +0 logged in as bob.test +0 logged in as eve.test +0 using community.test +0 at://did:plc:communitytest/space/at.linji.space/general +0 added did:plc:alicetest +0 added did:plc:bobtest +0 did:plc:alicetest alice.test\ndid:plc:bobtest bob.test +0 at://did:plc:alicetest/at.linji.thread/3jzfq0001 +0 at://did:plc:bobtest/at.linji.thread/3jzfq0002 +0 == 课文讨论 ==\n== 随喜 == +0 at://did:plc:alicetest/at.linji.post/3jzfq0101 +0 at://did:plc:bobtest/at.linji.post/3jzfq0102 +0 at://did:plc:bobtest/at.linji.post/3jzfq0103 +0 == 课文讨论 ==\n空间里的第一条,alice。\n空间里的第二条,bob。 #BlueSky\n== 随喜 ==\n岔出去的一句。 +0 == 课文讨论 ==\n空间里的第一条,alice。\n空间里的第二条,bob。 #BlueSky +0 moved +0 == 课文讨论 ==\n空间里的第一条,alice。\n空间里的第二条,bob。 #BlueSky\n岔出去的一句。\n== 随喜 ==\n↦ 一条消息被移动到「课文讨论」 +0 moved +1 denied +1 denied +1 denied +0 edited +0 edited +0 == 课文讨论 ==\n空间里的第一条,alice。\n空间里的第二条,bob。(社区修订) #BlueSky\n岔出去的一句。\n== 随喜 == +0 at://did:plc:bobtest/at.linji.post/3jzfq0104 +0 deleted +0 == 课文讨论 ==\n空间里的第一条,alice。\n空间里的第二条,bob。(社区修订) #BlueSky\n岔出去的一句。\n== 随喜 == +1 denied +1 denied +1 denied +0 removed +1 denied +1 denied +0 added +0 at://did:plc:bobtest/at.linji.post/3jzfq0105 +0 == 课文讨论 ==\n空间里的第一条,alice。\n空间里的第二条,bob。(社区修订) #BlueSky\n岔出去的一句。\n回来后的第一条。\n== 随喜 == +0 ## 课文讨论\n**alice.test** 空间里的第一条,alice。\n**bob.test** 空间里的第二条,bob。(社区修订) #BlueSky\n**bob.test** 回来后的第一条。 +0 ## 随喜\n**bob.test** 岔出去的一句。 +1 denied diff --git a/tests/fixtures/space_hang.tsv b/tests/fixtures/space_hang.tsv new file mode 100644 index 0000000..9b8a900 --- /dev/null +++ b/tests/fixtures/space_hang.tsv @@ -0,0 +1,41 @@ +0 logged in as community.test +0 logged in as alice.test +0 logged in as bob.test +0 logged in as eve.test +0 using community.test +0 at://did:plc:communitytest/space/at.linji.space/general +0 added did:plc:alicetest +0 added did:plc:bobtest +0 did:plc:alicetest alice.test\ndid:plc:bobtest bob.test +0 at://did:plc:alicetest/at.linji.thread/3jzfq0001 +0 at://did:plc:bobtest/at.linji.thread/3jzfq0002 +0 == 课文讨论 ==\n== 随喜 == +0 at://did:plc:alicetest/at.linji.post/3jzfq0101 +0 at://did:plc:bobtest/at.linji.post/3jzfq0102 +0 at://did:plc:bobtest/at.linji.post/3jzfq0103 +hang 60000 +0 == 课文讨论 ==\n空间里的第一条,alice。\n空间里的第二条,bob。 #BlueSky +0 moved +0 == 课文讨论 ==\n空间里的第一条,alice。\n空间里的第二条,bob。 #BlueSky\n岔出去的一句。\n== 随喜 ==\n↦ 一条消息被移动到「课文讨论」 +0 moved +1 denied +1 denied +1 denied +0 edited +0 edited +0 == 课文讨论 ==\n空间里的第一条,alice。\n空间里的第二条,bob。(社区修订) #BlueSky\n岔出去的一句。\n== 随喜 == +0 at://did:plc:bobtest/at.linji.post/3jzfq0104 +0 deleted +0 == 课文讨论 ==\n空间里的第一条,alice。\n空间里的第二条,bob。(社区修订) #BlueSky\n岔出去的一句。\n== 随喜 == +1 denied +1 denied +1 denied +0 removed +1 denied +1 denied +0 added +0 at://did:plc:bobtest/at.linji.post/3jzfq0105 +0 == 课文讨论 ==\n空间里的第一条,alice。\n空间里的第二条,bob。(社区修订) #BlueSky\n岔出去的一句。\n回来后的第一条。\n== 随喜 == +0 ## 课文讨论\n**alice.test** 空间里的第一条,alice。\n**bob.test** 空间里的第二条,bob。(社区修订) #BlueSky\n**bob.test** 回来后的第一条。 +0 ## 随喜\n**bob.test** 岔出去的一句。 +1 denied diff --git a/tests/harness.zig b/tests/harness.zig new file mode 100644 index 0000000..0c7eeb0 --- /dev/null +++ b/tests/harness.zig @@ -0,0 +1,457 @@ +//! Shared scenario harness — the assertion helpers, rig lifecycle, and +//! subprocess drivers the scenario executables (`scenario_space.zig`, +//! `scenario_serve.zig`) are built on. +//! +//! Everything here is deliberately rig-agnostic: binaries and ports come +//! from environment variables (`LINJI_ZDS_BIN`, `LINJI_BIN`, +//! `LINJI_SERVE_BIN`, `LINJI_ZDS_PORT`, `LINJI_API_PORT`), so the same +//! code runs against the real zds/linji or against scripted fakes (see +//! `zig build scenario-self`). +//! +//! The `test` blocks at the bottom are the L1 self-tests: they prove the +//! gate's own machinery — every assertion helper in both polarities, the +//! parsers, and the curl API driver against an in-process stub server. +//! No rig, no network, milliseconds. + +const std = @import("std"); +const builtin = @import("builtin"); +const zat = @import("zat"); +const httpz = @import("httpz"); + +var app_threaded_io: std.Io.Threaded = undefined; +pub const std_options_debug_threaded_io: ?*std.Io.Threaded = &app_threaded_io; + +/// Scenario output goes to stdout (fd 1 via libc — no Io dependency, so +/// it is safe in unit tests where the global threaded io is not yet +/// initialized). std.debug.print would go to stderr, which the build +/// runner captures and echoes as a bogus "failed command" on green runs. +/// In test binaries fd 1 is the `--listen=-` protocol channel, so there +/// the output is suppressed entirely. +fn scenarioOut(comptime fmt: []const u8, args: anytype) void { + if (builtin.is_test) return; + var buf: [8192]u8 = undefined; + const s = std.fmt.bufPrint(&buf, fmt, args) catch return; + _ = std.c.write(std.c.STDOUT_FILENO, s.ptr, s.len); +} + +pub const ApiResult = struct { + code: u16, + body: []const u8, +}; + +// ---------------------------------------------------------------- env + +/// Env override with a fallback. The self-tests point the harness at the +/// fakes through these. +pub fn envOr(name: [*:0]const u8, fallback: []const u8) []const u8 { + return if (std.c.getenv(name)) |v| std.mem.span(v) else fallback; +} + +/// Port from `LINJI_ZDS_PORT` / `LINJI_API_PORT`; defaults keep the +/// historical fixed ports, overrides let concurrent runs avoid collision. +pub fn envPort(allocator: std.mem.Allocator, name: [*:0]const u8, fallback: u16) !u16 { + _ = allocator; + const v = std.c.getenv(name) orelse return fallback; + return std.fmt.parseInt(u16, std.mem.span(v), 10) catch fallback; +} + +/// Milliseconds before a harness subprocess call is killed, from +/// `LINJI_CMD_TIMEOUT_MS` (default 30s). The self-test hang case sets it +/// short to prove a stuck CLI is reaped, not wedged. +fn cmdTimeoutMs() u32 { + const v = std.c.getenv("LINJI_CMD_TIMEOUT_MS") orelse return 30_000; + return std.fmt.parseInt(u32, std.mem.span(v), 10) catch 30_000; +} + +// ---------------------------------------------------------------- fs + +/// mkdir -p with mode 0700. +pub fn mkdirs(path: []const u8) !void { + var buf: [4096]u8 = undefined; + var index: usize = 0; + while (index < path.len) : (index += 1) { + if (path[index] != std.fs.path.sep or index == 0) continue; + try mkdirOne(path[0..index], &buf); + } + try mkdirOne(path, &buf); +} + +fn mkdirOne(path: []const u8, buf: []u8) !void { + if (path.len == 0 or path.len >= buf.len) return error.PathTooLong; + @memcpy(buf[0..path.len], path); + buf[path.len] = 0; + const path_z: [:0]const u8 = buf[0..path.len :0]; + if (std.c.mkdir(path_z.ptr, 0o700) == 0) return; + const err: std.posix.E = @enumFromInt(std.posix.system._errno().*); + if (err == .EXIST) return; + return error.CreateDirFailed; +} + +pub fn writeFile(io: std.Io, path: []const u8, content: []const u8) !void { + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = content }); +} + +// ---------------------------------------------------------------- http + +/// GET, returning whatever status the server answered (or transport error). +pub fn httpAny(allocator: std.mem.Allocator, io: std.Io, url: []const u8) !std.http.Status { + var transport = zat.HttpTransport.init(io, allocator); + defer transport.deinit(); + var result = try transport.fetch(.{ .url = url, .accept = "application/json" }); + defer result.deinit(allocator); + return result.status; +} + +/// Poll `/{base}/xrpc/_health` until it answers 200 (zds boot wait). +pub fn waitForHealth(allocator: std.mem.Allocator, io: std.Io, base: []const u8) !void { + var i: usize = 0; + while (i < 200) : (i += 1) { + const url = try std.fmt.allocPrint(allocator, "{s}/xrpc/_health", .{base}); + if (httpAny(allocator, io, url)) |status| { + if (status == .ok) return; + } else |_| {} + io.sleep(std.Io.Duration.fromMilliseconds(150), .awake) catch {}; + } + return error.ZdsDidNotStart; +} + +/// Poll until any HTTP response (server boot wait). +pub fn waitForHttp(allocator: std.mem.Allocator, io: std.Io, base: []const u8) !void { + var i: usize = 0; + while (i < 200) : (i += 1) { + if (httpAny(allocator, io, base)) |_| return else |_| {} + io.sleep(std.Io.Duration.fromMilliseconds(150), .awake) catch {}; + } + return error.ServeDidNotStart; +} + +/// One API call through curl: cookie jar in/out, JSON body for POSTs. +/// `base` is the API origin; `path` may already be a full URL. +pub fn api(allocator: std.mem.Allocator, io: std.Io, base: []const u8, jar: ?[]const u8, method: []const u8, path: []const u8, body: ?[]const u8) !ApiResult { + var argv: std.ArrayList([]const u8) = .empty; + try argv.appendSlice(allocator, &.{ "curl", "-s", "-X", method, "-w", "\n%{http_code}" }); + if (jar) |j| try argv.appendSlice(allocator, &.{ "-b", j, "-c", j }); + if (body) |b| try argv.appendSlice(allocator, &.{ "-H", "content-type: application/json", "-d", b }); + const url = if (std.mem.startsWith(u8, path, "http")) path else try std.fmt.allocPrint(allocator, "{s}{s}", .{ base, path }); + try argv.append(allocator, url); + const r = try std.process.run(allocator, io, .{ .argv = argv.items }); + try expectExited(r, "curl"); + const trimmed = std.mem.trimEnd(u8, r.stdout, "\n"); + const nl = std.mem.lastIndexOfScalar(u8, trimmed, '\n') orelse return error.ScenarioFailed; + return .{ + .code = try std.fmt.parseInt(u16, trimmed[nl + 1 ..], 10), + .body = trimmed[0..nl], + }; +} + +// ---------------------------------------------------------------- assertions + +/// Checkpoint that must pass: the process exited 0. +pub fn expectExited(r: std.process.RunResult, label: []const u8) !void { + switch (r.term) { + .exited => |code| { + if (code != 0) { + scenarioOut("FAIL {s}: exit {d}\nstderr: {s}\n", .{ label, code, r.stderr }); + return error.ScenarioFailed; + } + }, + else => { + scenarioOut("FAIL {s}: abnormal term\n", .{label}); + return error.ScenarioFailed; + }, + } +} + +/// Checkpoint that must pass: `needle` present in `haystack`. +pub fn expectContains(haystack: []const u8, needle: []const u8, label: []const u8) !void { + if (std.mem.indexOf(u8, haystack, needle) == null) { + scenarioOut("FAIL {s}: missing {s} in:\n{s}\n", .{ label, needle, haystack }); + return error.ScenarioFailed; + } + scenarioOut(" ok {s}\n", .{label}); +} + +/// Checkpoint that must pass: `needle` absent from `haystack`. +pub fn expectNotContains(haystack: []const u8, needle: []const u8, label: []const u8) !void { + if (std.mem.indexOf(u8, haystack, needle) != null) { + scenarioOut("FAIL {s}: unexpected {s} in:\n{s}\n", .{ label, needle, haystack }); + return error.ScenarioFailed; + } + scenarioOut(" ok {s}\n", .{label}); +} + +/// Checkpoint that must pass: `first` appears strictly before `second`. +pub fn expectOrder(haystack: []const u8, first: []const u8, second: []const u8, label: []const u8) !void { + const a = std.mem.indexOf(u8, haystack, first) orelse { + scenarioOut("FAIL {s}: missing first text {s}\n{s}\n", .{ label, first, haystack }); + return error.ScenarioFailed; + }; + const b = std.mem.indexOf(u8, haystack, second) orelse { + scenarioOut("FAIL {s}: missing second text {s}\n{s}\n", .{ label, second, haystack }); + return error.ScenarioFailed; + }; + if (a >= b) { + scenarioOut("FAIL {s}: wrong order in:\n{s}\n", .{ label, haystack }); + return error.ScenarioFailed; + } + scenarioOut(" ok {s}\n", .{label}); +} + +/// Checkpoint that must pass: HTTP status matches. +pub fn expectCode(actual: u16, expected: u16, label: []const u8) !void { + if (actual != expected) { + scenarioOut("FAIL {s}: http {d}, expected {d}\n", .{ label, actual, expected }); + return error.ScenarioFailed; + } + scenarioOut(" ok {s}\n", .{label}); +} + +// ---------------------------------------------------------------- linji driver + +/// Run the linji CLI with LINJI_HOME set and a bounded wait; returns the +/// captured result (stdout/stderr are caller-owned — arena in the +/// scenario mains). +pub fn runLinji(allocator: std.mem.Allocator, io: std.Io, home: []const u8, bin: []const u8, argv: []const []const u8) !std.process.RunResult { + var env = std.process.Environ.Map.init(allocator); + try env.put("LINJI_HOME", home); + // Passthrough for the fake-CLI fixture selector (self-tests only). + if (std.c.getenv("LINJI_FAKE_FIXTURE")) |f| try env.put("LINJI_FAKE_FIXTURE", std.mem.span(f)); + var full: std.ArrayList([]const u8) = .empty; + try full.append(allocator, bin); + try full.appendSlice(allocator, argv); + return std.process.run(allocator, io, .{ + .argv = full.items, + .environ_map = &env, + .timeout = .{ .deadline = std.Io.Clock.Timestamp.fromNow(io, .{ + .raw = std.Io.Duration.fromMilliseconds(cmdTimeoutMs()), + .clock = .real, + }) }, + }); +} + +/// Checkpoint driver: the CLI must exit 0. +pub fn linji(allocator: std.mem.Allocator, io: std.Io, home: []const u8, bin: []const u8, argv: []const []const u8, label: []const u8) !std.process.RunResult { + const r = runLinji(allocator, io, home, bin, argv) catch |err| { + if (err == error.Timeout) { + scenarioOut("FAIL {s}: timed out after {d}ms\n", .{ label, cmdTimeoutMs() }); + return error.ScenarioFailed; + } + return err; + }; + try expectExited(r, label); + scenarioOut(" ok {s}\n", .{label}); + return r; +} + +/// Checkpoint driver: the CLI must exit NON-zero (server refusal). +pub fn linjiFails(allocator: std.mem.Allocator, io: std.Io, home: []const u8, bin: []const u8, argv: []const []const u8, label: []const u8) !void { + const r = runLinji(allocator, io, home, bin, argv) catch |err| { + if (err == error.Timeout) { + scenarioOut("FAIL {s}: timed out after {d}ms\n", .{ label, cmdTimeoutMs() }); + return error.ScenarioFailed; + } + return err; + }; + switch (r.term) { + .exited => |code| { + if (code == 0) { + scenarioOut("FAIL {s}: expected failure but exited 0\nstdout: {s}\n", .{ label, r.stdout }); + return error.ScenarioFailed; + } + }, + else => { + scenarioOut("FAIL {s}: abnormal term\nstderr: {s}\n", .{ label, r.stderr }); + return error.ScenarioFailed; + }, + } + scenarioOut(" ok {s}\n", .{label}); +} + +// ---------------------------------------------------------------- parsing + +/// Extract the value of a flat string field from a small JSON object. +pub fn takeJsonString(allocator: std.mem.Allocator, json: []const u8, key: []const u8) ![]const u8 { + const needle = try std.fmt.allocPrint(allocator, "\"{s}\":\"", .{key}); + const start = (std.mem.indexOf(u8, json, needle) orelse return error.ScenarioFailed) + needle.len; + const rest = json[start..]; + const end = std.mem.indexOfScalar(u8, rest, '"') orelse return error.ScenarioFailed; + return rest[0..end]; +} + +/// The space URI is the whole stdout of `space create` (no prefix). +pub fn takeSpaceUri(allocator: std.mem.Allocator, out: []const u8) ![]const u8 { + const trimmed = std.mem.trim(u8, out, "\r\n "); + if (!std.mem.startsWith(u8, trimmed, "at://")) { + scenarioOut("FAIL: no at-uri in:\n{s}\n", .{out}); + return error.ScenarioFailed; + } + return allocator.dupe(u8, trimmed); +} + +// ---------------------------------------------------------------- rig + +pub const ZdsOpts = struct { + port: u16, + base: []const u8, + db_path: []const u8, + blobs_dir: []const u8, +}; + +/// Boot a fresh zds with dev tools + permissioned data. The caller owns +/// the child and must kill it (scenarios `defer` that). +pub fn spawnZds(allocator: std.mem.Allocator, io: std.Io, bin: []const u8, opts: ZdsOpts) !std.process.Child { + const port_str = try std.fmt.allocPrint(allocator, "{d}", .{opts.port}); + var env = std.process.Environ.Map.init(allocator); + try env.put("ZDS_DEV_TOOLS", "true"); + try env.put("ZDS_PERMISSIONED_DATA", "true"); + return std.process.spawn(io, .{ + .argv = &.{ + bin, + "--host", "127.0.0.1", + "--port", port_str, + "--db", opts.db_path, + "--blobstore-path", opts.blobs_dir, + "--public-url", opts.base, + "--server-did", "did:web:localhost", + "--handle-domains", ".test", + "--admin-token", "scenario-admin", + }, + .environ_map = &env, + .stdout = .ignore, + .stderr = .ignore, + }); +} + +/// Seed accounts directly in the zds sqlite db (the sandbox pattern — +/// createAccount needs the live PLC directory). +pub fn seedAccounts(allocator: std.mem.Allocator, io: std.Io, db_path: []const u8, names: []const []const u8) !void { + for (names) |name| { + const sql = try std.fmt.allocPrint(allocator, + "insert into accounts (did, handle, email, password_hash, activated_at, email_confirmed_at) values ('did:plc:{s}test', '{s}.test', '{s}@example.com', '{s}-password', unixepoch(), unixepoch())", + .{ name, name, name, name }); + const r = try std.process.run(allocator, io, .{ .argv = &.{ "sqlite3", db_path, sql } }); + try expectExited(r, "sqlite seed"); + } +} + +// ================================================================ L1 self-tests +// +// The scenario suite is the gate for zds upgrades; these tests prove the +// gate's own machinery. The regress terminates here: fixtures are +// hand-verified oracles, never products of more tests. + +test "expectContains accepts and rejects" { + try expectContains("hello world", "world", "present"); + try std.testing.expectError(error.ScenarioFailed, expectContains("hello world", "nope", "absent")); +} + +test "expectNotContains accepts and rejects" { + try expectNotContains("hello world", "nope", "absent"); + try std.testing.expectError(error.ScenarioFailed, expectNotContains("hello world", "world", "present")); +} + +test "expectOrder accepts order and rejects all three failure shapes" { + try expectOrder("a then b", "a", "b", "ordered"); + try std.testing.expectError(error.ScenarioFailed, expectOrder("b then a", "a", "b", "reversed")); + try std.testing.expectError(error.ScenarioFailed, expectOrder("only b", "a", "b", "missing first")); + try std.testing.expectError(error.ScenarioFailed, expectOrder("only a", "a", "b", "missing second")); + // strictly ordered: identical texts are rejected + try std.testing.expectError(error.ScenarioFailed, expectOrder("x x", "x", "x", "identical")); +} + +test "expectCode matches and rejects" { + try expectCode(200, 200, "match"); + try std.testing.expectError(error.ScenarioFailed, expectCode(403, 200, "mismatch")); +} + +test "expectExited passes on exit 0 and fails on non-zero" { + const ok = std.process.RunResult{ .term = .{ .exited = 0 }, .stdout = &.{}, .stderr = &.{} }; + try expectExited(ok, "exit 0"); + const bad = std.process.RunResult{ .term = .{ .exited = 1 }, .stdout = &.{}, .stderr = &.{} }; + try std.testing.expectError(error.ScenarioFailed, expectExited(bad, "exit 1")); +} + +test "takeJsonString extracts flat string fields" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + try std.testing.expectEqualStrings("did:plc:alicetest", try takeJsonString(a, "{\"did\":\"did:plc:alicetest\",\"handle\":\"alice.test\"}", "did")); + try std.testing.expectError(error.ScenarioFailed, takeJsonString(a, "{\"did\":123}", "did")); + try std.testing.expectError(error.ScenarioFailed, takeJsonString(a, "{}", "missing")); +} + +test "takeSpaceUri trims and validates" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + try std.testing.expectEqualStrings( + "at://did:plc:communitytest/space/at.linji.space/general", + try takeSpaceUri(a, " at://did:plc:communitytest/space/at.linji.space/general\n"), + ); + try std.testing.expectError(error.ScenarioFailed, takeSpaceUri(a, "not a uri")); +} + +const Stub = struct { + fn handler(_: *Stub, req: *httpz.Request, res: *httpz.Response) !void { + if (req.method == .POST) { + res.status = 201; + res.body = "{\"uri\":\"at://did:plc:alicetest/at.linji.post/x\"}"; + } else { + res.status = 401; + res.body = "nope"; + } + } +}; + +fn serveThread(server: *httpz.Server(*Stub)) void { + server.listen() catch {}; +} + +test "api() drives curl and splits http code from body" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var threaded_io = std.Io.Threaded.init(allocator, .{ + .async_limit = .limited(4), + .concurrent_limit = .limited(4), + }); + const io = threaded_io.io(); + app_threaded_io = threaded_io; + + var stub: Stub = .{}; + var server = try httpz.Server(*Stub).init(io, allocator, .{ + .address = .{ .ip = .{ .ip4 = try std.Io.net.Ip4Address.parse("127.0.0.1", 2599) } }, + }, &stub); + const router = try server.router(.{}); + router.get("/x", Stub.handler, .{}); + router.post("/x", Stub.handler, .{}); + + const serve = try std.Thread.spawn(.{}, serveThread, .{&server}); + // Poll until the stub answers (a fixed sleep raced the build runner + // under load — first-run flake). + const base = "http://127.0.0.1:2599"; + var up = false; + var i: usize = 0; + while (i < 40) : (i += 1) { + if (httpAny(allocator, io, base ++ "/x")) |_| { + up = true; + break; + } else |_| {} + io.sleep(std.Io.Duration.fromMilliseconds(50), .awake) catch {}; + } + try std.testing.expect(up); + + const got = try api(allocator, io, base, null, "GET", "/x", null); + try std.testing.expectEqual(@as(u16, 401), got.code); + try std.testing.expectEqualStrings("nope", got.body); + + const posted = try api(allocator, io, base, null, "POST", "/x", "{}"); + try std.testing.expectEqual(@as(u16, 201), posted.code); + try std.testing.expectEqualStrings("{\"uri\":\"at://did:plc:alicetest/at.linji.post/x\"}", posted.body); + + server.stop(); + serve.join(); + server.deinit(); + threaded_io.deinit(); +} diff --git a/tests/scenario_serve.zig b/tests/scenario_serve.zig index 4a98232..32f9f84 100644 --- a/tests/scenario_serve.zig +++ b/tests/scenario_serve.zig @@ -10,16 +10,17 @@ //! //! Run from app0/: `zig build scenario-serve` after building zds: //! (cd ../zds && zig build -Dtarget=x86_64-linux-gnu.2.36) +//! +//! Self-test mode (no zds build needed): `zig build scenario-self` drives +//! this same executable against scripted fakes via LINJI_ZDS_BIN / +//! LINJI_BIN / LINJI_SERVE_BIN — see tests/fixtures/. const std = @import("std"); const zat = @import("zat"); +const harness = @import("harness"); var app_threaded_io: std.Io.Threaded = undefined; - -const zds_port = 2592; -const zds_base = "http://127.0.0.1:" ++ std.fmt.comptimePrint("{d}", .{zds_port}); -const api_port = 8791; -const api_base = "http://127.0.0.1:" ++ std.fmt.comptimePrint("{d}", .{api_port}); +pub const std_options_debug_threaded_io: ?*std.Io.Threaded = &app_threaded_io; pub fn main(init: std.process.Init) !void { _ = init; @@ -32,170 +33,159 @@ pub fn main(init: std.process.Init) !void { }); const io = app_threaded_io.io(); - const zds_bin = envOr("LINJI_ZDS_BIN", "../zds/zig-out/bin/zds"); - const linji_bin = envOr("LINJI_BIN", "zig-out/bin/linji"); + const zds_bin = harness.envOr("LINJI_ZDS_BIN", "../zds/zig-out/bin/zds"); + const linji_bin = harness.envOr("LINJI_BIN", "zig-out/bin/linji"); + const serve_bin = harness.envOr("LINJI_SERVE_BIN", linji_bin); + const zds_port = try harness.envPort(allocator, "LINJI_ZDS_PORT", 2592); + const api_port = try harness.envPort(allocator, "LINJI_API_PORT", 8791); + const zds_base = try std.fmt.allocPrint(allocator, "http://127.0.0.1:{d}", .{zds_port}); + const api_base = try std.fmt.allocPrint(allocator, "http://127.0.0.1:{d}", .{api_port}); const work = try std.fmt.allocPrint(allocator, "/tmp/linji-scenario-serve-{d}", .{std.os.linux.getpid()}); std.Io.Dir.cwd().deleteTree(io, work) catch {}; defer std.Io.Dir.cwd().deleteTree(io, work) catch {}; - try mkdirs(try std.fs.path.join(allocator, &.{ work, "blobs" })); + try harness.mkdirs(try std.fs.path.join(allocator, &.{ work, "blobs" })); const home = try std.fs.path.join(allocator, &.{ work, "home" }); const web = try std.fs.path.join(allocator, &.{ work, "web" }); - try mkdirs(web); - try writeFile(io, try std.fs.path.join(allocator, &.{ web, "index.html" }), "linji pwa placeholder"); + try harness.mkdirs(web); + try harness.writeFile(io, try std.fs.path.join(allocator, &.{ web, "index.html" }), "linji pwa placeholder"); std.debug.print("== scenario BFF serve ==\nwork: {s}\n", .{work}); // --- boot zds --- - var env = std.process.Environ.Map.init(allocator); - try env.put("ZDS_DEV_TOOLS", "true"); - try env.put("ZDS_PERMISSIONED_DATA", "true"); const db_path = try std.fs.path.join(allocator, &.{ work, "db.sqlite3" }); - var zds = try std.process.spawn(io, .{ - .argv = &.{ - zds_bin, - "--host", "127.0.0.1", - "--port", std.fmt.comptimePrint("{d}", .{zds_port}), - "--db", db_path, - "--blobstore-path", try std.fs.path.join(allocator, &.{ work, "blobs" }), - "--public-url", zds_base, - "--server-did", "did:web:localhost", - "--handle-domains", ".test", - "--admin-token", "scenario-admin", - }, - .environ_map = &env, - .stdout = .ignore, - .stderr = .ignore, + var zds = try harness.spawnZds(allocator, io, zds_bin, .{ + .port = zds_port, + .base = zds_base, + .db_path = db_path, + .blobs_dir = try std.fs.path.join(allocator, &.{ work, "blobs" }), }); defer zds.kill(io); - try waitForHealth(allocator, io); + try harness.waitForHealth(allocator, io, zds_base); std.debug.print("zds up on {s}\n", .{zds_base}); // --- seed + CLI setup (space owned by community, alice + bob members) --- const seeds = [_][]const u8{ "community", "alice", "bob", "carol", "dave" }; - for (seeds) |name| { - const sql = try std.fmt.allocPrint(allocator, - "insert into accounts (did, handle, email, password_hash, activated_at, email_confirmed_at) values ('did:plc:{s}test', '{s}.test', '{s}@example.com', '{s}-password', unixepoch(), unixepoch())", - .{ name, name, name, name }); - const r = try std.process.run(allocator, io, .{ .argv = &.{ "sqlite3", db_path, sql } }); - try expectExited(r, "sqlite seed"); - } + try harness.seedAccounts(allocator, io, db_path, &seeds); for (seeds) |name| { const handle = try std.fmt.allocPrint(allocator, "{s}.test", .{name}); const password = try std.fmt.allocPrint(allocator, "{s}-password", .{name}); - _ = try linji(allocator, io, home, linji_bin, &.{ "login", handle, "--password", password, "--pds", zds_base }, handle); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "login", handle, "--password", password, "--pds", zds_base }, handle); } - _ = try linji(allocator, io, home, linji_bin, &.{ "use", "community.test" }, "use community"); - const space_out = try linji(allocator, io, home, linji_bin, &.{ "space", "create", "--skey", "webboard" }, "create space"); - const space = try takeSpaceUri(allocator, space_out); - _ = try linji(allocator, io, home, linji_bin, &.{ "space", "add-member", "--space", space, "--did", "did:plc:alicetest" }, "add alice"); - _ = try linji(allocator, io, home, linji_bin, &.{ "space", "add-member", "--space", space, "--did", "did:plc:bobtest" }, "add bob"); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "use", "community.test" }, "use community"); + const space_out = try harness.linji(allocator, io, home, linji_bin, &.{ "space", "create", "--skey", "webboard" }, "create space"); + const space = try harness.takeSpaceUri(allocator, space_out.stdout); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "space", "add-member", "--space", space, "--did", "did:plc:alicetest" }, "add alice"); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "space", "add-member", "--space", space, "--did", "did:plc:bobtest" }, "add bob"); // --- boot linji serve --- var serve_env = std.process.Environ.Map.init(allocator); try serve_env.put("LINJI_HOME", home); + // Passthrough for the fake-serve fixture selector (self-tests only). + if (std.c.getenv("LINJI_FAKE_SERVE_FIXTURE")) |f| try serve_env.put("LINJI_FAKE_SERVE_FIXTURE", std.mem.span(f)); + const api_port_str = try std.fmt.allocPrint(allocator, "{d}", .{api_port}); var server = try std.process.spawn(io, .{ - .argv = &.{ linji_bin, "serve", "--port", std.fmt.comptimePrint("{d}", .{api_port}), "--base", api_base, "--pds", zds_base, "--web", web, "--handle-domain", "test" }, + .argv = &.{ serve_bin, "serve", "--port", api_port_str, "--base", api_base, "--pds", zds_base, "--web", web, "--handle-domain", "test" }, .environ_map = &serve_env, .stdout = .ignore, .stderr = .ignore, }); defer server.kill(io); - try waitForServe(allocator, io); + try harness.waitForHttp(allocator, io, api_base); std.debug.print("serve up on {s}\n", .{api_base}); const jar_alice = try std.fs.path.join(allocator, &.{ work, "cookies-alice" }); const jar_bob = try std.fs.path.join(allocator, &.{ work, "cookies-bob" }); // --- 0. config + invite gate --- - var res = try api(allocator, io, null, "GET", "/api/config", null); - try expectCode(res.code, 200, "/api/config"); - try expectContains(res.body, "\"handleDomain\":\"test\"", "config domain"); - try expectContains(res.body, "\"vapidKey\":\"", "config vapid key"); + var res = try harness.api(allocator, io, api_base, null, "GET", "/api/config", null); + try harness.expectCode(res.code, 200, "/api/config"); + try harness.expectContains(res.body, "\"handleDomain\":\"test\"", "config domain"); + try harness.expectContains(res.body, "\"vapidKey\":\"", "config vapid key"); // bare name + domain suffix: "alice" resolves to alice.test; no invite // gate - sign-in only needs an Internet handle. - res = try api(allocator, io, jar_alice, "GET", "/api/login?handle=alice&password=alice-password", null); - try expectCode(res.code, 200, "bare-name login alice"); - try expectContains(res.body, "alice.test", "bare name normalized"); + res = try harness.api(allocator, io, api_base, jar_alice, "GET", "/api/login?handle=alice&password=alice-password", null); + try harness.expectCode(res.code, 200, "bare-name login alice"); + try harness.expectContains(res.body, "alice.test", "bare name normalized"); // --- 1. unauthenticated --- - res = try api(allocator, io, null, "GET", "/api/me", null); - try expectCode(res.code, 401, "unauth /api/me"); + res = try harness.api(allocator, io, api_base, null, "GET", "/api/me", null); + try harness.expectCode(res.code, 401, "unauth /api/me"); // --- 2. scripted login --- - res = try api(allocator, io, jar_alice, "GET", "/api/login?handle=alice.test&password=alice-password", null); - try expectCode(res.code, 200, "scripted login alice"); - try expectContains(res.body, "alicetest", "login body has did"); + res = try harness.api(allocator, io, api_base, jar_alice, "GET", "/api/login?handle=alice.test&password=alice-password", null); + try harness.expectCode(res.code, 200, "scripted login alice"); + try harness.expectContains(res.body, "alicetest", "login body has did"); // --- 3. session endpoints --- - res = try api(allocator, io, jar_alice, "GET", "/api/me", null); - try expectCode(res.code, 200, "/api/me"); - try expectContains(res.body, "alice.test", "/api/me handle"); + res = try harness.api(allocator, io, api_base, jar_alice, "GET", "/api/me", null); + try harness.expectCode(res.code, 200, "/api/me"); + try harness.expectContains(res.body, "alice.test", "/api/me handle"); - res = try api(allocator, io, jar_alice, "POST", "/api/thread", try std.fmt.allocPrint(allocator, + res = try harness.api(allocator, io, api_base, jar_alice, "POST", "/api/thread", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"title\":\"服务器上的线程\"}}", .{space})); - try expectCode(res.code, 200, "POST /api/thread"); - const thread_uri = try takeJsonString(allocator, res.body, "uri"); + try harness.expectCode(res.code, 200, "POST /api/thread"); + const thread_uri = try harness.takeJsonString(allocator, res.body, "uri"); - res = try api(allocator, io, jar_alice, "POST", "/api/post", try std.fmt.allocPrint(allocator, + res = try harness.api(allocator, io, api_base, jar_alice, "POST", "/api/post", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"thread\":\"{s}\",\"text\":\"API 帖一\"}}", .{ space, thread_uri })); - try expectCode(res.code, 200, "POST /api/post"); + try harness.expectCode(res.code, 200, "POST /api/post"); - res = try api(allocator, io, jar_alice, "GET", try std.fmt.allocPrint(allocator, "/api/space?uri={s}", .{space}), null); - try expectCode(res.code, 200, "/api/space"); - try expectContains(res.body, "API 帖一", "/api/space has post"); - try expectContains(res.body, "服务器上的线程", "/api/space has thread title"); + res = try harness.api(allocator, io, api_base, jar_alice, "GET", try std.fmt.allocPrint(allocator, "/api/space?uri={s}", .{space}), null); + try harness.expectCode(res.code, 200, "/api/space"); + try harness.expectContains(res.body, "API 帖一", "/api/space has post"); + try harness.expectContains(res.body, "服务器上的线程", "/api/space has thread title"); // --- 4. denial through the API: alice cannot delete bob's post --- - res = try api(allocator, io, jar_bob, "GET", "/api/login?handle=bob.test&password=bob-password", null); - try expectCode(res.code, 200, "scripted login bob"); - res = try api(allocator, io, jar_bob, "POST", "/api/post", try std.fmt.allocPrint(allocator, + res = try harness.api(allocator, io, api_base, jar_bob, "GET", "/api/login?handle=bob.test&password=bob-password", null); + try harness.expectCode(res.code, 200, "scripted login bob"); + res = try harness.api(allocator, io, api_base, jar_bob, "POST", "/api/post", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"thread\":\"{s}\",\"text\":\"bob 的帖子\"}}", .{ space, thread_uri })); - try expectCode(res.code, 200, "bob post"); - const bob_uri = try takeJsonString(allocator, res.body, "uri"); + try harness.expectCode(res.code, 200, "bob post"); + const bob_uri = try harness.takeJsonString(allocator, res.body, "uri"); - res = try api(allocator, io, jar_alice, "POST", "/api/delete", try std.fmt.allocPrint(allocator, + res = try harness.api(allocator, io, api_base, jar_alice, "POST", "/api/delete", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"post\":\"{s}\"}}", .{ space, bob_uri })); - try expectCode(res.code, 403, "alice delete bob's post denied"); + try harness.expectCode(res.code, 403, "alice delete bob's post denied"); // --- 5. export --- - res = try api(allocator, io, jar_alice, "GET", try std.fmt.allocPrint(allocator, "/api/export?space={s}&thread={s}", .{ space, thread_uri }), null); - try expectCode(res.code, 200, "/api/export"); - try expectContains(res.body, "## 服务器上的线程", "export heading"); - try expectContains(res.body, "API 帖一", "export body"); + res = try harness.api(allocator, io, api_base, jar_alice, "GET", try std.fmt.allocPrint(allocator, "/api/export?space={s}&thread={s}", .{ space, thread_uri }), null); + try harness.expectCode(res.code, 200, "/api/export"); + try harness.expectContains(res.body, "## 服务器上的线程", "export heading"); + try harness.expectContains(res.body, "API 帖一", "export body"); // --- 6. static PWA --- - res = try api(allocator, io, null, "GET", "/", null); - try expectCode(res.code, 200, "GET /"); - try expectContains(res.body, "linji pwa", "PWA served"); + res = try harness.api(allocator, io, api_base, null, "GET", "/", null); + try harness.expectCode(res.code, 200, "GET /"); + try harness.expectContains(res.body, "linji pwa", "PWA served"); // --- 6b. web push --- // Subscribe requires a session. - const push_endpoint = api_base ++ "/api/no-such-push-endpoint"; // our own API 404s it -> 'gone' + const push_endpoint = try std.fmt.allocPrint(allocator, "{s}/api/no-such-push-endpoint", .{api_base}); // our own API 404s it -> 'gone' const p256dh = "BGsX0fLhLEJH-Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWT-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU"; // P-256 base point, valid test key const sub_body = try std.fmt.allocPrint(allocator, "{{\"endpoint\":\"{s}\",\"keys\":{{\"p256dh\":\"{s}\",\"auth\":\"MDEyMzQ1Njc4OWFiY2RlZg\"}}}}", .{ push_endpoint, p256dh }); - res = try api(allocator, io, null, "POST", "/api/push/subscribe", sub_body); - try expectCode(res.code, 401, "subscribe without session"); - res = try api(allocator, io, jar_alice, "POST", "/api/push/subscribe", sub_body); - try expectCode(res.code, 200, "alice subscribe"); - res = try api(allocator, io, jar_alice, "POST", "/api/push/subscribe", sub_body); - try expectCode(res.code, 200, "alice re-subscribe (upsert)"); + res = try harness.api(allocator, io, api_base, null, "POST", "/api/push/subscribe", sub_body); + try harness.expectCode(res.code, 401, "subscribe without session"); + res = try harness.api(allocator, io, api_base, jar_alice, "POST", "/api/push/subscribe", sub_body); + try harness.expectCode(res.code, 200, "alice subscribe"); + res = try harness.api(allocator, io, api_base, jar_alice, "POST", "/api/push/subscribe", sub_body); + try harness.expectCode(res.code, 200, "alice re-subscribe (upsert)"); // The store file records it (one row despite the upsert). const subs_path = try std.fs.path.join(allocator, &.{ home, "push-subscriptions.json" }); var subs_data = try std.Io.Dir.cwd().readFileAlloc(io, subs_path, allocator, .limited(1 << 20)); - try expectContains(subs_data, push_endpoint, "subscription stored"); + try harness.expectContains(subs_data, push_endpoint, "subscription stored"); if (std.mem.count(u8, subs_data, "endpoint") != 2) { // "endpoint" key + value once each std.debug.print("FAIL: expected one subscription row, got:\n{s}\n", .{subs_data}); return error.ScenarioFailed; } // bob posts -> server pushes alice's subscription synchronously -> our // own API 404s the endpoint -> 'gone' -> the row is pruned. - res = try api(allocator, io, jar_bob, "POST", "/api/post", try std.fmt.allocPrint(allocator, + res = try harness.api(allocator, io, api_base, jar_bob, "POST", "/api/post", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"thread\":\"{s}\",\"text\":\"触发推送\"}}", .{ space, thread_uri })); - try expectCode(res.code, 200, "bob post triggers push"); + try harness.expectCode(res.code, 200, "bob post triggers push"); subs_data = try std.Io.Dir.cwd().readFileAlloc(io, subs_path, allocator, .limited(1 << 20)); if (std.mem.indexOf(u8, subs_data, push_endpoint) != null) { std.debug.print("FAIL: dead subscription not pruned:\n{s}\n", .{subs_data}); @@ -203,13 +193,13 @@ pub fn main(init: std.process.Init) !void { } std.debug.print("ok dead subscription pruned after push 404\n", .{}); // unsubscribe is idempotent. - res = try api(allocator, io, jar_alice, "POST", "/api/push/unsubscribe", try std.fmt.allocPrint(allocator, + res = try harness.api(allocator, io, api_base, jar_alice, "POST", "/api/push/unsubscribe", try std.fmt.allocPrint(allocator, "{{\"endpoint\":\"{s}\"}}", .{push_endpoint})); - try expectCode(res.code, 200, "unsubscribe"); + try harness.expectCode(res.code, 200, "unsubscribe"); // --- 6c. SSE: a post from another member emits `event: update` --- - res = try api(allocator, io, null, "GET", "/api/events?uri=x", null); - try expectCode(res.code, 401, "events without session"); + res = try harness.api(allocator, io, api_base, null, "GET", "/api/events?uri=x", null); + try harness.expectCode(res.code, 401, "events without session"); const sse_out = try std.fs.path.join(allocator, &.{ work, "sse.out" }); var sse = try std.process.spawn(io, .{ .argv = &.{ "curl", "-s", "-N", "-b", jar_alice, "-o", sse_out, try std.fmt.allocPrint(allocator, "{s}/api/events?uri={s}", .{ api_base, space }) }, @@ -217,301 +207,153 @@ pub fn main(init: std.process.Init) !void { .stderr = .ignore, }); io.sleep(std.Io.Duration.fromSeconds(2), .awake) catch {}; // connect + baseline - res = try api(allocator, io, jar_bob, "POST", "/api/post", try std.fmt.allocPrint(allocator, + res = try harness.api(allocator, io, api_base, jar_bob, "POST", "/api/post", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"thread\":\"{s}\",\"text\":\"SSE 触发\"}}", .{ space, thread_uri })); - try expectCode(res.code, 200, "bob post for SSE"); + try harness.expectCode(res.code, 200, "bob post for SSE"); io.sleep(std.Io.Duration.fromSeconds(5), .awake) catch {}; // server polls zds at 3s sse.kill(io); const sse_data = try std.Io.Dir.cwd().readFileAlloc(io, sse_out, allocator, .limited(1 << 20)); - try expectContains(sse_data, "event: update", "SSE update event after post"); + try harness.expectContains(sse_data, "event: update", "SSE update event after post"); // --- 6d. spaces listing + management via API (S017) --- - res = try api(allocator, io, jar_alice, "GET", "/api/spaces", null); - try expectCode(res.code, 200, "/api/spaces (alice)"); - try expectContains(res.body, space, "alice's spaces contain webboard"); - try expectContains(res.body, "\"isOwner\":false", "alice is not the owner"); + res = try harness.api(allocator, io, api_base, jar_alice, "GET", "/api/spaces", null); + try harness.expectCode(res.code, 200, "/api/spaces (alice)"); + try harness.expectContains(res.body, space, "alice's spaces contain webboard"); + try harness.expectContains(res.body, "\"isOwner\":false", "alice is not the owner"); const jar_community = try std.fs.path.join(allocator, &.{ work, "cookies-community" }); - res = try api(allocator, io, jar_community, "GET", "/api/login?handle=community.test&password=community-password", null); - try expectCode(res.code, 200, "scripted login community"); - res = try api(allocator, io, jar_community, "GET", "/api/spaces", null); - try expectContains(res.body, "\"isOwner\":true", "community owns webboard"); - - res = try api(allocator, io, jar_alice, "POST", "/api/space/create", "{\"skey\":\"bad key!\"}"); - try expectCode(res.code, 400, "bad skey rejected"); - res = try api(allocator, io, jar_alice, "POST", "/api/space/create", "{\"skey\":\"api-made\"}"); - try expectCode(res.code, 200, "POST /api/space/create"); - const api_space = try takeJsonString(allocator, res.body, "uri"); - res = try api(allocator, io, jar_alice, "GET", try std.fmt.allocPrint(allocator, "/api/space/members?uri={s}", .{api_space}), null); - try expectCode(res.code, 200, "/api/space/members"); - try expectContains(res.body, "alicetest", "creator is a member"); - try expectNotContains(res.body, "bobtest", "bob not yet a member"); - res = try api(allocator, io, jar_bob, "POST", "/api/space/add-member", try std.fmt.allocPrint(allocator, + res = try harness.api(allocator, io, api_base, jar_community, "GET", "/api/login?handle=community.test&password=community-password", null); + try harness.expectCode(res.code, 200, "scripted login community"); + res = try harness.api(allocator, io, api_base, jar_community, "GET", "/api/spaces", null); + try harness.expectContains(res.body, "\"isOwner\":true", "community owns webboard"); + + res = try harness.api(allocator, io, api_base, jar_alice, "POST", "/api/space/create", "{\"skey\":\"bad key!\"}"); + try harness.expectCode(res.code, 400, "bad skey rejected"); + res = try harness.api(allocator, io, api_base, jar_alice, "POST", "/api/space/create", "{\"skey\":\"api-made\"}"); + try harness.expectCode(res.code, 200, "POST /api/space/create"); + const api_space = try harness.takeJsonString(allocator, res.body, "uri"); + res = try harness.api(allocator, io, api_base, jar_alice, "GET", try std.fmt.allocPrint(allocator, "/api/space/members?uri={s}", .{api_space}), null); + try harness.expectCode(res.code, 200, "/api/space/members"); + try harness.expectContains(res.body, "alicetest", "creator is a member"); + try harness.expectNotContains(res.body, "bobtest", "bob not yet a member"); + res = try harness.api(allocator, io, api_base, jar_bob, "POST", "/api/space/add-member", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"did\":\"did:plc:bobtest\"}}", .{api_space})); - try expectCode(res.code, 403, "non-owner add-member denied"); - res = try api(allocator, io, jar_alice, "POST", "/api/space/add-member", try std.fmt.allocPrint(allocator, + try harness.expectCode(res.code, 403, "non-owner add-member denied"); + res = try harness.api(allocator, io, api_base, jar_alice, "POST", "/api/space/add-member", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"did\":\"did:plc:bobtest\"}}", .{api_space})); - try expectCode(res.code, 200, "owner add-member"); - res = try api(allocator, io, jar_alice, "GET", try std.fmt.allocPrint(allocator, "/api/space/members?uri={s}", .{api_space}), null); - try expectContains(res.body, "bob.test", "bob listed with handle"); - res = try api(allocator, io, jar_alice, "POST", "/api/space/remove-member", try std.fmt.allocPrint(allocator, + try harness.expectCode(res.code, 200, "owner add-member"); + res = try harness.api(allocator, io, api_base, jar_alice, "GET", try std.fmt.allocPrint(allocator, "/api/space/members?uri={s}", .{api_space}), null); + try harness.expectContains(res.body, "bob.test", "bob listed with handle"); + res = try harness.api(allocator, io, api_base, jar_alice, "POST", "/api/space/remove-member", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"did\":\"did:plc:bobtest\"}}", .{api_space})); - try expectCode(res.code, 200, "owner remove-member"); - res = try api(allocator, io, jar_alice, "GET", try std.fmt.allocPrint(allocator, "/api/space/members?uri={s}", .{api_space}), null); - try expectNotContains(res.body, "bobtest", "bob removed"); + try harness.expectCode(res.code, 200, "owner remove-member"); + res = try harness.api(allocator, io, api_base, jar_alice, "GET", try std.fmt.allocPrint(allocator, "/api/space/members?uri={s}", .{api_space}), null); + try harness.expectNotContains(res.body, "bobtest", "bob removed"); // --- 6e. edit / move / delete own via API (S017) --- - res = try api(allocator, io, jar_alice, "POST", "/api/post", try std.fmt.allocPrint(allocator, + res = try harness.api(allocator, io, api_base, jar_alice, "POST", "/api/post", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"thread\":\"{s}\",\"text\":\"改前\"}}", .{ space, thread_uri })); - try expectCode(res.code, 200, "alice post for edit/move/delete"); - const emd_uri = try takeJsonString(allocator, res.body, "uri"); - res = try api(allocator, io, jar_alice, "POST", "/api/edit", try std.fmt.allocPrint(allocator, + try harness.expectCode(res.code, 200, "alice post for edit/move/delete"); + const emd_uri = try harness.takeJsonString(allocator, res.body, "uri"); + res = try harness.api(allocator, io, api_base, jar_alice, "POST", "/api/edit", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"post\":\"{s}\",\"text\":\"改后\"}}", .{ space, emd_uri })); - try expectCode(res.code, 200, "POST /api/edit"); - res = try api(allocator, io, jar_alice, "GET", try std.fmt.allocPrint(allocator, "/api/space?uri={s}", .{space}), null); - try expectContains(res.body, "改后", "edit visible"); - res = try api(allocator, io, jar_alice, "POST", "/api/thread", try std.fmt.allocPrint(allocator, + try harness.expectCode(res.code, 200, "POST /api/edit"); + res = try harness.api(allocator, io, api_base, jar_alice, "GET", try std.fmt.allocPrint(allocator, "/api/space?uri={s}", .{space}), null); + try harness.expectContains(res.body, "改后", "edit visible"); + res = try harness.api(allocator, io, api_base, jar_alice, "POST", "/api/thread", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"title\":\"移动目标\"}}", .{space})); - const thread2_uri = try takeJsonString(allocator, res.body, "uri"); - res = try api(allocator, io, jar_alice, "POST", "/api/move", try std.fmt.allocPrint(allocator, + const thread2_uri = try harness.takeJsonString(allocator, res.body, "uri"); + res = try harness.api(allocator, io, api_base, jar_alice, "POST", "/api/move", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"post\":\"{s}\",\"thread\":\"{s}\"}}", .{ space, emd_uri, thread2_uri })); - try expectCode(res.code, 200, "POST /api/move"); - res = try api(allocator, io, jar_alice, "GET", try std.fmt.allocPrint(allocator, "/api/export?space={s}&thread={s}", .{ space, thread2_uri }), null); - try expectContains(res.body, "改后", "moved post in target thread"); - res = try api(allocator, io, jar_alice, "POST", "/api/delete", try std.fmt.allocPrint(allocator, + try harness.expectCode(res.code, 200, "POST /api/move"); + res = try harness.api(allocator, io, api_base, jar_alice, "GET", try std.fmt.allocPrint(allocator, "/api/export?space={s}&thread={s}", .{ space, thread2_uri }), null); + try harness.expectContains(res.body, "改后", "moved post in target thread"); + res = try harness.api(allocator, io, api_base, jar_alice, "POST", "/api/delete", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"post\":\"{s}\"}}", .{ space, emd_uri })); - try expectCode(res.code, 200, "POST /api/delete own"); - res = try api(allocator, io, jar_alice, "GET", try std.fmt.allocPrint(allocator, "/api/space?uri={s}", .{space}), null); - try expectNotContains(res.body, "改后", "delete visible"); + try harness.expectCode(res.code, 200, "POST /api/delete own"); + res = try harness.api(allocator, io, api_base, jar_alice, "GET", try std.fmt.allocPrint(allocator, "/api/space?uri={s}", .{space}), null); + try harness.expectNotContains(res.body, "改后", "delete visible"); // --- 6f. per-user data export (S017) --- - res = try api(allocator, io, jar_alice, "GET", "/api/account/export", null); - try expectCode(res.code, 200, "/api/account/export"); - try expectContains(res.body, "did:plc:alicetest", "export has did"); - try expectContains(res.body, "API 帖一", "export has alice's post"); - try expectContains(res.body, "服务器上的线程", "export has alice's thread"); - try expectNotContains(res.body, "bob 的帖子", "export excludes bob's post"); + res = try harness.api(allocator, io, api_base, jar_alice, "GET", "/api/account/export", null); + try harness.expectCode(res.code, 200, "/api/account/export"); + try harness.expectContains(res.body, "did:plc:alicetest", "export has did"); + try harness.expectContains(res.body, "API 帖一", "export has alice's post"); + try harness.expectContains(res.body, "服务器上的线程", "export has alice's thread"); + try harness.expectNotContains(res.body, "bob 的帖子", "export excludes bob's post"); // --- 6g. per-user data delete (S017, throwaway carol) --- - _ = try linji(allocator, io, home, linji_bin, &.{ "use", "community.test" }, "use community"); - _ = try linji(allocator, io, home, linji_bin, &.{ "space", "add-member", "--space", space, "--did", "did:plc:caroltest" }, "add carol"); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "use", "community.test" }, "use community"); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "space", "add-member", "--space", space, "--did", "did:plc:caroltest" }, "add carol"); const jar_carol = try std.fs.path.join(allocator, &.{ work, "cookies-carol" }); - res = try api(allocator, io, jar_carol, "GET", "/api/login?handle=carol.test&password=carol-password", null); - try expectCode(res.code, 200, "scripted login carol"); - res = try api(allocator, io, jar_carol, "POST", "/api/post", try std.fmt.allocPrint(allocator, + res = try harness.api(allocator, io, api_base, jar_carol, "GET", "/api/login?handle=carol.test&password=carol-password", null); + try harness.expectCode(res.code, 200, "scripted login carol"); + res = try harness.api(allocator, io, api_base, jar_carol, "POST", "/api/post", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"thread\":\"{s}\",\"text\":\"carol 的话\"}}", .{ space, thread_uri })); - try expectCode(res.code, 200, "carol post"); - res = try api(allocator, io, jar_carol, "POST", "/api/account/delete", null); - try expectCode(res.code, 200, "POST /api/account/delete"); - try expectContains(res.body, "\"posts\":1", "carol's post counted"); - res = try api(allocator, io, jar_carol, "GET", "/api/me", null); - try expectCode(res.code, 401, "carol session ended by delete"); - res = try api(allocator, io, jar_alice, "GET", try std.fmt.allocPrint(allocator, "/api/space?uri={s}", .{space}), null); - try expectNotContains(res.body, "carol 的话", "carol's post gone"); - try expectContains(res.body, "bob 的帖子", "others' posts survive account delete"); + try harness.expectCode(res.code, 200, "carol post"); + res = try harness.api(allocator, io, api_base, jar_carol, "POST", "/api/account/delete", null); + try harness.expectCode(res.code, 200, "POST /api/account/delete"); + try harness.expectContains(res.body, "\"posts\":1", "carol's post counted"); + res = try harness.api(allocator, io, api_base, jar_carol, "GET", "/api/me", null); + try harness.expectCode(res.code, 401, "carol session ended by delete"); + res = try harness.api(allocator, io, api_base, jar_alice, "GET", try std.fmt.allocPrint(allocator, "/api/space?uri={s}", .{space}), null); + try harness.expectNotContains(res.body, "carol 的话", "carol's post gone"); + try harness.expectContains(res.body, "bob 的帖子", "others' posts survive account delete"); // --- 6h. space invites (S018): owner mints a link, dave redeems --- - res = try api(allocator, io, jar_alice, "POST", "/api/space/invite", try std.fmt.allocPrint(allocator, + res = try harness.api(allocator, io, api_base, jar_alice, "POST", "/api/space/invite", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"expiresAt\":\"2030-01-01T00:00:00Z\"}}", .{space})); - try expectCode(res.code, 403, "non-owner invite create denied"); - res = try api(allocator, io, jar_community, "POST", "/api/space/invite", try std.fmt.allocPrint(allocator, + try harness.expectCode(res.code, 403, "non-owner invite create denied"); + res = try harness.api(allocator, io, api_base, jar_community, "POST", "/api/space/invite", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"expiresAt\":\"2030-01-01T00:00:00Z\"}}", .{space})); - try expectCode(res.code, 200, "owner POST /api/space/invite"); - const invite_uri = try takeJsonString(allocator, res.body, "uri"); - try expectContains(res.body, "/#/join/", "invite url has join route"); - try expectContains(res.body, "expiresAt", "invite echoes expiry"); + try harness.expectCode(res.code, 200, "owner POST /api/space/invite"); + const invite_uri = try harness.takeJsonString(allocator, res.body, "uri"); + try harness.expectContains(res.body, "/#/join/", "invite url has join route"); + try harness.expectContains(res.body, "expiresAt", "invite echoes expiry"); // dave is seeded but not a member: preview, then redeem. const jar_dave = try std.fs.path.join(allocator, &.{ work, "cookies-dave" }); - res = try api(allocator, io, jar_dave, "GET", "/api/login?handle=dave.test&password=dave-password", null); - try expectCode(res.code, 200, "scripted login dave"); - res = try api(allocator, io, jar_dave, "GET", "/api/spaces", null); - try expectNotContains(res.body, "webboard", "dave cannot see webboard yet"); - res = try api(allocator, io, jar_dave, "GET", try std.fmt.allocPrint(allocator, "/api/space?uri={s}", .{space}), null); - try expectCode(res.code, 403, "dave read denied before join"); - - res = try api(allocator, io, jar_dave, "GET", try std.fmt.allocPrint(allocator, + res = try harness.api(allocator, io, api_base, jar_dave, "GET", "/api/login?handle=dave.test&password=dave-password", null); + try harness.expectCode(res.code, 200, "scripted login dave"); + res = try harness.api(allocator, io, api_base, jar_dave, "GET", "/api/spaces", null); + try harness.expectNotContains(res.body, "webboard", "dave cannot see webboard yet"); + res = try harness.api(allocator, io, api_base, jar_dave, "GET", try std.fmt.allocPrint(allocator, "/api/space?uri={s}", .{space}), null); + try harness.expectCode(res.code, 403, "dave read denied before join"); + + res = try harness.api(allocator, io, api_base, jar_dave, "GET", try std.fmt.allocPrint(allocator, "/api/invite?space={s}&invite={s}", .{ space, invite_uri }), null); - try expectCode(res.code, 200, "GET /api/invite"); - try expectContains(res.body, "\"valid\":true", "invite valid"); - try expectContains(res.body, "webboard", "preview names the space"); - try expectContains(res.body, "community.test", "preview names inviter"); - res = try api(allocator, io, jar_dave, "GET", try std.fmt.allocPrint(allocator, + try harness.expectCode(res.code, 200, "GET /api/invite"); + try harness.expectContains(res.body, "\"valid\":true", "invite valid"); + try harness.expectContains(res.body, "webboard", "preview names the space"); + try harness.expectContains(res.body, "community.test", "preview names inviter"); + res = try harness.api(allocator, io, api_base, jar_dave, "GET", try std.fmt.allocPrint(allocator, "/api/invite?space={s}&invite=at://did:plc:communitytest/at.linji.invite/nosuch", .{space}), null); - try expectCode(res.code, 200, "GET /api/invite bogus"); - try expectContains(res.body, "\"valid\":false", "bogus invite invalid"); + try harness.expectCode(res.code, 200, "GET /api/invite bogus"); + try harness.expectContains(res.body, "\"valid\":false", "bogus invite invalid"); - res = try api(allocator, io, jar_dave, "POST", "/api/space/join", try std.fmt.allocPrint(allocator, + res = try harness.api(allocator, io, api_base, jar_dave, "POST", "/api/space/join", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"invite\":\"at://did:plc:communitytest/at.linji.invite/nosuch\"}}", .{space})); - try expectCode(res.code, 403, "join with bogus invite denied"); - res = try api(allocator, io, jar_dave, "POST", "/api/space/join", try std.fmt.allocPrint(allocator, + try harness.expectCode(res.code, 403, "join with bogus invite denied"); + res = try harness.api(allocator, io, api_base, jar_dave, "POST", "/api/space/join", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"invite\":\"{s}\"}}", .{ space, invite_uri })); - try expectCode(res.code, 200, "dave join with invite"); - res = try api(allocator, io, jar_dave, "GET", "/api/spaces", null); - try expectContains(res.body, "webboard", "dave sees webboard after join"); - res = try api(allocator, io, jar_dave, "GET", try std.fmt.allocPrint(allocator, "/api/space?uri={s}", .{space}), null); - try expectCode(res.code, 200, "dave reads after join"); - res = try api(allocator, io, jar_dave, "POST", "/api/post", try std.fmt.allocPrint(allocator, + try harness.expectCode(res.code, 200, "dave join with invite"); + res = try harness.api(allocator, io, api_base, jar_dave, "GET", "/api/spaces", null); + try harness.expectContains(res.body, "webboard", "dave sees webboard after join"); + res = try harness.api(allocator, io, api_base, jar_dave, "GET", try std.fmt.allocPrint(allocator, "/api/space?uri={s}", .{space}), null); + try harness.expectCode(res.code, 200, "dave reads after join"); + res = try harness.api(allocator, io, api_base, jar_dave, "POST", "/api/post", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"thread\":\"{s}\",\"text\":\"dave 来了\"}}", .{ space, thread_uri })); - try expectCode(res.code, 200, "dave posts after join"); + try harness.expectCode(res.code, 200, "dave posts after join"); // Redeeming again is idempotent. - res = try api(allocator, io, jar_dave, "POST", "/api/space/join", try std.fmt.allocPrint(allocator, + res = try harness.api(allocator, io, api_base, jar_dave, "POST", "/api/space/join", try std.fmt.allocPrint(allocator, "{{\"space\":\"{s}\",\"invite\":\"{s}\"}}", .{ space, invite_uri })); - try expectCode(res.code, 200, "rejoin idempotent"); - try expectContains(res.body, "alreadyMember", "rejoin reports alreadyMember"); + try harness.expectCode(res.code, 200, "rejoin idempotent"); + try harness.expectContains(res.body, "alreadyMember", "rejoin reports alreadyMember"); // --- 7. logout revokes --- - res = try api(allocator, io, jar_alice, "POST", "/api/logout", null); - try expectCode(res.code, 200, "logout"); - res = try api(allocator, io, jar_alice, "GET", "/api/me", null); - try expectCode(res.code, 401, "me after logout"); + res = try harness.api(allocator, io, api_base, jar_alice, "POST", "/api/logout", null); + try harness.expectCode(res.code, 200, "logout"); + res = try harness.api(allocator, io, api_base, jar_alice, "GET", "/api/me", null); + try harness.expectCode(res.code, 401, "me after logout"); std.debug.print("PASS: BFF serve scenario\n", .{}); } - -const ApiResult = struct { code: u16, body: []const u8 }; - -/// One API call through curl: cookie jar in/out, JSON body for POSTs. -fn api(allocator: std.mem.Allocator, io: std.Io, jar: ?[]const u8, method: []const u8, path: []const u8, body: ?[]const u8) !ApiResult { - var argv: std.ArrayList([]const u8) = .empty; - try argv.appendSlice(allocator, &.{ "curl", "-s", "-X", method, "-w", "\n%{http_code}" }); - if (jar) |j| try argv.appendSlice(allocator, &.{ "-b", j, "-c", j }); - if (body) |b| try argv.appendSlice(allocator, &.{ "-H", "content-type: application/json", "-d", b }); - const url = if (std.mem.startsWith(u8, path, "http")) path else try std.mem.concat(allocator, u8, &.{ api_base, path }); - try argv.append(allocator, url); - const r = try std.process.run(allocator, io, .{ .argv = argv.items }); - try expectExited(r, "curl"); - const trimmed = std.mem.trimEnd(u8, r.stdout, "\n"); - const nl = std.mem.lastIndexOfScalar(u8, trimmed, '\n') orelse return error.ScenarioFailed; - return .{ - .code = try std.fmt.parseInt(u16, trimmed[nl + 1 ..], 10), - .body = trimmed[0..nl], - }; -} - -/// Extract the value of a flat string field from a small JSON object. -fn takeJsonString(allocator: std.mem.Allocator, json: []const u8, key: []const u8) ![]const u8 { - const needle = try std.fmt.allocPrint(allocator, "\"{s}\":\"", .{key}); - const start = (std.mem.indexOf(u8, json, needle) orelse return error.ScenarioFailed) + needle.len; - const rest = json[start..]; - const end = std.mem.indexOfScalar(u8, rest, '"') orelse return error.ScenarioFailed; - return rest[0..end]; -} - -/// The space URI is the whole stdout of `space create` (no prefix). -fn takeSpaceUri(allocator: std.mem.Allocator, out: []const u8) ![]const u8 { - const trimmed = std.mem.trim(u8, out, "\r\n "); - if (!std.mem.startsWith(u8, trimmed, "at://")) { - std.debug.print("FAIL: no at-uri in:\n{s}\n", .{out}); - return error.ScenarioFailed; - } - return allocator.dupe(u8, trimmed); -} - -fn linji(allocator: std.mem.Allocator, io: std.Io, home: []const u8, bin: []const u8, argv: []const []const u8, label: []const u8) ![]const u8 { - var env = std.process.Environ.Map.init(allocator); - try env.put("LINJI_HOME", home); - var full: std.ArrayList([]const u8) = .empty; - try full.append(allocator, bin); - try full.appendSlice(allocator, argv); - const r = try std.process.run(allocator, io, .{ .argv = full.items, .environ_map = &env }); - try expectExited(r, label); - std.debug.print(" ok {s}\n", .{label}); - return r.stdout; -} - -fn waitForHealth(allocator: std.mem.Allocator, io: std.Io) !void { - var i: usize = 0; - while (i < 200) : (i += 1) { - if (httpAny(allocator, io, zds_base ++ "/xrpc/_health")) |status| { - if (status == .ok) return; - } else |_| {} - io.sleep(std.Io.Duration.fromMilliseconds(150), .awake) catch {}; - } - return error.ZdsDidNotStart; -} - -/// The serve root answers before any auth exists - any HTTP response is up. -fn waitForServe(allocator: std.mem.Allocator, io: std.Io) !void { - var i: usize = 0; - while (i < 200) : (i += 1) { - if (httpAny(allocator, io, api_base ++ "/")) |_| { - return; - } else |_| { - io.sleep(std.Io.Duration.fromMilliseconds(150), .awake) catch {}; - } - } - return error.ServeDidNotStart; -} - -fn httpAny(allocator: std.mem.Allocator, io: std.Io, url: []const u8) !std.http.Status { - var transport = zat.HttpTransport.init(io, allocator); - defer transport.deinit(); - var result = try transport.fetch(.{ .url = url, .accept = "application/json" }); - defer result.deinit(allocator); - return result.status; -} - -fn expectExited(r: std.process.RunResult, label: []const u8) !void { - switch (r.term) { - .exited => |code| { - if (code != 0) { - std.debug.print("FAIL {s}: exit {d}\nstderr: {s}\n", .{ label, code, r.stderr }); - return error.ScenarioFailed; - } - }, - else => return error.ScenarioFailed, - } -} - -fn expectCode(actual: u16, expected: u16, label: []const u8) !void { - if (actual != expected) { - std.debug.print("FAIL {s}: http {d}, expected {d}\n", .{ label, actual, expected }); - return error.ScenarioFailed; - } - std.debug.print(" ok {s}\n", .{label}); -} - -fn expectContains(haystack: []const u8, needle: []const u8, label: []const u8) !void { - if (std.mem.indexOf(u8, haystack, needle) == null) { - std.debug.print("FAIL {s}: missing {s} in:\n{s}\n", .{ label, needle, haystack }); - return error.ScenarioFailed; - } - std.debug.print(" ok {s}\n", .{label}); -} - -fn expectNotContains(haystack: []const u8, needle: []const u8, label: []const u8) !void { - if (std.mem.indexOf(u8, haystack, needle) != null) { - std.debug.print("FAIL {s}: unexpected {s} in:\n{s}\n", .{ label, needle, haystack }); - return error.ScenarioFailed; - } - std.debug.print(" ok {s}\n", .{label}); -} - -fn envOr(name: [*:0]const u8, fallback: []const u8) []const u8 { - return if (std.c.getenv(name)) |v| std.mem.span(v) else fallback; -} - -fn writeFile(io: std.Io, path: []const u8, content: []const u8) !void { - try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = content }); -} - -fn mkdirs(path: []const u8) !void { - var buf: [4096]u8 = undefined; - var index: usize = 0; - while (index < path.len) : (index += 1) { - if (path[index] != std.fs.path.sep or index == 0) continue; - try mkdirOne(path[0..index], &buf); - } - try mkdirOne(path, &buf); -} - -fn mkdirOne(path: []const u8, buf: []u8) !void { - if (path.len == 0 or path.len >= buf.len) return error.PathTooLong; - @memcpy(buf[0..path.len], path); - buf[path.len] = 0; - const path_z: [:0]const u8 = buf[0..path.len :0]; - if (std.c.mkdir(path_z.ptr, 0o700) == 0) return; - const err: std.posix.E = @enumFromInt(std.posix.system._errno().*); - if (err == .EXIST) return; - return error.CreateDirFailed; -} diff --git a/tests/scenario_space.zig b/tests/scenario_space.zig index f782c3d..2a579a1 100644 --- a/tests/scenario_space.zig +++ b/tests/scenario_space.zig @@ -18,16 +18,18 @@ //! //! Run from app0/: `zig build scenario-space` after building zds: //! (cd ../zds && zig build -Dtarget=x86_64-linux-gnu.2.36) +//! +//! Self-test mode (no zds build needed): `zig build scenario-self` drives +//! this same executable against scripted fakes via LINJI_ZDS_BIN / +//! LINJI_BIN — see tests/fixtures/. const std = @import("std"); const zat = @import("zat"); +const harness = @import("harness"); var app_threaded_io: std.Io.Threaded = undefined; pub const std_options_debug_threaded_io: ?*std.Io.Threaded = &app_threaded_io; -const port = 2591; -const base = "http://127.0.0.1:" ++ std.fmt.comptimePrint("{d}", .{port}); - pub fn main(init: std.process.Init) !void { _ = init; var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); @@ -39,305 +41,152 @@ pub fn main(init: std.process.Init) !void { }); const io = app_threaded_io.io(); - const zds_bin = envOr("LINJI_ZDS_BIN", "../zds/zig-out/bin/zds"); - const linji_bin = envOr("LINJI_BIN", "zig-out/bin/linji"); + const zds_bin = harness.envOr("LINJI_ZDS_BIN", "../zds/zig-out/bin/zds"); + const linji_bin = harness.envOr("LINJI_BIN", "zig-out/bin/linji"); + const port = try harness.envPort(allocator, "LINJI_ZDS_PORT", 2591); + const base = try std.fmt.allocPrint(allocator, "http://127.0.0.1:{d}", .{port}); const work = try std.fmt.allocPrint(allocator, "/tmp/linji-scenario-space-{d}", .{std.os.linux.getpid()}); std.Io.Dir.cwd().deleteTree(io, work) catch {}; defer std.Io.Dir.cwd().deleteTree(io, work) catch {}; - try mkdirs(try std.fs.path.join(allocator, &.{ work, "blobs" })); + try harness.mkdirs(try std.fs.path.join(allocator, &.{ work, "blobs" })); const home = try std.fs.path.join(allocator, &.{ work, "home" }); std.debug.print("== scenario member-only space ==\nwork: {s}\n", .{work}); // --- boot zds --- - var env = std.process.Environ.Map.init(allocator); - try env.put("ZDS_DEV_TOOLS", "true"); - try env.put("ZDS_PERMISSIONED_DATA", "true"); const db_path = try std.fs.path.join(allocator, &.{ work, "db.sqlite3" }); - var zds = try std.process.spawn(io, .{ - .argv = &.{ - zds_bin, - "--host", "127.0.0.1", - "--port", "2591", - "--db", db_path, - "--blobstore-path", try std.fs.path.join(allocator, &.{ work, "blobs" }), - "--public-url", base, - "--server-did", "did:web:localhost", - "--handle-domains", ".test", - "--admin-token", "scenario-admin", - }, - .environ_map = &env, - .stdout = .ignore, - .stderr = .ignore, + var zds = try harness.spawnZds(allocator, io, zds_bin, .{ + .port = port, + .base = base, + .db_path = db_path, + .blobs_dir = try std.fs.path.join(allocator, &.{ work, "blobs" }), }); defer zds.kill(io); - try waitForHealth(allocator, io); + try harness.waitForHealth(allocator, io, base); std.debug.print("zds up on {s}\n", .{base}); // --- seed accounts --- - const seeds = [_][]const u8{ "community", "alice", "bob", "eve" }; - for (seeds) |name| { - const sql = try std.fmt.allocPrint(allocator, - "insert into accounts (did, handle, email, password_hash, activated_at, email_confirmed_at) values ('did:plc:{s}test', '{s}.test', '{s}@example.com', '{s}-password', unixepoch(), unixepoch())", - .{ name, name, name, name }); - const r = try std.process.run(allocator, io, .{ .argv = &.{ "sqlite3", db_path, sql } }); - try expectExited(r, "sqlite seed"); - } + try harness.seedAccounts(allocator, io, db_path, &.{ "community", "alice", "bob", "eve" }); std.debug.print("accounts seeded\n", .{}); // --- logins into one store --- + const seeds = [_][]const u8{ "community", "alice", "bob", "eve" }; for (seeds) |name| { const handle = try std.fmt.allocPrint(allocator, "{s}.test", .{name}); const password = try std.fmt.allocPrint(allocator, "{s}-password", .{name}); - _ = try linji(allocator, io, home, linji_bin, &.{ "login", handle, "--password", password, "--pds", base }, handle); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "login", handle, "--password", password, "--pds", base }, handle); } // --- 1. community creates the space, adds alice + bob --- - _ = try linji(allocator, io, home, linji_bin, &.{ "use", "community.test" }, "use community"); - const create_out = try linji(allocator, io, home, linji_bin, &.{ "space", "create", "--skey", "general" }, "create space"); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "use", "community.test" }, "use community"); + const create_out = try harness.linji(allocator, io, home, linji_bin, &.{ "space", "create", "--skey", "general" }, "create space"); const space_uri = std.mem.trim(u8, create_out.stdout, " \r\n\t"); - try expectContains(space_uri, "at://did:plc:communitytest/space/at.linji.space/general", "space uri shape"); + try harness.expectContains(space_uri, "at://did:plc:communitytest/space/at.linji.space/general", "space uri shape"); - _ = try linji(allocator, io, home, linji_bin, &.{ "space", "add-member", "--space", space_uri, "--did", "did:plc:alicetest" }, "add alice"); - _ = try linji(allocator, io, home, linji_bin, &.{ "space", "add-member", "--space", space_uri, "--did", "did:plc:bobtest" }, "add bob"); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "space", "add-member", "--space", space_uri, "--did", "did:plc:alicetest" }, "add alice"); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "space", "add-member", "--space", space_uri, "--did", "did:plc:bobtest" }, "add bob"); { - const r = try linji(allocator, io, home, linji_bin, &.{ "space", "members", "--space", space_uri }, "list members"); - try expectContains(r.stdout, "did:plc:alicetest", "alice listed"); - try expectContains(r.stdout, "did:plc:bobtest", "bob listed"); + const r = try harness.linji(allocator, io, home, linji_bin, &.{ "space", "members", "--space", space_uri }, "list members"); + try harness.expectContains(r.stdout, "did:plc:alicetest", "alice listed"); + try harness.expectContains(r.stdout, "did:plc:bobtest", "bob listed"); } // --- 2. threads: create, post, grouped read, hashtag search --- - const t1_out = try linji(allocator, io, home, linji_bin, &.{ "thread", "create", "--as", "alice.test", "--space", space_uri, "--title", "课文讨论" }, "alice creates thread"); + const t1_out = try harness.linji(allocator, io, home, linji_bin, &.{ "thread", "create", "--as", "alice.test", "--space", space_uri, "--title", "课文讨论" }, "alice creates thread"); const t1 = std.mem.trim(u8, t1_out.stdout, " \r\n\t"); - try expectContains(t1, "at://did:plc:alicetest/at.linji.thread/", "thread uri shape"); - const t2_out = try linji(allocator, io, home, linji_bin, &.{ "thread", "create", "--as", "bob.test", "--space", space_uri, "--title", "随喜" }, "bob creates thread"); + try harness.expectContains(t1, "at://did:plc:alicetest/at.linji.thread/", "thread uri shape"); + const t2_out = try harness.linji(allocator, io, home, linji_bin, &.{ "thread", "create", "--as", "bob.test", "--space", space_uri, "--title", "随喜" }, "bob creates thread"); const t2 = std.mem.trim(u8, t2_out.stdout, " \r\n\t"); { - const r = try linji(allocator, io, home, linji_bin, &.{ "thread", "list", "--as", "bob.test", "--space", space_uri }, "bob lists threads"); - try expectContains(r.stdout, "课文讨论", "first thread listed"); - try expectContains(r.stdout, "随喜", "second thread listed"); + const r = try harness.linji(allocator, io, home, linji_bin, &.{ "thread", "list", "--as", "bob.test", "--space", space_uri }, "bob lists threads"); + try harness.expectContains(r.stdout, "课文讨论", "first thread listed"); + try harness.expectContains(r.stdout, "随喜", "second thread listed"); } - _ = try linji(allocator, io, home, linji_bin, &.{ "post", "--as", "alice.test", "--space", space_uri, "--thread", t1, "空间里的第一条,alice。" }, "alice space post"); - const bob_post_out = try linji(allocator, io, home, linji_bin, &.{ "post", "--as", "bob.test", "--space", space_uri, "--thread", t1, "空间里的第二条,bob。 #BlueSky" }, "bob space post"); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "post", "--as", "alice.test", "--space", space_uri, "--thread", t1, "空间里的第一条,alice。" }, "alice space post"); + const bob_post_out = try harness.linji(allocator, io, home, linji_bin, &.{ "post", "--as", "bob.test", "--space", space_uri, "--thread", t1, "空间里的第二条,bob。 #BlueSky" }, "bob space post"); const bob_post_uri = std.mem.trim(u8, bob_post_out.stdout, " \r\n\t"); - const stray_out = try linji(allocator, io, home, linji_bin, &.{ "post", "--as", "bob.test", "--space", space_uri, "--thread", t2, "岔出去的一句。" }, "bob stray post"); + const stray_out = try harness.linji(allocator, io, home, linji_bin, &.{ "post", "--as", "bob.test", "--space", space_uri, "--thread", t2, "岔出去的一句。" }, "bob stray post"); const stray_uri = std.mem.trim(u8, stray_out.stdout, " \r\n\t"); { - const r = try linji(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "bob.test" }, "bob reads space"); - try expectContains(r.stdout, "== 课文讨论 ==", "thread title header"); - try expectOrder(r.stdout, "空间里的第一条,alice。", "空间里的第二条,bob。", "alice before bob"); - try expectContains(r.stdout, "== 随喜 ==", "second thread header"); + const r = try harness.linji(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "bob.test" }, "bob reads space"); + try harness.expectContains(r.stdout, "== 课文讨论 ==", "thread title header"); + try harness.expectOrder(r.stdout, "空间里的第一条,alice。", "空间里的第二条,bob。", "alice before bob"); + try harness.expectContains(r.stdout, "== 随喜 ==", "second thread header"); } { - const r = try linji(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "bob.test", "--tag", "BlueSky" }, "search by inline tag"); - try expectContains(r.stdout, "== 课文讨论 ==", "tagged message's thread shown"); - try expectAbsent(r.stdout, "== 随喜 ==", "untagged thread filtered out"); + const r = try harness.linji(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "bob.test", "--tag", "BlueSky" }, "search by inline tag"); + try harness.expectContains(r.stdout, "== 课文讨论 ==", "tagged message's thread shown"); + try harness.expectNotContains(r.stdout, "== 随喜 ==", "untagged thread filtered out"); } // --- 2b. migration: bob moves his stray post into 课文讨论 --- - _ = try linji(allocator, io, home, linji_bin, &.{ "move", "--as", "bob.test", "--space", space_uri, "--post", stray_uri, "--thread", t1 }, "bob moves stray post"); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "move", "--as", "bob.test", "--space", space_uri, "--post", stray_uri, "--thread", t1 }, "bob moves stray post"); { - const r = try linji(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "alice.test" }, "alice reads after move"); - try expectOrder(r.stdout, "空间里的第二条,bob。", "岔出去的一句。", "moved post lands after thread's posts"); - try expectContains(r.stdout, "== 随喜 ==", "old thread keeps its trace"); - try expectContains(r.stdout, "↦ 一条消息被移动到「课文讨论」", "trace names the target thread"); + const r = try harness.linji(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "alice.test" }, "alice reads after move"); + try harness.expectOrder(r.stdout, "空间里的第二条,bob。", "岔出去的一句。", "moved post lands after thread's posts"); + try harness.expectContains(r.stdout, "== 随喜 ==", "old thread keeps its trace"); + try harness.expectContains(r.stdout, "↦ 一条消息被移动到「课文讨论」", "trace names the target thread"); } // move it back so later steps see the original layout - _ = try linji(allocator, io, home, linji_bin, &.{ "move", "--as", "bob.test", "--space", space_uri, "--post", stray_uri, "--thread", t2 }, "bob moves post back"); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "move", "--as", "bob.test", "--space", space_uri, "--post", stray_uri, "--thread", t2 }, "bob moves post back"); // --- 2c. edits + owner moderation powers --- - try linjiFails(allocator, io, home, linji_bin, &.{ "edit", "--as", "alice.test", "--space", space_uri, "--post", bob_post_uri, "越权改别人。" }, "alice editing bob's post denied"); - try linjiFails(allocator, io, home, linji_bin, &.{ "move", "--as", "alice.test", "--space", space_uri, "--post", bob_post_uri, "--thread", t2 }, "alice moving bob's post denied"); - try linjiFails(allocator, io, home, linji_bin, &.{ "delete", "--as", "alice.test", "--space", space_uri, "--post", bob_post_uri }, "alice deleting bob's post denied"); - _ = try linji(allocator, io, home, linji_bin, &.{ "edit", "--as", "bob.test", "--space", space_uri, "--post", bob_post_uri, "空间里的第二条,bob。(修订) #BlueSky" }, "bob edits own post"); - _ = try linji(allocator, io, home, linji_bin, &.{ "edit", "--as", "community.test", "--space", space_uri, "--post", bob_post_uri, "空间里的第二条,bob。(社区修订) #BlueSky" }, "owner edits bob's post"); + try harness.linjiFails(allocator, io, home, linji_bin, &.{ "edit", "--as", "alice.test", "--space", space_uri, "--post", bob_post_uri, "越权改别人。" }, "alice editing bob's post denied"); + try harness.linjiFails(allocator, io, home, linji_bin, &.{ "move", "--as", "alice.test", "--space", space_uri, "--post", bob_post_uri, "--thread", t2 }, "alice moving bob's post denied"); + try harness.linjiFails(allocator, io, home, linji_bin, &.{ "delete", "--as", "alice.test", "--space", space_uri, "--post", bob_post_uri }, "alice deleting bob's post denied"); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "edit", "--as", "bob.test", "--space", space_uri, "--post", bob_post_uri, "空间里的第二条,bob。(修订) #BlueSky" }, "bob edits own post"); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "edit", "--as", "community.test", "--space", space_uri, "--post", bob_post_uri, "空间里的第二条,bob。(社区修订) #BlueSky" }, "owner edits bob's post"); { - const r = try linji(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "alice.test" }, "alice reads after edits"); - try expectContains(r.stdout, "空间里的第二条,bob。(社区修订)", "owner edit visible to members"); - try expectOrder(r.stdout, "空间里的第一条,alice。", "空间里的第二条,bob。", "edited post keeps its slot"); + const r = try harness.linji(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "alice.test" }, "alice reads after edits"); + try harness.expectContains(r.stdout, "空间里的第二条,bob。(社区修订)", "owner edit visible to members"); + try harness.expectOrder(r.stdout, "空间里的第一条,alice。", "空间里的第二条,bob。", "edited post keeps its slot"); } - const doomed_out = try linji(allocator, io, home, linji_bin, &.{ "post", "--as", "bob.test", "--space", space_uri, "--thread", t1, "将被删除。" }, "bob posts doomed message"); + const doomed_out = try harness.linji(allocator, io, home, linji_bin, &.{ "post", "--as", "bob.test", "--space", space_uri, "--thread", t1, "将被删除。" }, "bob posts doomed message"); const doomed_uri = std.mem.trim(u8, doomed_out.stdout, " \r\n\t"); - _ = try linji(allocator, io, home, linji_bin, &.{ "delete", "--as", "community.test", "--space", space_uri, "--post", doomed_uri }, "owner deletes bob's post"); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "delete", "--as", "community.test", "--space", space_uri, "--post", doomed_uri }, "owner deletes bob's post"); { - const r = try linji(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "alice.test" }, "alice reads after delete"); - try expectAbsent(r.stdout, "将被删除。", "deleted post gone"); + const r = try harness.linji(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "alice.test" }, "alice reads after delete"); + try harness.expectNotContains(r.stdout, "将被删除。", "deleted post gone"); } // --- 3. eve (never a member) is denied all three ways --- - try linjiFails(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "eve.test" }, "eve read denied"); - try linjiFails(allocator, io, home, linji_bin, &.{ "post", "--as", "eve.test", "--space", space_uri, "--thread", t1, "我不该能发这条。" }, "eve post denied"); - try linjiFails(allocator, io, home, linji_bin, &.{ "thread", "create", "--as", "eve.test", "--space", space_uri, "--title", "不该存在" }, "eve thread denied"); + try harness.linjiFails(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "eve.test" }, "eve read denied"); + try harness.linjiFails(allocator, io, home, linji_bin, &.{ "post", "--as", "eve.test", "--space", space_uri, "--thread", t1, "我不该能发这条。" }, "eve post denied"); + try harness.linjiFails(allocator, io, home, linji_bin, &.{ "thread", "create", "--as", "eve.test", "--space", space_uri, "--title", "不该存在" }, "eve thread denied"); // --- 4. removing bob revokes read + write immediately --- - _ = try linji(allocator, io, home, linji_bin, &.{ "space", "remove-member", "--space", space_uri, "--did", "did:plc:bobtest" }, "remove bob"); - try linjiFails(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "bob.test" }, "removed bob read denied"); - try linjiFails(allocator, io, home, linji_bin, &.{ "post", "--as", "bob.test", "--space", space_uri, "--thread", t1, "被移出后的发言。" }, "removed bob post denied"); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "space", "remove-member", "--space", space_uri, "--did", "did:plc:bobtest" }, "remove bob"); + try harness.linjiFails(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "bob.test" }, "removed bob read denied"); + try harness.linjiFails(allocator, io, home, linji_bin, &.{ "post", "--as", "bob.test", "--space", space_uri, "--thread", t1, "被移出后的发言。" }, "removed bob post denied"); // --- 5. re-add bob; he posts again; alice sees all four --- - _ = try linji(allocator, io, home, linji_bin, &.{ "space", "add-member", "--space", space_uri, "--did", "did:plc:bobtest" }, "re-add bob"); - _ = try linji(allocator, io, home, linji_bin, &.{ "post", "--as", "bob.test", "--space", space_uri, "--thread", t1, "回来后的第一条。" }, "bob post after re-add"); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "space", "add-member", "--space", space_uri, "--did", "did:plc:bobtest" }, "re-add bob"); + _ = try harness.linji(allocator, io, home, linji_bin, &.{ "post", "--as", "bob.test", "--space", space_uri, "--thread", t1, "回来后的第一条。" }, "bob post after re-add"); { - const r = try linji(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "alice.test" }, "alice final read"); - try expectOrder(r.stdout, "空间里的第一条,alice。", "空间里的第二条,bob。", "alice before bob"); - try expectOrder(r.stdout, "空间里的第二条,bob。", "回来后的第一条。", "bob before re-add post"); - try expectContains(r.stdout, "== 随喜 ==", "stray thread still grouped"); + const r = try harness.linji(allocator, io, home, linji_bin, &.{ "read", "--space", space_uri, "--as", "alice.test" }, "alice final read"); + try harness.expectOrder(r.stdout, "空间里的第一条,alice。", "空间里的第二条,bob。", "alice before bob"); + try harness.expectOrder(r.stdout, "空间里的第二条,bob。", "回来后的第一条。", "bob before re-add post"); + try harness.expectContains(r.stdout, "== 随喜 ==", "stray thread still grouped"); } // --- 6. export as Markdown (for publishing elsewhere) --- { - const r = try linji(allocator, io, home, linji_bin, &.{ "export", "--as", "community.test", "--space", space_uri, "--thread", t1 }, "export thread as markdown"); - try expectContains(r.stdout, "## 课文讨论", "thread title as heading"); - try expectContains(r.stdout, "**alice.test**", "author handle bold"); - try expectContains(r.stdout, "(社区修订)", "edited text exported"); - try expectContains(r.stdout, "#BlueSky", "hashtag survives in text"); - try expectAbsent(r.stdout, "岔出去的一句。", "other thread not included"); + const r = try harness.linji(allocator, io, home, linji_bin, &.{ "export", "--as", "community.test", "--space", space_uri, "--thread", t1 }, "export thread as markdown"); + try harness.expectContains(r.stdout, "## 课文讨论", "thread title as heading"); + try harness.expectContains(r.stdout, "**alice.test**", "author handle bold"); + try harness.expectContains(r.stdout, "(社区修订)", "edited text exported"); + try harness.expectContains(r.stdout, "#BlueSky", "hashtag survives in text"); + try harness.expectNotContains(r.stdout, "岔出去的一句。", "other thread not included"); } { - const r = try linji(allocator, io, home, linji_bin, &.{ "export", "--as", "alice.test", "--space", space_uri, "--post", stray_uri }, "export selected post"); - try expectContains(r.stdout, "## 随喜", "selected post's thread heading"); - try expectContains(r.stdout, "岔出去的一句。", "selected post exported"); - try expectAbsent(r.stdout, "空间里的第一条", "unselected posts excluded"); + const r = try harness.linji(allocator, io, home, linji_bin, &.{ "export", "--as", "alice.test", "--space", space_uri, "--post", stray_uri }, "export selected post"); + try harness.expectContains(r.stdout, "## 随喜", "selected post's thread heading"); + try harness.expectContains(r.stdout, "岔出去的一句。", "selected post exported"); + try harness.expectNotContains(r.stdout, "空间里的第一条", "unselected posts excluded"); } - try linjiFails(allocator, io, home, linji_bin, &.{ "export", "--as", "eve.test", "--space", space_uri, "--thread", t1 }, "eve export denied"); + try harness.linjiFails(allocator, io, home, linji_bin, &.{ "export", "--as", "eve.test", "--space", space_uri, "--thread", t1 }, "eve export denied"); std.debug.print("== scenario member-only space: PASS ==\n", .{}); } - -fn envOr(name: [*:0]const u8, fallback: []const u8) []const u8 { - return if (std.c.getenv(name)) |v| std.mem.span(v) else fallback; -} - -fn linji(allocator: std.mem.Allocator, io: std.Io, home: []const u8, bin: []const u8, argv: []const []const u8, label: []const u8) !std.process.RunResult { - const r = try runLinji(allocator, io, home, bin, argv); - switch (r.term) { - .exited => |code| { - if (code != 0) { - std.debug.print("FAIL {s}: exit {d}\nstdout: {s}\nstderr: {s}\n", .{ label, code, r.stdout, r.stderr }); - return error.ScenarioFailed; - } - }, - else => { - std.debug.print("FAIL {s}: abnormal term\nstdout: {s}\nstderr: {s}\n", .{ label, r.stdout, r.stderr }); - return error.ScenarioFailed; - }, - } - std.debug.print(" ok {s}\n", .{label}); - return r; -} - -/// A checkpoint that must fail: the CLI exits non-zero when the server -/// refuses (membership denied / revoked). -fn linjiFails(allocator: std.mem.Allocator, io: std.Io, home: []const u8, bin: []const u8, argv: []const []const u8, label: []const u8) !void { - const r = try runLinji(allocator, io, home, bin, argv); - switch (r.term) { - .exited => |code| { - if (code == 0) { - std.debug.print("FAIL {s}: expected failure but exited 0\nstdout: {s}\n", .{ label, r.stdout }); - return error.ScenarioFailed; - } - }, - else => { - std.debug.print("FAIL {s}: abnormal term\nstderr: {s}\n", .{ label, r.stderr }); - return error.ScenarioFailed; - }, - } - std.debug.print(" ok {s}\n", .{label}); -} - -fn runLinji(allocator: std.mem.Allocator, io: std.Io, home: []const u8, bin: []const u8, argv: []const []const u8) !std.process.RunResult { - var env = std.process.Environ.Map.init(allocator); - try env.put("LINJI_HOME", home); - var full: std.ArrayList([]const u8) = .empty; - try full.append(allocator, bin); - try full.appendSlice(allocator, argv); - return std.process.run(allocator, io, .{ .argv = full.items, .environ_map = &env }); -} - -fn waitForHealth(allocator: std.mem.Allocator, io: std.Io) !void { - var i: usize = 0; - while (i < 200) : (i += 1) { - if (httpGet(allocator, io, base ++ "/xrpc/_health")) |_| { - return; - } else |_| { - io.sleep(std.Io.Duration.fromMilliseconds(150), .awake) catch {}; - } - } - return error.ZdsDidNotStart; -} - -fn httpGet(allocator: std.mem.Allocator, io: std.Io, url: []const u8) ![]u8 { - var transport = zat.HttpTransport.init(io, allocator); - defer transport.deinit(); - var result = try transport.fetch(.{ .url = url, .accept = "application/json" }); - defer result.deinit(allocator); - if (result.status != .ok) return error.HttpStatus; - return try allocator.dupe(u8, result.body); -} - -fn expectExited(r: std.process.RunResult, label: []const u8) !void { - switch (r.term) { - .exited => |code| { - if (code != 0) { - std.debug.print("FAIL {s}: exit {d}\nstderr: {s}\n", .{ label, code, r.stderr }); - return error.ScenarioFailed; - } - }, - else => return error.ScenarioFailed, - } -} - -fn expectContains(haystack: []const u8, needle: []const u8, label: []const u8) !void { - if (std.mem.indexOf(u8, haystack, needle) == null) { - std.debug.print("FAIL {s}: missing {s} in:\n{s}\n", .{ label, needle, haystack }); - return error.ScenarioFailed; - } - std.debug.print(" ok {s}\n", .{label}); -} - -fn expectAbsent(haystack: []const u8, needle: []const u8, label: []const u8) !void { - if (std.mem.indexOf(u8, haystack, needle) != null) { - std.debug.print("FAIL {s}: unexpected {s} in:\n{s}\n", .{ label, needle, haystack }); - return error.ScenarioFailed; - } - std.debug.print(" ok {s}\n", .{label}); -} - -fn expectOrder(haystack: []const u8, first: []const u8, second: []const u8, label: []const u8) !void { - const a = std.mem.indexOf(u8, haystack, first) orelse { - std.debug.print("FAIL {s}: missing first text {s}\n{s}\n", .{ label, first, haystack }); - return error.ScenarioFailed; - }; - const b = std.mem.indexOf(u8, haystack, second) orelse { - std.debug.print("FAIL {s}: missing second text {s}\n{s}\n", .{ label, second, haystack }); - return error.ScenarioFailed; - }; - if (a >= b) { - std.debug.print("FAIL {s}: wrong order in:\n{s}\n", .{ label, haystack }); - return error.ScenarioFailed; - } - std.debug.print(" ok {s}\n", .{label}); -} - -fn mkdirs(path: []const u8) !void { - var buf: [4096]u8 = undefined; - var index: usize = 0; - while (index < path.len) : (index += 1) { - if (path[index] != std.fs.path.sep or index == 0) continue; - try mkdirOne(path[0..index], &buf); - } - try mkdirOne(path, &buf); -} - -fn mkdirOne(path: []const u8, buf: []u8) !void { - if (path.len == 0 or path.len >= buf.len) return error.PathTooLong; - @memcpy(buf[0..path.len], path); - buf[path.len] = 0; - const path_z: [:0]const u8 = buf[0..path.len :0]; - if (std.c.mkdir(path_z.ptr, 0o700) == 0) return; - const err: std.posix.E = @enumFromInt(std.posix.system._errno().*); - if (err == .EXIST) return; - return error.CreateDirFailed; -} diff --git a/tests/self_isolation.zig b/tests/self_isolation.zig new file mode 100644 index 0000000..321318e --- /dev/null +++ b/tests/self_isolation.zig @@ -0,0 +1,101 @@ +//! L3 isolation proof: two concurrent scenario-space runs (good fixture, +//! distinct ports) must both pass, must use distinct work dirs, and must +//! tear those dirs down on exit. +//! +//! Env (set by `zig build scenario-isolation`): SELF_SCENARIO (the +//! scenario-space executable), SELF_FAKE_ZDS, SELF_FAKE_LINJI, +//! SELF_FIXTURE (the known-good fixture). + +const std = @import("std"); + +var app_threaded_io: std.Io.Threaded = undefined; +pub const std_options_debug_threaded_io: ?*std.Io.Threaded = &app_threaded_io; + +const Spawned = struct { + child: std.process.Child, + port: u16, + work: []const u8, +}; + +fn envOrPanic(name: [*:0]const u8) []const u8 { + return std.mem.span(std.c.getenv(name) orelse { + std.debug.print("self-isolation: {s} not set\n", .{name}); + std.process.exit(2); + }); +} + +pub fn main(init: std.process.Init) !void { + _ = init; + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + app_threaded_io = std.Io.Threaded.init(allocator, .{ + .async_limit = .limited(4), + .concurrent_limit = .limited(4), + }); + const io = app_threaded_io.io(); + + const scenario = envOrPanic("SELF_SCENARIO"); + const fake_zds = envOrPanic("SELF_FAKE_ZDS"); + const fake_linji = envOrPanic("SELF_FAKE_LINJI"); + const fixture = envOrPanic("SELF_FIXTURE"); + + // Ports deliberately outside the default set (2591/2592/8791); each + // run also gets its own pid-suffixed work dir, so the two overlap + // only if the isolation contract is broken. + var runs: [2]Spawned = undefined; + for (&runs, 0..) |*run, i| { + const port: u16 = if (i == 0) 2611 else 2612; + var env = std.process.Environ.Map.init(allocator); + try env.put("LINJI_ZDS_BIN", fake_zds); + try env.put("LINJI_BIN", fake_linji); + try env.put("LINJI_FAKE_FIXTURE", fixture); + try env.put("LINJI_ZDS_PORT", try std.fmt.allocPrint(allocator, "{d}", .{port})); + const child = try std.process.spawn(io, .{ + .argv = &.{scenario}, + .environ_map = &env, + .stdout = .ignore, + .stderr = .ignore, + }); + run.* = .{ + .child = child, + .port = port, + .work = try std.fmt.allocPrint(allocator, "/tmp/linji-scenario-space-{d}", .{child.id.?}), + }; + } + + // Both were started before either was waited on: the overlap window + // is the point of the test. + var all_pass = true; + for (&runs) |*run| { + const term = try run.child.wait(io); + switch (term) { + .exited => |code| { + if (code == 0) { + std.debug.print("ok run on port {d} (work {s})\n", .{ run.port, run.work }); + } else { + std.debug.print("FAIL run on port {d}: exit {d}\n", .{ run.port, code }); + all_pass = false; + } + }, + else => { + std.debug.print("FAIL run on port {d}: abnormal term\n", .{run.port}); + all_pass = false; + }, + } + // Teardown contract: the work dir (and everything in it, + // including the fake-CLI fixture cursor) is gone after exit. + if (dirExists(io, run.work)) { + std.debug.print("FAIL run on port {d}: work dir not cleaned up: {s}\n", .{ run.port, run.work }); + all_pass = false; + } + } + + if (!all_pass) return error.IsolationFailed; + std.debug.print("== scenario isolation: PASS (concurrent runs, distinct ports, teardown) ==\n", .{}); +} + +fn dirExists(io: std.Io, path: []const u8) bool { + std.Io.Dir.cwd().access(io, path, .{}) catch return false; + return true; +}