Files
cli/tests/cli.rs
zooqueenandHanzo Dev e34e3ba84f network: an invocation keeps its network; devnet trusts one brand
Each invocation is bound to the network active as it starts and keeps it
through every config reload, so selecting another network in another shell
never moves where a running command's credentials go. A coding session's
token helper names its network and realm (`auth token --brand <realm>
--network <name>`) and refuses a realm that is not that network's; the
session refuses a network redefined since it started.

A network with an issuer of its own refuses every brand but the default,
so `--brand lux` on devnet sends nothing. A network that names no issuer
is sent no credential. A custom entry named for a built-in moves its chain
endpoints only, never its API or issuer. Plain http is for a parsed
loopback address or localhost only.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-16 11:38:56 -07:00

502 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());
// `usage` fans out to api.hanzo.ai with each held account's own token. A
// stacked view always renders; a broke/expired account is a row state, not
// a crash — so success + at least one row is the honest assertion.
hanzo()
.arg("usage")
.args(cfg_args(&cfg))
.assert()
.success();
}