mirror of
https://github.com/hanzoai/cli.git
synced 2026-10-30 15:00:27 +00:00
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.
505 lines
20 KiB
Rust
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()));
|
|
}
|