Files
zeekay e351a1e3a3 Add opt-in production CLI and installer end-to-end checks
Exercise actual token login, authenticated usage, local file creation and remote coding completion. Repair the live auth test to call usage summary and verify the identity scope.

Testing: make test passed; live login with source and published binaries passed; local dev file-creation test passed. Production cloud test currently reproduces the API 402 pending release.
2026-10-18 19:34:40 -07:00

505 lines
20 KiB
Rust

//! Native functionality tests for the SHIPPED `hanzo` binary — the real
//! executable, real argv, real exit codes. Hermetic tests isolate ALL persisted
//! state behind `--config <tempdir>` (the global flag, placed after the
//! subcommand); nothing here reads or writes the developer's own config.
//!
//! Live tests (real login against hanzo.id / api.hanzo.ai) are gated on
//! `HANZO_E2E_TOKEN` — a real hanzo.id bearer. Absent → the test SKIPS with the
//! env var named on stderr (honest skip, never a fake green). NOTE: a live
//! login stores a credential through the real vault (the OS keychain on a
//! desktop; the file vault in CI) for the token's OWN identity — the same slot
//! a developer's real login for that identity uses — so the suite never runs
//! `logout` and never removes credentials.
use assert_cmd::Command;
use predicates::prelude::*;
/// A `hanzo` invocation with clean, non-interactive output, in a home of its
/// own: the binary keeps state under HOME (a telemetry id, at least), and a test
/// run is nobody's home. A test that needs a particular home sets HOME after.
fn hanzo() -> Command {
static HOME: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
let home = HOME.get_or_init(|| tempfile::tempdir().expect("a home for the tests"));
let mut cmd = Command::cargo_bin("hanzo").expect("hanzo binary builds");
cmd.env("NO_COLOR", "1");
cmd.env("HANZO_NO_ANIMATION", "1");
cmd.env("HOME", home.path());
for v in ["XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME"] {
cmd.env_remove(v);
}
cmd
}
/// The `--config <file>` tail every isolated invocation appends.
fn cfg_args(cfg: &tempfile::TempDir) -> [String; 2] {
[
"--config".into(),
cfg.path().join("config.toml").display().to_string(),
]
}
fn tmp() -> tempfile::TempDir {
tempfile::tempdir().expect("tempdir")
}
// ── What gen reads ───────────────────────────────────────────────────────────
/// `gen` reads what this CLI has on file through `hanzo config profile` — the one
/// reader of the config, so there is no second parser to drift — and holds it to
/// its own `Profile`. A `code.link = false` here keeps a gen session local. The
/// verb is for gen, so `hanzo config --help` does not offer it.
#[test]
fn config_profile_is_what_gen_reads() {
let c = tmp();
let read = |c: &tempfile::TempDir| -> hanzo_gen::Profile {
let a = hanzo().args(["config", "profile"]).args(cfg_args(c)).assert().success();
serde_json::from_slice(&a.get_output().stdout).expect("a Profile")
};
let fresh = read(&c);
assert!(fresh.link, "a session links by default");
assert!(fresh.api.starts_with("https://"), "{}", fresh.api);
assert!(fresh.identities.is_empty() && fresh.provider.is_none());
hanzo().args(["config", "set", "code.link", "false"]).args(cfg_args(&c)).assert().success();
assert!(!read(&c).link, "the opt-out reaches gen");
let help = hanzo().args(["config", "--help"]).assert().success();
assert!(!String::from_utf8_lossy(&help.get_output().stdout).contains("profile"));
}
// ── Version + surface ────────────────────────────────────────────────────────
/// `--version` reports the ONE version (Cargo.toml via CARGO_PKG_VERSION).
#[test]
fn version_is_the_cargo_version() {
hanzo()
.arg("--version")
.assert()
.success()
.stdout(predicate::str::contains(env!("CARGO_PKG_VERSION")));
}
/// THE DEFECT: `hanzo --version` printed clap's own line and exited, so it never
/// reached `commands::version::run` — the only thing that reports an install
/// skew — and it disagreed with `hanzo version` on format. All three spellings
/// must be ONE function, so their STDOUT must be byte-identical: exactly one
/// line, `hanzo <semver>`, which is what the Go control binary's delegate parser
/// reads. A skew report is a warning, so it may never land on stdout.
#[test]
fn every_version_spelling_prints_the_one_line() {
let out = |args: &[&str]| {
let a = hanzo().args(args).assert().success();
String::from_utf8_lossy(&a.get_output().stdout).to_string()
};
let one = format!("hanzo {}\n", env!("CARGO_PKG_VERSION"));
assert_eq!(out(&["--version"]), one);
assert_eq!(out(&["-V"]), one);
assert_eq!(out(&["version"]), one);
}
/// THE DEFECT: there was no `status` command, so `hanzo status` was parsed as the
/// flattened coding-session TASK and launched a headless agent. It must be a real
/// command with its own help.
#[test]
fn status_is_a_command_not_a_coding_session() {
hanzo()
.args(["status", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("unhealthy").and(predicate::str::contains("Usage: hanzo status")));
}
/// Signed out, `status` REFUSES and names the way in — it never renders an empty
/// cloud as though that were the truth.
#[test]
fn status_signed_out_refuses_and_never_shows_an_empty_fleet() {
let cfg = tmp();
hanzo()
.args(["status"])
.args(cfg_args(&cfg))
.assert()
.failure()
.stderr(predicate::str::contains("not signed in").and(predicate::str::contains("hanzo auth login")))
.stdout(predicate::str::contains("all clear").not());
}
/// A bare `hanzo` is the coding session, like `hanzo dev`: it reaches for the
/// `dev` agent (here missing, so it says how to install it) instead of printing
/// the help page, which `hanzo --help` still prints.
#[test]
fn a_bare_hanzo_starts_the_coding_agent() {
let home = tmp();
let empty_path = tmp();
let out = hanzo()
.current_dir(home.path())
.env("HOME", home.path())
.env("PATH", empty_path.path())
.write_stdin("")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&out.get_output().stderr).to_string();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(stderr.contains("the `dev` coding agent is not installed"), "{stderr}");
assert!(stderr.contains("hanzo.sh"), "the error names the installer: {stderr}");
assert!(!stdout.contains("SYNOPSIS"), "a bare hanzo printed the help page: {stdout}");
}
/// `hanzo run` is gone: it exits 2 with one line naming `hanzo dev`, and starts
/// nothing. `hanzo code` in its session form IS `hanzo dev`, so it answers what
/// `hanzo dev` answers: the tool is a flag.
#[test]
fn the_retired_launchers_name_hanzo_dev() {
let out = hanzo().args(["code", "claude"]).assert().failure();
let stderr = String::from_utf8_lossy(&out.get_output().stderr).to_string();
assert!(stderr.contains("hanzo dev --claude"), "`hanzo code claude` is `hanzo dev claude`: {stderr}");
for argv in [
vec!["run"],
vec!["run", "claude"],
vec!["run", "--runc", "dev"],
vec!["--no-route"],
] {
let out = hanzo().args(&argv).assert().failure().code(2);
let stderr = String::from_utf8_lossy(&out.get_output().stderr).to_string();
assert!(stderr.contains("hanzo dev") || argv == ["--no-route"], "{argv:?}: {stderr}");
assert!(stderr.lines().filter(|l| l.starts_with("error:")).count() == 1, "{argv:?}: {stderr}");
}
}
/// `--help` names every major resource the CLI ships (the resource-noun tree),
/// and the old top-level verbs are GONE.
#[test]
fn help_lists_the_resource_nouns() {
let out = hanzo().arg("--help").assert().success();
let help = String::from_utf8_lossy(&out.get_output().stdout).to_string();
for surface in [
"agent", "auth", "cluster", "config", "model", "node", "runner", "secret",
"serve", "version", "chain", "network", "wallet", "billing", "connector", "usage",
] {
assert!(help.contains(surface), "`hanzo --help` must list the `{surface}` resource");
}
// The relocated verbs are not top-level names any more.
for gone in ["login", "whoami", "switch", "deploy"] {
assert!(
!help.contains(&format!(" {gone} ")),
"`hanzo --help` must not list `{gone}` as a top-level command"
);
}
}
/// Every graph operation is a command, and each one that asks the graph a
/// question at a point takes both of its times: `--as-of` (when it was so) and
/// `--as-known` (how much the graph had heard).
#[test]
fn graph_asks_at_both_times() {
let help = |args: &[&str]| {
let out = hanzo().args(args).arg("--help").assert().success();
String::from_utf8_lossy(&out.get_output().stdout).to_string()
};
let top = help(&["graph"]);
let listed = |verb: &str| top.lines().any(|l| l.split_whitespace().next() == Some(verb));
for verb in [
"create", "get", "search", "resolve", "neighbors", "path", "diff", "communities", "answer",
"vocabulary", "extract", "ingest", "erase",
] {
assert!(listed(verb), "`hanzo graph` does not list `{verb}`:\n{top}");
}
for verb in ["get", "search", "resolve", "neighbors", "path", "communities", "answer"] {
let h = help(&["graph", verb]);
assert!(h.contains("--as-of") && h.contains("--as-known"), "`hanzo graph {verb}` lacks a time:\n{h}");
}
let h = help(&["graph", "diff"]);
for flag in ["--from", "--to", "--from-known", "--to-known"] {
assert!(h.contains(flag), "`hanzo graph diff` lacks {flag}:\n{h}");
}
}
/// `hanzo vm` is a pure passthrough: a resolvable, current-enough `hanzo-vm`
/// runs verbatim — no reinstall, its stdout and exit are ours. (The bootstrap
/// that installs an absent or stale binary is unit-tested in commands/vm.rs;
/// this stays hermetic.)
#[test]
fn vm_passes_through_to_a_resolved_binary() {
let home = tmp();
let bin_dir = home.path().join(".local/bin");
std::fs::create_dir_all(&bin_dir).unwrap();
// First line is the version contract (last token); second proves the args
// arrived verbatim.
let fake = bin_dir.join("hanzo-vm");
std::fs::write(&fake, "#!/bin/sh\necho \"hanzo-vm 99.0.0\"\necho \"args: $@\"\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
hanzo()
.args(["vm", "--version"])
.env("PATH", "/nonexistent")
.env("HOME", home.path())
.assert()
.success()
.stdout(predicate::str::contains("hanzo-vm 99.0.0"))
.stdout(predicate::str::contains("args: --version"));
}
// ── Network model (hermetic, config-isolated) ────────────────────────────────
/// `network list` shows the four built-ins, mainnet active by default, and the
/// sovereign L1 law (network_id == chain_id) on each.
#[test]
fn network_list_shows_builtins_with_mainnet_active() {
let cfg = tmp();
hanzo()
.args(["network", "list"])
.args(cfg_args(&cfg))
.assert()
.success()
.stdout(
predicate::str::contains("* mainnet")
.and(predicate::str::contains("testnet"))
.and(predicate::str::contains("devnet"))
.and(predicate::str::contains("local"))
.and(predicate::str::contains("36963"))
.and(predicate::str::contains("sovereign")),
);
}
/// `network use` switches and PERSISTS: a later invocation on the same config
/// reads the selection back. This proves the config write path end to end.
#[test]
fn network_use_persists_across_invocations() {
let cfg = tmp();
hanzo()
.args(["network", "use", "testnet"])
.args(cfg_args(&cfg))
.assert()
.success();
hanzo()
.args(["network", "current"])
.args(cfg_args(&cfg))
.assert()
.success()
.stdout(
predicate::str::contains("testnet")
.and(predicate::str::contains("36962"))
.and(predicate::str::contains("https://api-test.hanzo.ai")),
);
}
/// Each built-in network names its own cloud and the issuer that cloud trusts:
/// a sandbox network never reaches production's API, so nothing selected as
/// testnet or devnet spends live money, and devnet signs in at its own issuer.
#[test]
fn network_sandboxes_name_their_own_api() {
for (name, api, iam) in [
("devnet", "https://api-dev.hanzo.ai", "https://id-dev.hanzo.ai"),
("testnet", "https://api-test.hanzo.ai", "https://hanzo.id"),
("mainnet", "https://api.hanzo.ai", "https://hanzo.id"),
] {
let cfg = tmp();
hanzo().args(["network", "use", name]).args(cfg_args(&cfg)).assert().success();
hanzo()
.args(["network", "current"])
.args(cfg_args(&cfg))
.assert()
.success()
.stdout(predicate::str::contains(format!("api {api}\n")))
.stdout(predicate::str::contains(format!("iam {iam}\n")));
}
}
/// A token as an issuer mints it, with `iss` stated.
fn issued(iss: &str, owner: &str, name: &str) -> String {
use base64::Engine;
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
let claims = format!(r#"{{"iss":"{iss}","owner":"{owner}","name":"{name}","sub":"u-1"}}"#);
format!("{}.{}.c2ln", b64(br#"{"alg":"RS256","typ":"JWT"}"#), b64(claims.as_bytes()))
}
/// Identities never cross networks: devnet refuses a hanzo.id credential, holds
/// none of mainnet's, and each network's `auth token` is its own issuer's.
#[test]
fn identities_never_cross_networks() {
let (cfg, home) = (tmp(), tmp());
let run = |args: &[&str], stdin: Option<&str>| {
let mut cmd = hanzo();
cmd.args(args).args(cfg_args(&cfg)).env("HOME", home.path());
if let Some(input) = stdin {
cmd.write_stdin(format!("{input}\n"));
}
cmd.assert()
};
let login = ["auth", "login", "--provider", "hanzo", "--token", "-"];
let prod = issued("https://hanzo.id", "hanzo", "net");
let dev = issued("https://id-dev.hanzo.ai", "hanzo", "net");
run(&login, Some(&prod)).success();
run(&["network", "use", "devnet"], None).success();
run(&["auth", "token"], None).failure().stderr(predicate::str::contains("not signed in"));
run(&login, Some(&prod))
.failure()
.stderr(predicate::str::contains("issued by https://hanzo.id"));
run(&login, Some(&dev)).success();
run(&["auth", "token"], None).success().stdout(format!("{dev}\n"));
// No other brand signs in on devnet: its API trusts id-dev alone.
run(&["auth", "login", "--brand", "lux", "--provider", "hanzo", "--token", "-"], Some(&prod))
.failure()
.stderr(predicate::str::contains("lux does not sign in"));
run(&["network", "use", "mainnet"], None).success();
run(&["auth", "token"], None).success().stdout(format!("{prod}\n"));
run(&["network", "use", "testnet"], None).success();
run(&["auth", "token"], None).success().stdout(format!("{prod}\n"));
// A devnet session's token helper is bound to devnet: selecting another
// network meanwhile changes nothing it prints, and its realm is refused
// anywhere but on devnet.
let helper = ["auth", "token", "--brand", "[email protected]", "--network", "devnet"];
run(&helper, None).success().stdout(format!("{dev}\n"));
run(&["auth", "token", "--brand", "[email protected]"], None)
.failure()
.stderr(predicate::str::contains("not a realm of the active network"));
}
/// An unknown network is refused with a useful hint, non-zero.
#[test]
fn network_use_unknown_fails_with_hint() {
let cfg = tmp();
hanzo()
.args(["network", "use", "bogus"])
.args(cfg_args(&cfg))
.assert()
.failure()
.stderr(predicate::str::contains("unknown network").and(predicate::str::contains("network add")));
}
/// `network add` registers a sovereign L1 (chain-id defaults to network-id),
/// `--activate` selects it, and it round-trips through `current`.
#[test]
fn network_add_sovereign_roundtrip() {
let cfg = tmp();
hanzo()
.args([
"network", "add", "my-l1",
"--network-id", "424242",
"--rpc", "https://rpc.my-l1.example",
"--api", "https://api.my-l1.example",
"--activate",
])
.args(cfg_args(&cfg))
.assert()
.success();
hanzo()
.args(["network", "current"])
.args(cfg_args(&cfg))
.assert()
.success()
.stdout(
predicate::str::contains("my-l1")
.and(predicate::str::contains("424242")) // chain_id defaulted == network_id
.and(predicate::str::contains("https://rpc.my-l1.example")),
);
}
// ── Identity + secrets law (hermetic) ────────────────────────────────────────
/// `auth show` with no identity is an honest refusal naming `hanzo auth login`.
#[test]
fn auth_show_signed_out_refuses_with_login_hint() {
let cfg = tmp();
hanzo()
.args(["auth", "show"])
.args(cfg_args(&cfg))
.assert()
.failure()
.stderr(
predicate::str::contains("not signed in").and(predicate::str::contains("hanzo auth login")),
);
}
/// The stdin-secret law: a literal token on argv is REFUSED (it would land in
/// `ps` and shell history) — the grammar itself demands stdin.
#[test]
fn login_refuses_a_literal_token_on_argv() {
let cfg = tmp();
hanzo()
.args(["auth", "login", "--provider", "hanzo", "--token", "not-a-real-token"])
.args(cfg_args(&cfg))
.assert()
.failure();
}
/// A garbage bearer on stdin fails CLEANLY: no identity can be derived from a
/// non-JWT, so nothing is stored and the exit is non-zero.
#[test]
fn login_rejects_a_garbage_token_from_stdin() {
let cfg = tmp();
hanzo()
.args(["auth", "login", "--provider", "hanzo", "--token", "-"])
.args(cfg_args(&cfg))
.write_stdin("garbage-not-a-jwt\n")
.assert()
.failure();
}
/// `wallet list` with no wallets is a clean empty state (exit 0) that names the
/// create path — never an error, never an invented wallet.
#[test]
fn wallet_list_empty_state() {
let cfg = tmp();
hanzo()
.args(["wallet", "list"])
.args(cfg_args(&cfg))
.assert()
.success()
.stdout(predicate::str::contains("no wallets").and(predicate::str::contains("hanzo wallet create")));
}
// ── Live auth flow (gated on HANZO_E2E_TOKEN) ────────────────────────────────
/// Skip helper: honest skip on stderr naming the exact env var.
fn e2e_token() -> Option<String> {
match std::env::var("HANZO_E2E_TOKEN") {
Ok(t) if !t.trim().is_empty() => Some(t.trim().to_string()),
_ => {
eprintln!("SKIP: set HANZO_E2E_TOKEN (a real hanzo.id bearer) to run the live auth flow");
None
}
}
}
/// The REAL auth flow with a token from env: `login --token -` (stdin) files the
/// identity, `whoami` reports it, and `usage` reads the live per-account
/// balances from api.hanzo.ai with that credential. One flow, live.
#[test]
fn live_login_whoami_usage_with_env_token() {
let Some(token) = e2e_token() else { return };
let cfg = tmp();
hanzo()
.args(["auth", "login", "--provider", "hanzo", "--token", "-"])
.args(cfg_args(&cfg))
.write_stdin(format!("{token}\n"))
.assert()
.success();
hanzo()
.args(["auth", "show"])
.args(cfg_args(&cfg))
.assert()
.success()
// auth show prints the active identity as owner/name — never "not signed in".
.stdout(predicate::str::contains("/"))
.stdout(predicate::str::contains("not signed in").not());
// Exercise the published command and verify that the response belongs to
// an authenticated account, rather than accepting an HTML page or empty JSON.
let usage = hanzo()
.args(["usage", "summary"])
.args(cfg_args(&cfg))
.assert()
.success();
let summary: serde_json::Value = serde_json::from_slice(&usage.get_output().stdout)
.expect("usage summary returns JSON");
assert!(summary["scope"]["org"].as_str().is_some_and(|v| !v.is_empty()));
assert!(summary["scope"]["user"].as_str().is_some_and(|v| !v.is_empty()));
}