Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ This document describes how `codex-auth` stores accounts, synchronizes auth file
- `~/.codex/accounts/registry.json.bak.<timestamp>`
- `~/.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/<email_b64>.auth.json`.
Expand Down
2 changes: 1 addition & 1 deletion src/cli.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
76 changes: 76 additions & 0 deletions src/tests/bdd_helpers.zig
Original file line number Diff line number Diff line change
@@ -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);
}
55 changes: 55 additions & 0 deletions src/tests/cli_bdd_test.zig
Original file line number Diff line number Diff line change
@@ -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));
}
183 changes: 183 additions & 0 deletions src/tests/registry_bdd_test.zig
Original file line number Diff line number Diff line change
@@ -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, &reg, "low@example.com", "", null);
try bdd.appendAccount(gpa, &reg, "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(&reg);
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, &reg, "older@example.com", "", null);
try bdd.appendAccount(gpa, &reg, "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(&reg);
try std.testing.expect(best != null);
try std.testing.expect(best.? == 1);
}