use nils_test_support::bin;
use nils_test_support::cmd::{self, CmdOptions, CmdOutput};
use nils_test_support::write_exe;
use pretty_assertions::assert_eq;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
const HEADER: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0";
const PAYLOAD_ALPHA: &str = "eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxwaGFAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91c2VyX2lkIjoidXNlcl8xMjMiLCJlbWFpbCI6ImFscGhhQGV4YW1wbGUuY29tIn19";
const PAYLOAD_BETA: &str = "eyJzdWIiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSIsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSJ9fQ";
fn token(payload: &str) -> String {
format!("{HEADER}.{payload}.sig")
}
fn auth_json(payload: &str, account_id: &str, refresh_token: &str, last_refresh: &str) -> String {
format!(
r#"{{"tokens":{{"access_token":"{}","id_token":"{}","refresh_token":"{}","account_id":"{}"}},"last_refresh":"{}"}}"#,
token(payload),
token(payload),
refresh_token,
account_id,
last_refresh
)
}
fn codex_cli_bin() -> PathBuf {
bin::resolve("codex-cli")
}
fn run(args: &[&str], envs: &[(&str, &Path)]) -> CmdOutput {
run_with(args, envs, &[])
}
fn run_with(args: &[&str], envs: &[(&str, &Path)], vars: &[(&str, &str)]) -> CmdOutput {
let mut options = CmdOptions::default();
for (key, path) in envs {
let value = path.to_string_lossy();
options = options.with_env(key, value.as_ref());
}
for (key, value) in vars {
options = options.with_env(key, value);
}
let bin = codex_cli_bin();
cmd::run_with(&bin, args, &options)
}
fn run_with_path_prepend(
args: &[&str],
envs: &[(&str, &Path)],
vars: &[(&str, &str)],
path_prepend: &Path,
) -> CmdOutput {
let mut options = CmdOptions::default().with_path_prepend(path_prepend);
for (key, path) in envs {
let value = path.to_string_lossy();
options = options.with_env(key, value.as_ref());
}
for (key, value) in vars {
options = options.with_env(key, value);
}
let bin = codex_cli_bin();
cmd::run_with(&bin, args, &options)
}
fn stdout(output: &CmdOutput) -> String {
output.stdout_text()
}
fn codex_stub_script() -> &'static str {
r#"#!/bin/bash
set -euo pipefail
exit "${CODEX_STUB_EXIT_CODE:-0}"
"#
}
#[test]
fn auth_json_contract_current_success_includes_stable_fields() {
let dir = tempfile::TempDir::new().expect("tempdir");
let secrets = dir.path().join("secrets");
fs::create_dir_all(&secrets).expect("secrets dir");
let auth_file = dir.path().join("auth.json");
let content = auth_json(
PAYLOAD_ALPHA,
"acct_001",
"refresh_a",
"2025-01-20T12:34:56Z",
);
fs::write(&auth_file, &content).expect("write auth");
fs::write(secrets.join("alpha.json"), &content).expect("write secret");
let output = run(
&["auth", "current", "--json"],
&[
("CODEX_AUTH_FILE", &auth_file),
("CODEX_SECRET_DIR", &secrets),
],
);
assert_eq!(output.code, 0);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth current");
assert_eq!(payload["ok"], true);
assert_eq!(payload["result"]["matched"], true);
assert_eq!(payload["result"]["matched_secret"], "alpha.json");
}
#[test]
fn auth_json_contract_use_ambiguous_returns_structured_error() {
let dir = tempfile::TempDir::new().expect("tempdir");
let secrets = dir.path().join("secrets");
fs::create_dir_all(&secrets).expect("secrets dir");
let auth_file = dir.path().join("auth.json");
fs::write(&auth_file, "{}").expect("write auth");
let content = auth_json(
PAYLOAD_ALPHA,
"acct_001",
"refresh_a",
"2025-01-20T12:34:56Z",
);
fs::write(secrets.join("alpha.json"), &content).expect("write secret");
fs::write(secrets.join("alpha-dup.json"), &content).expect("write secret");
let output = run(
&["auth", "use", "--json", "alpha@example.com"],
&[
("CODEX_AUTH_FILE", &auth_file),
("CODEX_SECRET_DIR", &secrets),
],
);
assert_eq!(output.code, 2);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth use");
assert_eq!(payload["ok"], false);
assert_eq!(payload["error"]["code"], "ambiguous-secret");
assert!(payload["error"]["details"]["candidates"].is_array());
}
#[test]
fn auth_json_contract_auto_refresh_unconfigured_returns_zeroed_result() {
let dir = tempfile::TempDir::new().expect("tempdir");
let auth_file = dir.path().join("missing_auth.json");
let secret_dir = dir.path().join("secrets");
fs::create_dir_all(&secret_dir).expect("secret dir");
let output = run(
&["auth", "auto-refresh", "--json"],
&[
("CODEX_AUTH_FILE", &auth_file),
("CODEX_SECRET_DIR", &secret_dir),
],
);
assert_eq!(output.code, 0);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth auto-refresh");
assert_eq!(payload["ok"], true);
assert_eq!(payload["result"]["refreshed"], 0);
assert_eq!(payload["result"]["failed"], 0);
}
#[test]
fn auth_json_contract_refresh_invalid_name_is_structured() {
let output = run(&["auth", "refresh", "--json", "../bad.json"], &[]);
assert_eq!(output.code, 64);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth refresh");
assert_eq!(payload["ok"], false);
assert_eq!(payload["error"]["code"], "invalid-secret-file-name");
assert_eq!(
payload["error"]["message"],
"codex-refresh: invalid secret file name"
);
assert!(payload["error"]["details"]["secret"].is_null());
}
#[test]
fn auth_json_contract_current_missing_auth_file_is_structured() {
let dir = tempfile::TempDir::new().expect("tempdir");
let auth_file = dir.path().join("missing.json");
let secret_dir = dir.path().join("secrets");
fs::create_dir_all(&secret_dir).expect("secrets dir");
let output = run(
&["auth", "current", "--format", "json"],
&[
("CODEX_AUTH_FILE", &auth_file),
("CODEX_SECRET_DIR", &secret_dir),
],
);
assert_eq!(output.code, 1);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth current");
assert_eq!(payload["ok"], false);
assert_eq!(payload["error"]["code"], "auth-file-not-found");
}
#[test]
fn auth_json_contract_current_defaults_to_home_secret_dir_when_env_unset() {
let dir = tempfile::TempDir::new().expect("tempdir");
let home = dir.path().join("home");
let auth_dir = home.join(".agents");
let secret_dir = home.join(".config").join("codex_secrets");
fs::create_dir_all(&auth_dir).expect("auth dir");
fs::create_dir_all(&secret_dir).expect("secret dir");
let auth_file = auth_dir.join("auth.json");
let content = auth_json(
PAYLOAD_ALPHA,
"acct_001",
"refresh_a",
"2025-01-20T12:34:56Z",
);
fs::write(&auth_file, &content).expect("write auth");
fs::write(secret_dir.join("alpha.json"), &content).expect("write secret");
let home_str = home.to_string_lossy().to_string();
let output = run_with(
&["auth", "current", "--json"],
&[],
&[
("HOME", home_str.as_str()),
("CODEX_AUTH_FILE", ""),
("CODEX_SECRET_DIR", ""),
],
);
assert_eq!(output.code, 0);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth current");
assert_eq!(payload["ok"], true);
assert_eq!(
payload["result"]["auth_file"],
auth_file.display().to_string()
);
assert_eq!(payload["result"]["matched"], true);
assert_eq!(payload["result"]["matched_secret"], "alpha.json");
}
#[test]
fn auth_json_contract_current_missing_default_secret_dir_is_structured() {
let dir = tempfile::TempDir::new().expect("tempdir");
let home = dir.path().join("home");
let auth_dir = home.join(".agents");
fs::create_dir_all(&auth_dir).expect("auth dir");
let auth_file = auth_dir.join("auth.json");
fs::write(
&auth_file,
auth_json(
PAYLOAD_ALPHA,
"acct_001",
"refresh_a",
"2025-01-20T12:34:56Z",
),
)
.expect("write auth");
let home_str = home.to_string_lossy().to_string();
let output = run_with(
&["auth", "current", "--json"],
&[],
&[
("HOME", home_str.as_str()),
("CODEX_AUTH_FILE", ""),
("CODEX_SECRET_DIR", ""),
],
);
assert_eq!(output.code, 1);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth current");
assert_eq!(payload["ok"], false);
assert_eq!(payload["error"]["code"], "secret-dir-not-found");
assert_eq!(
payload["error"]["details"]["secret_dir"],
home.join(".config")
.join("codex_secrets")
.display()
.to_string()
);
}
#[test]
fn auth_json_contract_current_no_match_is_structured() {
let dir = tempfile::TempDir::new().expect("tempdir");
let secrets = dir.path().join("secrets");
fs::create_dir_all(&secrets).expect("secrets dir");
let auth_file = dir.path().join("auth.json");
fs::write(
&auth_file,
auth_json(
PAYLOAD_ALPHA,
"acct_001",
"refresh_a",
"2025-01-20T12:34:56Z",
),
)
.expect("write auth");
fs::write(
secrets.join("beta.json"),
auth_json(
PAYLOAD_BETA,
"acct_002",
"refresh_b",
"2025-01-21T12:34:56Z",
),
)
.expect("write beta");
let output = run(
&["auth", "current", "--json"],
&[
("CODEX_AUTH_FILE", &auth_file),
("CODEX_SECRET_DIR", &secrets),
],
);
assert_eq!(output.code, 2);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth current");
assert_eq!(payload["ok"], false);
assert_eq!(payload["error"]["code"], "secret-not-matched");
}
#[test]
fn auth_json_contract_sync_missing_auth_reports_skipped() {
let dir = tempfile::TempDir::new().expect("tempdir");
let auth_file = dir.path().join("missing.json");
let output = run(
&["auth", "sync", "--json"],
&[("CODEX_AUTH_FILE", &auth_file)],
);
assert_eq!(output.code, 0);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth sync");
assert_eq!(payload["ok"], true);
assert_eq!(payload["result"]["synced"], 0);
assert_eq!(payload["result"]["skipped"], 1);
assert_eq!(
payload["result"]["auth_file"],
auth_file.display().to_string()
);
}
#[test]
fn auth_json_contract_sync_success_reports_updated_files() {
let dir = tempfile::TempDir::new().expect("tempdir");
let secrets = dir.path().join("secrets");
let cache = dir.path().join("cache");
fs::create_dir_all(&secrets).expect("secrets dir");
fs::create_dir_all(&cache).expect("cache dir");
let auth_file = dir.path().join("auth.json");
fs::write(
&auth_file,
auth_json(
PAYLOAD_ALPHA,
"acct_001",
"refresh_a",
"2025-01-20T12:34:56Z",
),
)
.expect("write auth");
let alpha = secrets.join("alpha.json");
fs::write(
&alpha,
auth_json(
PAYLOAD_ALPHA,
"acct_001",
"refresh_b",
"2025-01-21T12:34:56Z",
),
)
.expect("write alpha");
fs::write(
secrets.join("beta.json"),
auth_json(
PAYLOAD_BETA,
"acct_002",
"refresh_c",
"2025-01-22T12:34:56Z",
),
)
.expect("write beta");
let output = run(
&["auth", "sync", "--json"],
&[
("CODEX_AUTH_FILE", &auth_file),
("CODEX_SECRET_DIR", &secrets),
("CODEX_SECRET_CACHE_DIR", &cache),
],
);
assert_eq!(output.code, 0);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth sync");
assert_eq!(payload["ok"], true);
assert_eq!(payload["result"]["synced"], 1);
assert_eq!(payload["result"]["failed"], 0);
let updated = payload["result"]["updated_files"]
.as_array()
.expect("updated_files");
assert_eq!(updated.len(), 1);
assert_eq!(updated[0], alpha.display().to_string());
}
#[test]
fn auth_json_contract_use_not_found_is_structured() {
let dir = tempfile::TempDir::new().expect("tempdir");
let secrets = dir.path().join("secrets");
fs::create_dir_all(&secrets).expect("secrets dir");
let auth_file = dir.path().join("auth.json");
fs::write(&auth_file, "{}").expect("write auth");
let output = run(
&["auth", "use", "--json", "missing@example.com"],
&[
("CODEX_AUTH_FILE", &auth_file),
("CODEX_SECRET_DIR", &secrets),
],
);
assert_eq!(output.code, 1);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth use");
assert_eq!(payload["ok"], false);
assert_eq!(payload["error"]["code"], "secret-not-found");
}
#[test]
fn auth_json_contract_use_invalid_name_is_structured() {
let output = run(&["auth", "use", "--format", "json", "../bad"], &[]);
assert_eq!(output.code, 64);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth use");
assert_eq!(payload["ok"], false);
assert_eq!(payload["error"]["code"], "invalid-secret-name");
}
#[test]
fn auth_json_contract_login_success_includes_stable_fields() {
let dir = tempfile::TempDir::new().expect("tempdir");
let stubs = dir.path().join("stubs");
fs::create_dir_all(&stubs).expect("stubs");
write_exe(&stubs, "codex", codex_stub_script());
let output = run_with_path_prepend(&["auth", "login", "--json", "--api-key"], &[], &[], &stubs);
assert_eq!(output.code, 0);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth login");
assert_eq!(payload["ok"], true);
assert_eq!(payload["result"]["method"], "api-key");
assert_eq!(payload["result"]["provider"], "openai-api");
}
#[test]
fn auth_json_contract_login_exec_failure_is_structured() {
let output = run_with(&["auth", "login", "--json"], &[], &[("PATH", "")]);
assert_eq!(output.code, 1);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth login");
assert_eq!(payload["ok"], false);
assert_eq!(payload["error"]["code"], "login-exec-failed");
}
#[test]
fn auth_json_contract_save_success_includes_stable_fields() {
let dir = tempfile::TempDir::new().expect("tempdir");
let secrets = dir.path().join("secrets");
fs::create_dir_all(&secrets).expect("secrets");
let auth_file = dir.path().join("auth.json");
fs::write(&auth_file, r#"{"tokens":{"access_token":"tok"}}"#).expect("write auth");
let output = run(
&["auth", "save", "--json", "alpha.json"],
&[
("CODEX_AUTH_FILE", &auth_file),
("CODEX_SECRET_DIR", &secrets),
],
);
assert_eq!(output.code, 0);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth save");
assert_eq!(payload["ok"], true);
assert_eq!(payload["result"]["saved"], true);
assert_eq!(payload["result"]["overwritten"], false);
}
#[test]
fn auth_json_contract_save_overwrite_requires_confirmation() {
let dir = tempfile::TempDir::new().expect("tempdir");
let secrets = dir.path().join("secrets");
fs::create_dir_all(&secrets).expect("secrets");
let auth_file = dir.path().join("auth.json");
fs::write(&auth_file, r#"{"tokens":{"access_token":"tok-new"}}"#).expect("write auth");
fs::write(
secrets.join("alpha.json"),
r#"{"tokens":{"access_token":"tok-old"}}"#,
)
.expect("write target");
let output = run(
&["auth", "save", "--json", "alpha.json"],
&[
("CODEX_AUTH_FILE", &auth_file),
("CODEX_SECRET_DIR", &secrets),
],
);
assert_eq!(output.code, 1);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth save");
assert_eq!(payload["ok"], false);
assert_eq!(payload["error"]["code"], "overwrite-confirmation-required");
}
#[test]
fn auth_json_contract_remove_success_includes_stable_fields() {
let dir = tempfile::TempDir::new().expect("tempdir");
let secrets = dir.path().join("secrets");
fs::create_dir_all(&secrets).expect("secrets");
fs::write(
secrets.join("alpha.json"),
r#"{"tokens":{"access_token":"tok-old"}}"#,
)
.expect("write target");
let output = run(
&["auth", "remove", "--json", "--yes", "alpha.json"],
&[("CODEX_SECRET_DIR", &secrets)],
);
assert_eq!(output.code, 0);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth remove");
assert_eq!(payload["ok"], true);
assert_eq!(payload["result"]["removed"], true);
assert_eq!(
payload["result"]["target_file"],
secrets.join("alpha.json").display().to_string()
);
}
#[test]
fn auth_json_contract_remove_requires_confirmation() {
let dir = tempfile::TempDir::new().expect("tempdir");
let secrets = dir.path().join("secrets");
fs::create_dir_all(&secrets).expect("secrets");
fs::write(
secrets.join("alpha.json"),
r#"{"tokens":{"access_token":"tok-old"}}"#,
)
.expect("write target");
let output = run(
&["auth", "remove", "--json", "alpha.json"],
&[("CODEX_SECRET_DIR", &secrets)],
);
assert_eq!(output.code, 1);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth remove");
assert_eq!(payload["ok"], false);
assert_eq!(payload["error"]["code"], "remove-confirmation-required");
}
#[test]
fn auth_json_contract_auto_refresh_invalid_min_days_is_structured() {
let dir = tempfile::TempDir::new().expect("tempdir");
let auth_file = dir.path().join("auth.json");
let secret_dir = dir.path().join("secrets");
fs::create_dir_all(&secret_dir).expect("secrets dir");
fs::write(&auth_file, r#"{"last_refresh":"2025-01-20T12:34:56Z"}"#).expect("write auth");
let output = run_with(
&["auth", "auto-refresh", "--json"],
&[
("CODEX_AUTH_FILE", &auth_file),
("CODEX_SECRET_DIR", &secret_dir),
],
&[("CODEX_AUTO_REFRESH_MIN_DAYS", "oops")],
);
assert_eq!(output.code, 64);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth auto-refresh");
assert_eq!(payload["ok"], false);
assert_eq!(payload["error"]["code"], "invalid-min-days");
}
#[test]
fn auth_json_contract_refresh_missing_token_is_structured() {
let dir = tempfile::TempDir::new().expect("tempdir");
let auth_file = dir.path().join("auth.json");
fs::write(&auth_file, r#"{"tokens":{"access_token":"tok-only"}}"#).expect("write auth");
let output = run(
&["auth", "refresh", "--json"],
&[("CODEX_AUTH_FILE", &auth_file)],
);
assert_eq!(output.code, 2);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth refresh");
assert_eq!(payload["ok"], false);
assert_eq!(payload["error"]["code"], "refresh-token-missing");
}
#[test]
fn auth_json_contract_refresh_bad_json_is_structured() {
let dir = tempfile::TempDir::new().expect("tempdir");
let secrets = dir.path().join("secrets");
fs::create_dir_all(&secrets).expect("secrets dir");
fs::write(secrets.join("alpha.json"), "{not-json").expect("write invalid");
let output = run(
&["auth", "refresh", "--json", "alpha.json"],
&[("CODEX_SECRET_DIR", &secrets)],
);
assert_eq!(output.code, 2);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth refresh");
assert_eq!(payload["ok"], false);
assert_eq!(payload["error"]["code"], "refresh-token-read-failed");
}
#[test]
fn auth_json_contract_refresh_missing_target_is_structured() {
let dir = tempfile::TempDir::new().expect("tempdir");
let secrets = dir.path().join("secrets");
fs::create_dir_all(&secrets).expect("secrets dir");
let output = run(
&["auth", "refresh", "--format", "json", "missing.json"],
&[("CODEX_SECRET_DIR", &secrets)],
);
assert_eq!(output.code, 1);
let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
assert_eq!(payload["schema_version"], "codex-cli.auth.v1");
assert_eq!(payload["command"], "auth refresh");
assert_eq!(payload["ok"], false);
assert_eq!(payload["error"]["code"], "target-not-found");
}