From c92d2b3f2c2d57cebab8b67cb5c68398c90a1e16 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 10 Feb 2026 11:58:03 +0000 Subject: [PATCH 1/2] test(bdd): add registry and cli scenarios Co-authored-by: Loongphy Wei --- docs/implement.md | 8 ++ src/main.zig | 2 + src/tests/bdd_helpers.zig | 76 +++++++++++++ src/tests/cli_bdd_test.zig | 55 ++++++++++ src/tests/registry_bdd_test.zig | 183 ++++++++++++++++++++++++++++++++ 5 files changed, 324 insertions(+) create mode 100644 src/tests/bdd_helpers.zig create mode 100644 src/tests/cli_bdd_test.zig create mode 100644 src/tests/registry_bdd_test.zig diff --git a/docs/implement.md b/docs/implement.md index c138aa2..f2e8c2b 100644 --- a/docs/implement.md +++ b/docs/implement.md @@ -11,6 +11,14 @@ This document describes how `codex-auth` stores accounts, synchronizes auth file - `~/.codex/accounts/registry.json.bak.` - `~/.codex/sessions/...` +## Testing Conventions (BDD Style on std.testing) + +- The project keeps using Zig native tests (`zig build test`) for CI and local checks. +- BDD scenarios are expressed in Zig `test` blocks with descriptive names like: + - `Scenario: Given ... when ... then ...` +- Reusable Given/When/Then setup logic should live in test-only helper/context code under `src/tests/` (for example `*_bdd_test.zig` plus helper modules). +- Existing unit-style tests remain valid; BDD-style tests should prioritize behavior flows and branches that are not already covered. + ## First Run and Empty Registry - If `registry.json` is empty and `~/.codex/auth.json` exists, the tool auto-imports it into `accounts/.auth.json`. diff --git a/src/main.zig b/src/main.zig index 0f83488..8f62a78 100644 --- a/src/main.zig +++ b/src/main.zig @@ -159,4 +159,6 @@ test { _ = @import("tests/auth_test.zig"); _ = @import("tests/sessions_test.zig"); _ = @import("tests/registry_test.zig"); + _ = @import("tests/registry_bdd_test.zig"); + _ = @import("tests/cli_bdd_test.zig"); } diff --git a/src/tests/bdd_helpers.zig b/src/tests/bdd_helpers.zig new file mode 100644 index 0000000..c9bf559 --- /dev/null +++ b/src/tests/bdd_helpers.zig @@ -0,0 +1,76 @@ +const std = @import("std"); +const registry = @import("../registry.zig"); + +pub fn b64url(allocator: std.mem.Allocator, input: []const u8) ![]u8 { + const encoder = std.base64.url_safe_no_pad.Encoder; + const out_len = encoder.calcSize(input.len); + const buf = try allocator.alloc(u8, out_len); + _ = encoder.encode(buf, input); + return buf; +} + +fn authJsonFromPayload(allocator: std.mem.Allocator, payload: []const u8) ![]u8 { + const header = "{\"alg\":\"none\",\"typ\":\"JWT\"}"; + const h64 = try b64url(allocator, header); + defer allocator.free(h64); + const p64 = try b64url(allocator, payload); + defer allocator.free(p64); + const jwt = try std.mem.concat(allocator, u8, &[_][]const u8{ h64, ".", p64, ".sig" }); + defer allocator.free(jwt); + return try std.fmt.allocPrint(allocator, "{{\"tokens\":{{\"id_token\":\"{s}\"}}}}", .{jwt}); +} + +pub fn authJsonWithEmailPlan(allocator: std.mem.Allocator, email: []const u8, plan: []const u8) ![]u8 { + const payload = try std.fmt.allocPrint( + allocator, + "{{\"email\":\"{s}\",\"https://api.openai.com/auth\":{{\"chatgpt_plan_type\":\"{s}\"}}}}", + .{ email, plan }, + ); + defer allocator.free(payload); + return try authJsonFromPayload(allocator, payload); +} + +pub fn authJsonWithoutEmail(allocator: std.mem.Allocator) ![]u8 { + return try authJsonFromPayload(allocator, "{\"sub\":\"missing-email\"}"); +} + +pub fn makeEmptyRegistry() registry.Registry { + return registry.Registry{ + .version = 2, + .active_email = null, + .accounts = std.ArrayList(registry.AccountRecord).empty, + }; +} + +pub fn appendAccount( + allocator: std.mem.Allocator, + reg: *registry.Registry, + email: []const u8, + name: []const u8, + plan: ?registry.PlanType, +) !void { + const rec = registry.AccountRecord{ + .email = try allocator.dupe(u8, email), + .name = try allocator.dupe(u8, name), + .plan = plan, + .auth_mode = .chatgpt, + .created_at = std.time.timestamp(), + .last_used_at = null, + .last_usage = null, + .last_usage_at = null, + }; + try reg.accounts.append(allocator, rec); +} + +pub fn findAccountIndexByEmail(reg: *registry.Registry, email: []const u8) ?usize { + for (reg.accounts.items, 0..) |rec, i| { + if (std.mem.eql(u8, rec.email, email)) return i; + } + return null; +} + +pub fn readFileAlloc(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + var file = try std.fs.cwd().openFile(path, .{}); + defer file.close(); + return try file.readToEndAlloc(allocator, 10 * 1024 * 1024); +} diff --git a/src/tests/cli_bdd_test.zig b/src/tests/cli_bdd_test.zig new file mode 100644 index 0000000..b84825a --- /dev/null +++ b/src/tests/cli_bdd_test.zig @@ -0,0 +1,55 @@ +const std = @import("std"); +const cli = @import("../cli.zig"); + +fn isHelp(cmd: cli.Command) bool { + return switch (cmd) { + .help => true, + else => false, + }; +} + +test "Scenario: Given add with no-login when parsing then login flow is disabled" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "add", "--no-login" }; + var cmd = try cli.parseArgs(gpa, &args); + defer cli.freeCommand(gpa, &cmd); + + switch (cmd) { + .add => |opts| try std.testing.expect(!opts.login), + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given import path and name when parsing then import options are preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "import", "/tmp/auth.json", "--name", "personal" }; + var cmd = try cli.parseArgs(gpa, &args); + defer cli.freeCommand(gpa, &cmd); + + switch (cmd) { + .import_auth => |opts| { + try std.testing.expect(std.mem.eql(u8, opts.auth_path, "/tmp/auth.json")); + try std.testing.expect(opts.name != null); + try std.testing.expect(std.mem.eql(u8, opts.name.?, "personal")); + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given list with extra args when parsing then help command is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "list", "unexpected" }; + var cmd = try cli.parseArgs(gpa, &args); + defer cli.freeCommand(gpa, &cmd); + + try std.testing.expect(isHelp(cmd)); +} + +test "Scenario: Given add with unknown flag when parsing then help command is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "add", "--bad-flag" }; + var cmd = try cli.parseArgs(gpa, &args); + defer cli.freeCommand(gpa, &cmd); + + try std.testing.expect(isHelp(cmd)); +} diff --git a/src/tests/registry_bdd_test.zig b/src/tests/registry_bdd_test.zig new file mode 100644 index 0000000..4f3bb7c --- /dev/null +++ b/src/tests/registry_bdd_test.zig @@ -0,0 +1,183 @@ +const std = @import("std"); +const registry = @import("../registry.zig"); +const bdd = @import("bdd_helpers.zig"); + +const SyncBddContext = struct { + allocator: std.mem.Allocator, + tmp: std.testing.TmpDir, + codex_home: []u8, + reg: registry.Registry, + + fn givenCleanCodexHome(allocator: std.mem.Allocator) !SyncBddContext { + var tmp = std.testing.tmpDir(.{}); + const codex_home = try tmp.dir.realpathAlloc(allocator, "."); + return SyncBddContext{ + .allocator = allocator, + .tmp = tmp, + .codex_home = codex_home, + .reg = bdd.makeEmptyRegistry(), + }; + } + + fn deinit(self: *SyncBddContext) void { + self.reg.deinit(self.allocator); + self.allocator.free(self.codex_home); + self.tmp.cleanup(); + } + + fn givenActiveAuthJson(self: *SyncBddContext, auth_json: []const u8) !void { + try self.tmp.dir.writeFile(.{ .sub_path = "auth.json", .data = auth_json }); + } + + fn givenRegisteredAccount(self: *SyncBddContext, email: []const u8, name: []const u8, plan: ?registry.PlanType) !void { + try bdd.appendAccount(self.allocator, &self.reg, email, name, plan); + } + + fn whenSyncActiveAccountFromAuth(self: *SyncBddContext) !bool { + return try registry.syncActiveAccountFromAuth(self.allocator, self.codex_home, &self.reg); + } + + fn thenAccountCountShouldBe(self: *SyncBddContext, expected: usize) !void { + try std.testing.expect(self.reg.accounts.items.len == expected); + } + + fn thenActiveEmailShouldBe(self: *SyncBddContext, expected: []const u8) !void { + try std.testing.expect(self.reg.active_email != null); + try std.testing.expect(std.mem.eql(u8, self.reg.active_email.?, expected)); + } + + fn thenAccountShouldExist(self: *SyncBddContext, email: []const u8) !void { + const idx = bdd.findAccountIndexByEmail(&self.reg, email); + try std.testing.expect(idx != null); + } + + fn thenAccountAuthShouldMatchActive(self: *SyncBddContext, email: []const u8) !void { + const active_auth_path = try registry.activeAuthPath(self.allocator, self.codex_home); + defer self.allocator.free(active_auth_path); + const account_auth_path = try registry.accountAuthPath(self.allocator, self.codex_home, email); + defer self.allocator.free(account_auth_path); + + const active_data = try bdd.readFileAlloc(self.allocator, active_auth_path); + defer self.allocator.free(active_data); + const account_data = try bdd.readFileAlloc(self.allocator, account_auth_path); + defer self.allocator.free(account_data); + + try std.testing.expect(std.mem.eql(u8, active_data, account_data)); + } +}; + +test "Scenario: Given empty registry when syncing auth then auto import and activate" { + const gpa = std.testing.allocator; + var ctx = try SyncBddContext.givenCleanCodexHome(gpa); + defer ctx.deinit(); + + const active_auth = try bdd.authJsonWithEmailPlan(gpa, "auto@example.com", "plus"); + defer gpa.free(active_auth); + try ctx.givenActiveAuthJson(active_auth); + + const changed = try ctx.whenSyncActiveAccountFromAuth(); + + try std.testing.expect(changed); + try ctx.thenAccountCountShouldBe(1); + try ctx.thenActiveEmailShouldBe("auto@example.com"); + try ctx.thenAccountAuthShouldMatchActive("auto@example.com"); +} + +test "Scenario: Given auth without email when syncing then keep registry unchanged" { + const gpa = std.testing.allocator; + var ctx = try SyncBddContext.givenCleanCodexHome(gpa); + defer ctx.deinit(); + + try ctx.givenRegisteredAccount("keep@example.com", "keep", .pro); + try registry.setActiveAccount(gpa, &ctx.reg, "keep@example.com"); + + const invalid_auth = try bdd.authJsonWithoutEmail(gpa); + defer gpa.free(invalid_auth); + try ctx.givenActiveAuthJson(invalid_auth); + + const changed = try ctx.whenSyncActiveAccountFromAuth(); + + try std.testing.expect(!changed); + try ctx.thenAccountCountShouldBe(1); + try ctx.thenActiveEmailShouldBe("keep@example.com"); +} + +test "Scenario: Given unmatched active auth email when syncing then append account and switch active" { + const gpa = std.testing.allocator; + var ctx = try SyncBddContext.givenCleanCodexHome(gpa); + defer ctx.deinit(); + + try ctx.givenRegisteredAccount("old@example.com", "old", .free); + try registry.setActiveAccount(gpa, &ctx.reg, "old@example.com"); + + const active_auth = try bdd.authJsonWithEmailPlan(gpa, "new@example.com", "team"); + defer gpa.free(active_auth); + try ctx.givenActiveAuthJson(active_auth); + + const changed = try ctx.whenSyncActiveAccountFromAuth(); + + try std.testing.expect(changed); + try ctx.thenAccountCountShouldBe(2); + try ctx.thenAccountShouldExist("old@example.com"); + try ctx.thenAccountShouldExist("new@example.com"); + try ctx.thenActiveEmailShouldBe("new@example.com"); + try ctx.thenAccountAuthShouldMatchActive("new@example.com"); +} + +test "Scenario: Given accounts with different remaining usage when selecting best then highest remaining wins" { + const gpa = std.testing.allocator; + var reg = bdd.makeEmptyRegistry(); + defer reg.deinit(gpa); + + try bdd.appendAccount(gpa, ®, "low@example.com", "", null); + try bdd.appendAccount(gpa, ®, "high@example.com", "", null); + + reg.accounts.items[0].last_usage = .{ + .primary = .{ .used_percent = 85.0, .window_minutes = 300, .resets_at = null }, + .secondary = .{ .used_percent = 10.0, .window_minutes = 10080, .resets_at = null }, + .credits = null, + .plan_type = null, + }; + reg.accounts.items[0].last_usage_at = 100; + + reg.accounts.items[1].last_usage = .{ + .primary = .{ .used_percent = 40.0, .window_minutes = 300, .resets_at = null }, + .secondary = null, + .credits = null, + .plan_type = null, + }; + reg.accounts.items[1].last_usage_at = 50; + + const best = registry.selectBestAccountIndexByUsage(®); + try std.testing.expect(best != null); + try std.testing.expect(best.? == 1); +} + +test "Scenario: Given equal usage when selecting best then most recent snapshot wins tie" { + const gpa = std.testing.allocator; + var reg = bdd.makeEmptyRegistry(); + defer reg.deinit(gpa); + + try bdd.appendAccount(gpa, ®, "older@example.com", "", null); + try bdd.appendAccount(gpa, ®, "newer@example.com", "", null); + + reg.accounts.items[0].last_usage = .{ + .primary = .{ .used_percent = 50.0, .window_minutes = 300, .resets_at = null }, + .secondary = null, + .credits = null, + .plan_type = null, + }; + reg.accounts.items[0].last_usage_at = 100; + + reg.accounts.items[1].last_usage = .{ + .primary = .{ .used_percent = 50.0, .window_minutes = 300, .resets_at = null }, + .secondary = null, + .credits = null, + .plan_type = null, + }; + reg.accounts.items[1].last_usage_at = 200; + + const best = registry.selectBestAccountIndexByUsage(®); + try std.testing.expect(best != null); + try std.testing.expect(best.? == 1); +} From 91c86ac3a83a466907e6629da9bc55850d5d423a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 10 Feb 2026 14:08:51 +0000 Subject: [PATCH 2/2] fix(cli): prevent remove index overflow Co-authored-by: Loongphy Wei --- src/cli.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli.zig b/src/cli.zig index 5fa3bf4..260034f 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -236,7 +236,7 @@ fn selectRemoveWithNumbers(allocator: std.mem.Allocator, reg: *registry.Registry } if (count == 0) return null; var selected = try allocator.alloc(usize, count); - var idx: usize = activeAccountIndex(reg) orelse 0; + var idx: usize = 0; for (checked, 0..) |flag, i| { if (!flag) continue; selected[idx] = i;