cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 0a9453fe47d47f6e8b11f8ad39570d72c6cf813d
parent b159e1225161443c327cd0304c4620580e230505
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 03:32:32 +0000

cli: cut over public parser to mvp surface

- route the binary through target operations and output envelopes
- remove legacy dispatcher and renderer from the active binary graph
- replace legacy public-command integration tests with mvp contract coverage
- reject removed commands, removed flags, unsupported ndjson, and missing approval

Diffstat:
Msrc/cli.rs | 1305+------------------------------------------------------------------------------
Msrc/domain/runtime.rs | 2++
Msrc/main.rs | 361+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/runtime/mod.rs | 2++
Msrc/target_cli.rs | 2+-
Dtests/doctor.rs | 154-------------------------------------------------------------------------------
Dtests/farm.rs | 274-------------------------------------------------------------------------------
Dtests/find.rs | 541-------------------------------------------------------------------------------
Dtests/help.rs | 168-------------------------------------------------------------------------------
Dtests/identity_commands.rs | 601-------------------------------------------------------------------------------
Dtests/job_rpc.rs | 888-------------------------------------------------------------------------------
Dtests/listing.rs | 1860-------------------------------------------------------------------------------
Dtests/local.rs | 164-------------------------------------------------------------------------------
Dtests/market.rs | 437-------------------------------------------------------------------------------
Dtests/myc_status.rs | 922-------------------------------------------------------------------------------
Dtests/order.rs | 1817-------------------------------------------------------------------------------
Dtests/relay_net.rs | 154-------------------------------------------------------------------------------
Dtests/runtime_management.rs | 367-------------------------------------------------------------------------------
Dtests/runtime_show.rs | 873-------------------------------------------------------------------------------
Dtests/sell.rs | 414-------------------------------------------------------------------------------
Dtests/signer_status.rs | 201-------------------------------------------------------------------------------
Dtests/sync.rs | 200-------------------------------------------------------------------------------
Atests/target_cli.rs | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dtests/workflow.rs | 274-------------------------------------------------------------------------------
24 files changed, 472 insertions(+), 11646 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use clap::{ ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum, error::ErrorKind, @@ -24,142 +26,9 @@ impl OutputFormatArg { } } -const ROOT_HELP: &str = "\ -radroots - local food trade on Nostr - -Start here - setup Guided first-time setup for sellers and buyers - status Show what is ready and what needs attention - -Sell from your farm - farm Set up and publish your farm - sell Create, check, and publish listings - -Buy from the market - market Update local market data and search listings - order Create and manage order requests - -Accounts and settings - account Create, import, and manage local accounts - config Show effective configuration - -Advanced and troubleshooting - doctor Check readiness and suggest next steps - local Manage local market data storage - sync Inspect sync status and watch updates - relay Show relay configuration - net Show network posture - signer Show signer readiness - rpc Show runtime bridge status - myc Show myc status - runtime Manage runtimes - job Inspect background jobs - listing Advanced listing commands - find Advanced search command - -Global options - --output <human|json|ndjson> - --json - --ndjson - --dry-run - --no-input - --yes - --quiet - --verbose - --trace - --no-color - --account <ACCOUNT> - --signer <SIGNER> - --relay <RELAY> - --myc-status-timeout-ms <MS> - -Examples - radroots setup seller - radroots market search eggs - radroots sell add tomatoes - radroots order create --listing sf-tomatoes --bin bin-1 --qty 2 -"; - -const SETUP_HELP: &str = "\ -Examples: - radroots setup seller - radroots setup buyer - radroots setup both - -This workflow layer sits on top of the existing account, local, and farm commands. -Use `radroots account create` or `radroots account select` explicitly when no actor is resolved. -"; - -const STATUS_HELP: &str = "\ -Examples: - radroots status - radroots doctor - radroots config show - -This workflow summary reflects the current readiness and configuration surfaces. -When no actor is resolved, it points to explicit account commands instead of mutating account state. -"; - -const ACCOUNT_HELP: &str = "\ -Examples: - radroots account create - radroots account import ./identity.json - radroots account view - radroots account list - radroots account select market-main - radroots account clear-default - radroots account remove market-main - -Select stores the default account. Clear-default removes the stored default without deleting accounts. - -Compatibility aliases: new, whoami, ls, use. -"; - -const FARM_HELP: &str = "\ -Examples: - radroots farm init - radroots farm set delivery pickup - radroots farm check - radroots farm show --scope workspace - radroots farm publish - -Compatibility paths: `farm setup`, `farm status`, and `farm get` remain available. -"; - -const MARKET_HELP: &str = "\ -Examples: - radroots market update - radroots market search tomatoes - radroots market view sf-tomatoes - -Compatibility paths: `sync pull`, `find`, and `listing get` remain available. -"; - -const SELL_HELP: &str = "\ -Examples: - radroots sell add tomatoes --pack \"1 kg\" --price \"10 USD/kg\" --stock 25 - radroots sell check ./listing.toml - radroots sell publish ./listing.toml - -Compatibility path: the advanced `listing` command family remains available. -"; - -const ORDER_HELP: &str = "\ -Examples: - radroots order create --listing sf-tomatoes --bin bin-1 --qty 2 - radroots order view ord_demo - radroots order list - radroots order submit ord_demo --watch - -Compatibility aliases: new, get, ls. -"; - #[derive(Debug, Parser, Clone)] #[command(name = "radroots")] #[command(version)] -#[command( - after_help = "Global output: use --output <human|json|ndjson>. Existing --json and --ndjson aliases remain supported." -)] pub struct CliArgs { #[arg(skip)] pub output_format: Option<OutputFormatArg>, @@ -252,14 +121,6 @@ impl CliArgs { pub fn build_command() -> clap::Command { <Self as CommandFactory>::command() - .override_help(ROOT_HELP) - .mut_subcommand("setup", |command| command.after_help(SETUP_HELP)) - .mut_subcommand("status", |command| command.after_help(STATUS_HELP)) - .mut_subcommand("account", |command| command.after_help(ACCOUNT_HELP)) - .mut_subcommand("farm", |command| command.after_help(FARM_HELP)) - .mut_subcommand("market", |command| command.after_help(MARKET_HELP)) - .mut_subcommand("sell", |command| command.after_help(SELL_HELP)) - .mut_subcommand("order", |command| command.after_help(ORDER_HELP)) } fn command_error(message: impl Into<String>, kind: ErrorKind) -> clap::Error { @@ -1263,1165 +1124,3 @@ pub struct SellRestockArgs { pub file: PathBuf, pub available: String, } - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::{ - AccountCommand, CliArgs, Command, ConfigCommand, FarmCommand, FarmFieldArg, FarmScopeArg, - JobCommand, JobWatchArgs, ListingCommand, LocalCommand, LocalExportFormatArg, - MarketCommand, MycCommand, NetCommand, OrderCommand, OrderWatchArgs, OutputFormatArg, - RelayCommand, RpcCommand, RuntimeCommand, RuntimeConfigCommand, SellCommand, SetupRoleArg, - SignerCommand, SignerSessionCommand, SyncCommand, SyncWatchArgs, - }; - use crate::runtime::config::OutputFormat; - #[test] - fn parses_config_show_command() { - let parsed = CliArgs::parse_from(["radroots", "config", "show"]); - match parsed.command { - Command::Config(config) => match config.command { - ConfigCommand::Show => {} - }, - _ => panic!("unexpected command variant"), - } - } - - #[test] - fn parses_global_runtime_flags() { - let parsed = CliArgs::parse_from([ - "radroots", - "--output", - "json", - "--json", - "--verbose", - "--dry-run", - "--no-color", - "--no-input", - "--yes", - "--env-file", - ".env.local", - "--account", - "acct_demo", - "--log-filter", - "debug,radroots_cli=trace", - "--log-dir", - "logs", - "--log-stdout", - "--identity-path", - "identity.local.json", - "--signer", - "myc", - "--relay", - "wss://relay.one", - "--relay", - "wss://relay.two", - "--myc-executable", - "bin/myc", - "--myc-status-timeout-ms", - "2500", - "--hyf-enabled", - "--hyf-executable", - "bin/hyfd", - "config", - "show", - ]); - assert_eq!(parsed.output_format, Some(OutputFormatArg::Json)); - assert!(parsed.json); - assert!(parsed.verbose); - assert!(parsed.dry_run); - assert!(parsed.no_color); - assert!(parsed.no_input); - assert!(parsed.yes); - assert_eq!( - parsed.env_file.as_deref().and_then(|path| path.to_str()), - Some(".env.local") - ); - assert_eq!( - parsed.log_filter.as_deref(), - Some("debug,radroots_cli=trace") - ); - assert_eq!( - parsed.log_dir.as_deref().and_then(|path| path.to_str()), - Some("logs") - ); - assert_eq!(parsed.account.as_deref(), Some("acct_demo")); - assert!(parsed.log_stdout); - assert_eq!( - parsed - .identity_path - .as_deref() - .and_then(|path| path.to_str()), - Some("identity.local.json") - ); - assert_eq!(parsed.signer.as_deref(), Some("myc")); - assert_eq!( - parsed.relay, - vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()] - ); - assert_eq!( - parsed - .myc_executable - .as_deref() - .and_then(|path| path.to_str()), - Some("bin/myc") - ); - assert_eq!(parsed.myc_status_timeout_ms, Some(2500)); - assert!(parsed.hyf_enabled); - assert_eq!( - parsed - .hyf_executable - .as_deref() - .and_then(|path| path.to_str()), - Some("bin/hyfd") - ); - } - - #[test] - fn parses_output_format_and_interaction_flags() { - let parsed = CliArgs::parse_from([ - "radroots", - "--output", - "ndjson", - "--non-interactive", - "--yes", - "config", - "show", - ]); - assert_eq!(parsed.output_format, Some(OutputFormatArg::Ndjson)); - assert!(parsed.no_input); - assert!(parsed.yes); - } - - #[test] - fn parses_output_format_after_non_conflicting_subcommand() { - let parsed = CliArgs::parse_from(["radroots", "config", "show", "--output", "json"]); - assert_eq!(parsed.output_format, Some(OutputFormatArg::Json)); - match parsed.command { - Command::Config(config) => match config.command { - ConfigCommand::Show => {} - }, - _ => panic!("unexpected command variant"), - } - } - - #[test] - fn low_level_output_flags_remain_command_local() { - let parsed = CliArgs::parse_from([ - "radroots", - "--output", - "json", - "listing", - "new", - "--output", - "listing.toml", - ]); - assert_eq!(parsed.output_format, Some(OutputFormatArg::Json)); - match parsed.command { - Command::Listing(listing) => match listing.command { - ListingCommand::New(args) => { - assert_eq!( - args.output.as_deref().and_then(|path| path.to_str()), - Some("listing.toml") - ); - } - _ => panic!("unexpected listing subcommand"), - }, - _ => panic!("unexpected command variant"), - } - } - - #[test] - fn command_group_output_flag_can_still_target_global_format() { - let parsed = CliArgs::parse_from([ - "radroots", - "listing", - "--output", - "json", - "new", - "--output", - "listing.toml", - ]); - assert_eq!(parsed.output_format, Some(OutputFormatArg::Json)); - match parsed.command { - Command::Listing(listing) => match listing.command { - ListingCommand::New(args) => { - assert_eq!( - args.output.as_deref().and_then(|path| path.to_str()), - Some("listing.toml") - ); - } - _ => panic!("unexpected listing subcommand"), - }, - _ => panic!("unexpected command variant"), - } - } - - #[test] - fn parses_human_first_top_level_commands() { - let setup = CliArgs::parse_from(["radroots", "setup", "seller"]); - match setup.command { - Command::Setup(args) => assert_eq!(args.role, SetupRoleArg::Seller), - _ => panic!("unexpected command variant"), - } - - let status = CliArgs::parse_from(["radroots", "status"]); - assert!(matches!(status.command, Command::Status)); - - let market = CliArgs::parse_from(["radroots", "market", "search", "eggs"]); - match market.command { - Command::Market(args) => match args.command { - MarketCommand::Search(find) => assert_eq!(find.query, vec!["eggs"]), - _ => panic!("unexpected market subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let sell = CliArgs::parse_from([ - "radroots", - "sell", - "add", - "eggs", - "--pack", - "dozen", - "--price", - "8 USD/dozen", - "--stock", - "10", - ]); - match sell.command { - Command::Sell(args) => match args.command { - SellCommand::Add(add) => { - assert_eq!(add.product, "eggs"); - assert_eq!(add.pack.as_deref(), Some("dozen")); - assert_eq!(add.price_expr.as_deref(), Some("8 USD/dozen")); - assert_eq!(add.stock.as_deref(), Some("10")); - } - _ => panic!("unexpected sell subcommand"), - }, - _ => panic!("unexpected command variant"), - } - } - - #[test] - fn parses_human_first_aliases() { - let account_create = CliArgs::parse_from(["radroots", "account", "create"]); - match account_create.command { - Command::Account(account) => assert!(matches!(account.command, AccountCommand::New)), - _ => panic!("unexpected command variant"), - } - - let account_view = CliArgs::parse_from(["radroots", "account", "view"]); - match account_view.command { - Command::Account(account) => assert!(matches!(account.command, AccountCommand::Whoami)), - _ => panic!("unexpected command variant"), - } - - let account_list = CliArgs::parse_from(["radroots", "account", "list"]); - match account_list.command { - Command::Account(account) => assert!(matches!(account.command, AccountCommand::Ls)), - _ => panic!("unexpected command variant"), - } - - let account_select = CliArgs::parse_from(["radroots", "account", "select", "market-main"]); - match account_select.command { - Command::Account(account) => match account.command { - AccountCommand::Use(args) => assert_eq!(args.selector, "market-main"), - _ => panic!("unexpected account subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let farm_check = CliArgs::parse_from(["radroots", "farm", "check"]); - match farm_check.command { - Command::Farm(farm) => assert!(matches!(farm.command, FarmCommand::Status(_))), - _ => panic!("unexpected command variant"), - } - - let farm_show = CliArgs::parse_from(["radroots", "farm", "show"]); - match farm_show.command { - Command::Farm(farm) => assert!(matches!(farm.command, FarmCommand::Get(_))), - _ => panic!("unexpected command variant"), - } - - let market_view = CliArgs::parse_from(["radroots", "market", "view", "lst_123"]); - match market_view.command { - Command::Market(market) => match market.command { - MarketCommand::View(args) => assert_eq!(args.key, "lst_123"), - _ => panic!("unexpected market subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let order_create = - CliArgs::parse_from(["radroots", "order", "create", "--listing", "eggs"]); - match order_create.command { - Command::Order(order) => match order.command { - OrderCommand::New(args) => assert_eq!(args.listing.as_deref(), Some("eggs")), - _ => panic!("unexpected order subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let order_view = CliArgs::parse_from(["radroots", "order", "view", "ord_demo"]); - match order_view.command { - Command::Order(order) => match order.command { - OrderCommand::Get(args) => assert_eq!(args.key, "ord_demo"), - _ => panic!("unexpected order subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let order_list = CliArgs::parse_from(["radroots", "order", "list"]); - match order_list.command { - Command::Order(order) => assert!(matches!(order.command, OrderCommand::Ls)), - _ => panic!("unexpected command variant"), - } - } - - #[test] - fn parses_account_commands() { - let new = CliArgs::parse_from(["radroots", "account", "new"]); - match new.command { - Command::Account(account) => match account.command { - AccountCommand::New => {} - _ => panic!("unexpected account subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let whoami = CliArgs::parse_from(["radroots", "account", "whoami"]); - match whoami.command { - Command::Account(account) => match account.command { - AccountCommand::Whoami => {} - _ => panic!("unexpected account subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let ls = CliArgs::parse_from(["radroots", "account", "ls"]); - match ls.command { - Command::Account(account) => match account.command { - AccountCommand::Ls => {} - _ => panic!("unexpected account subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let import = CliArgs::parse_from([ - "radroots", - "account", - "import", - "./identity.json", - "--default", - ]); - match import.command { - Command::Account(account) => match account.command { - AccountCommand::Import(args) => { - assert_eq!(args.path, PathBuf::from("./identity.json")); - assert!(args.default); - } - _ => panic!("unexpected account subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let use_account = CliArgs::parse_from(["radroots", "account", "use", "market-main"]); - match use_account.command { - Command::Account(account) => match account.command { - AccountCommand::Use(args) => assert_eq!(args.selector, "market-main"), - _ => panic!("unexpected account subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let clear_default = CliArgs::parse_from(["radroots", "account", "clear-default"]); - match clear_default.command { - Command::Account(account) => match account.command { - AccountCommand::ClearDefault => {} - _ => panic!("unexpected account subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let remove = CliArgs::parse_from(["radroots", "account", "remove", "market-main"]); - match remove.command { - Command::Account(account) => match account.command { - AccountCommand::Remove(args) => assert_eq!(args.selector, "market-main"), - _ => panic!("unexpected account subcommand"), - }, - _ => panic!("unexpected command variant"), - } - } - - #[test] - fn parses_signer_status() { - let parsed = CliArgs::parse_from(["radroots", "signer", "status"]); - match parsed.command { - Command::Signer(signer) => match signer.command { - SignerCommand::Status => {} - SignerCommand::Session(_) => panic!("unexpected signer session command"), - }, - _ => panic!("unexpected command variant"), - } - } - - #[test] - fn parses_signer_session_lifecycle_commands() { - let parsed = CliArgs::parse_from([ - "radroots", - "signer", - "session", - "require-auth", - "sess_123", - "--auth-url", - "https://auth.example", - ]); - match parsed.command { - Command::Signer(signer) => match signer.command { - SignerCommand::Session(session) => match session.command { - SignerSessionCommand::RequireAuth { - session_id, - auth_url, - } => { - assert_eq!(session_id, "sess_123"); - assert_eq!(auth_url, "https://auth.example"); - } - _ => panic!("unexpected signer session subcommand"), - }, - SignerCommand::Status => panic!("unexpected signer status command"), - }, - _ => panic!("unexpected command variant"), - } - } - - #[test] - fn parses_myc_status() { - let parsed = CliArgs::parse_from(["radroots", "myc", "status"]); - match parsed.command { - Command::Myc(myc) => match myc.command { - MycCommand::Status => {} - }, - _ => panic!("unexpected command variant"), - } - } - - #[test] - fn parses_v1_command_skeleton() { - let doctor = CliArgs::parse_from(["radroots", "doctor"]); - assert!(matches!(doctor.command, Command::Doctor)); - - let find = CliArgs::parse_from(["radroots", "find", "tomatoes"]); - match find.command { - Command::Find(args) => assert_eq!(args.query, vec!["tomatoes"]), - _ => panic!("unexpected command variant"), - } - - let farm_setup = CliArgs::parse_from([ - "radroots", - "farm", - "setup", - "--scope", - "workspace", - "--name", - "La Huerta", - "--location", - "San Francisco, CA", - "--city", - "San Francisco", - "--region", - "CA", - "--country", - "US", - "--delivery-method", - "local_delivery", - ]); - match farm_setup.command { - Command::Farm(args) => match args.command { - FarmCommand::Setup(setup) => { - assert_eq!(setup.scope, Some(FarmScopeArg::Workspace)); - assert_eq!(setup.name, "La Huerta"); - assert_eq!(setup.location, "San Francisco, CA"); - assert_eq!(setup.city.as_deref(), Some("San Francisco")); - assert_eq!(setup.delivery_method, "local_delivery"); - } - _ => panic!("unexpected farm subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let farm_init = CliArgs::parse_from([ - "radroots", - "farm", - "init", - "--scope", - "workspace", - "--name", - "La Huerta", - "--location", - "San Francisco, CA", - "--delivery", - "pickup", - ]); - match farm_init.command { - Command::Farm(args) => match args.command { - FarmCommand::Init(init) => { - assert_eq!(init.scope, Some(FarmScopeArg::Workspace)); - assert_eq!(init.name.as_deref(), Some("La Huerta")); - assert_eq!(init.location.as_deref(), Some("San Francisco, CA")); - assert_eq!(init.delivery_method.as_deref(), Some("pickup")); - } - _ => panic!("unexpected farm subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let farm_set = CliArgs::parse_from([ - "radroots", - "farm", - "set", - "--scope", - "user", - "display-name", - "La", - "Huerta", - "Farm", - ]); - match farm_set.command { - Command::Farm(args) => match args.command { - FarmCommand::Set(set) => { - assert_eq!(set.scope, Some(FarmScopeArg::User)); - assert_eq!(set.field, FarmFieldArg::DisplayName); - assert_eq!(set.value, vec!["La", "Huerta", "Farm"]); - } - _ => panic!("unexpected farm subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let farm_status = CliArgs::parse_from(["radroots", "farm", "status", "--scope", "user"]); - match farm_status.command { - Command::Farm(args) => match args.command { - FarmCommand::Status(status) => assert_eq!(status.scope, Some(FarmScopeArg::User)), - _ => panic!("unexpected farm subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let farm_get = CliArgs::parse_from(["radroots", "farm", "get"]); - match farm_get.command { - Command::Farm(args) => match args.command { - FarmCommand::Get(get) => assert!(get.scope.is_none()), - _ => panic!("unexpected farm subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let farm_publish = CliArgs::parse_from([ - "radroots", - "farm", - "publish", - "--scope", - "workspace", - "--idempotency-key", - "farm-publish-1", - "--signer-session-id", - "session-1", - "--print-job", - "--print-event", - ]); - match farm_publish.command { - Command::Farm(args) => match args.command { - FarmCommand::Publish(publish) => { - assert_eq!(publish.scope, Some(FarmScopeArg::Workspace)); - assert_eq!(publish.idempotency_key.as_deref(), Some("farm-publish-1")); - assert_eq!(publish.signer_session_id.as_deref(), Some("session-1")); - assert!(publish.print_job); - assert!(publish.print_event); - } - _ => panic!("unexpected farm subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let relay = CliArgs::parse_from(["radroots", "relay", "ls"]); - match relay.command { - Command::Relay(args) => match args.command { - RelayCommand::Ls => {} - }, - _ => panic!("unexpected command variant"), - } - - let net = CliArgs::parse_from(["radroots", "net", "status"]); - match net.command { - Command::Net(args) => match args.command { - NetCommand::Status => {} - }, - _ => panic!("unexpected command variant"), - } - - let local = CliArgs::parse_from(["radroots", "local", "init"]); - match local.command { - Command::Local(args) => match args.command { - LocalCommand::Init => {} - _ => panic!("unexpected local subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let local_export = CliArgs::parse_from([ - "radroots", - "local", - "export", - "--format", - "ndjson", - "--output", - "replica.ndjson", - ]); - match local_export.command { - Command::Local(args) => match args.command { - LocalCommand::Export(export) => { - assert!(matches!(export.format, LocalExportFormatArg::Ndjson)); - assert_eq!(export.output.to_str(), Some("replica.ndjson")); - } - _ => panic!("unexpected local subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let sync = CliArgs::parse_from(["radroots", "sync", "status"]); - match sync.command { - Command::Sync(args) => match args.command { - SyncCommand::Status => {} - _ => panic!("unexpected sync subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let sync_watch = CliArgs::parse_from([ - "radroots", - "sync", - "watch", - "--frames", - "2", - "--interval-ms", - "25", - ]); - match sync_watch.command { - Command::Sync(args) => match args.command { - SyncCommand::Watch(SyncWatchArgs { - frames, - interval_ms, - }) => { - assert_eq!(frames, 2); - assert_eq!(interval_ms, 25); - } - _ => panic!("unexpected sync subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let listing_new = CliArgs::parse_from([ - "radroots", - "listing", - "new", - "--output", - "draft.toml", - "--key", - "sf-tomatoes", - "--title", - "San Francisco Tomatoes", - "--category", - "produce.vegetables.tomatoes", - "--summary", - "Fresh tomatoes", - "--quantity-amount", - "1000", - "--quantity-unit", - "g", - "--price-amount", - "0.01", - "--available", - "25", - ]); - match listing_new.command { - Command::Listing(args) => match args.command { - ListingCommand::New(new) => { - assert_eq!( - new.output.as_deref().and_then(|path| path.to_str()), - Some("draft.toml") - ); - assert_eq!(new.key.as_deref(), Some("sf-tomatoes")); - assert_eq!(new.title.as_deref(), Some("San Francisco Tomatoes")); - assert_eq!(new.category.as_deref(), Some("produce.vegetables.tomatoes")); - assert_eq!(new.summary.as_deref(), Some("Fresh tomatoes")); - assert_eq!(new.quantity_amount.as_deref(), Some("1000")); - assert_eq!(new.quantity_unit.as_deref(), Some("g")); - assert_eq!(new.price_amount.as_deref(), Some("0.01")); - assert_eq!(new.available.as_deref(), Some("25")); - assert!(new.price_currency.is_none()); - assert!(new.price_per_amount.is_none()); - assert!(new.price_per_unit.is_none()); - } - _ => panic!("unexpected listing subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let listing_validate = - CliArgs::parse_from(["radroots", "listing", "validate", "draft.toml"]); - match listing_validate.command { - Command::Listing(args) => match args.command { - ListingCommand::Validate(file) => { - assert_eq!(file.file.to_str(), Some("draft.toml")); - } - _ => panic!("unexpected listing subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let listing_publish = CliArgs::parse_from(["radroots", "listing", "publish", "draft.toml"]); - match listing_publish.command { - Command::Listing(args) => match args.command { - ListingCommand::Publish(file) => { - assert_eq!(file.file.to_str(), Some("draft.toml")); - assert!(file.idempotency_key.is_none()); - assert!(file.signer_session_id.is_none()); - assert!(!file.print_job); - assert!(!file.print_event); - } - _ => panic!("unexpected listing subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let listing_archive = CliArgs::parse_from([ - "radroots", - "listing", - "archive", - "--idempotency-key", - "archive-key", - "--print-job", - "--print-event", - "draft.toml", - ]); - match listing_archive.command { - Command::Listing(args) => match args.command { - ListingCommand::Archive(file) => { - assert_eq!(file.file.to_str(), Some("draft.toml")); - assert_eq!(file.idempotency_key.as_deref(), Some("archive-key")); - assert!(file.signer_session_id.is_none()); - assert!(file.print_job); - assert!(file.print_event); - } - _ => panic!("unexpected listing subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let listing_update = CliArgs::parse_from([ - "radroots", - "listing", - "update", - "--signer-session-id", - "sess_123", - "draft.toml", - ]); - match listing_update.command { - Command::Listing(args) => match args.command { - ListingCommand::Update(file) => { - assert_eq!(file.file.to_str(), Some("draft.toml")); - assert_eq!(file.signer_session_id.as_deref(), Some("sess_123")); - } - _ => panic!("unexpected listing subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let listing_get = CliArgs::parse_from(["radroots", "listing", "get", "lst_123"]); - match listing_get.command { - Command::Listing(args) => match args.command { - ListingCommand::Get(key) => assert_eq!(key.key, "lst_123"), - _ => panic!("unexpected listing subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let job = CliArgs::parse_from(["radroots", "job", "get", "job_123"]); - match job.command { - Command::Job(args) => match args.command { - JobCommand::Get(key) => assert_eq!(key.key, "job_123"), - _ => panic!("unexpected job subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let job_watch = CliArgs::parse_from([ - "radroots", - "job", - "watch", - "job_123", - "--frames", - "2", - "--interval-ms", - "5", - ]); - match job_watch.command { - Command::Job(args) => match args.command { - JobCommand::Watch(JobWatchArgs { - key, - frames, - interval_ms, - }) => { - assert_eq!(key, "job_123"); - assert_eq!(frames, Some(2)); - assert_eq!(interval_ms, 5); - } - _ => panic!("unexpected job watch subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let rpc = CliArgs::parse_from(["radroots", "rpc", "status"]); - match rpc.command { - Command::Rpc(args) => match args.command { - RpcCommand::Status => {} - _ => panic!("unexpected rpc subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let runtime_status = CliArgs::parse_from(["radroots", "runtime", "status", "radrootsd"]); - match runtime_status.command { - Command::Runtime(args) => match args.command { - RuntimeCommand::Status(target) => { - assert_eq!(target.runtime, "radrootsd"); - assert!(target.instance.is_none()); - } - _ => panic!("unexpected runtime subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let runtime_logs = CliArgs::parse_from([ - "radroots", - "runtime", - "logs", - "radrootsd", - "--instance", - "local", - ]); - match runtime_logs.command { - Command::Runtime(args) => match args.command { - RuntimeCommand::Logs(target) => { - assert_eq!(target.runtime, "radrootsd"); - assert_eq!(target.instance.as_deref(), Some("local")); - } - _ => panic!("unexpected runtime subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let runtime_config_show = - CliArgs::parse_from(["radroots", "runtime", "config", "show", "radrootsd"]); - match runtime_config_show.command { - Command::Runtime(args) => match args.command { - RuntimeCommand::Config(runtime_config) => match runtime_config.command { - RuntimeConfigCommand::Show(target) => { - assert_eq!(target.runtime, "radrootsd"); - assert!(target.instance.is_none()); - } - _ => panic!("unexpected runtime config subcommand"), - }, - _ => panic!("unexpected runtime subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let runtime_config_set = CliArgs::parse_from([ - "radroots", - "runtime", - "config", - "set", - "radrootsd", - "--instance", - "local", - "bridge.enabled", - "true", - ]); - match runtime_config_set.command { - Command::Runtime(args) => match args.command { - RuntimeCommand::Config(runtime_config) => match runtime_config.command { - RuntimeConfigCommand::Set(set) => { - assert_eq!(set.target.runtime, "radrootsd"); - assert_eq!(set.target.instance.as_deref(), Some("local")); - assert_eq!(set.key, "bridge.enabled"); - assert_eq!(set.value, "true"); - } - _ => panic!("unexpected runtime config subcommand"), - }, - _ => panic!("unexpected runtime subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let order_new = CliArgs::parse_from([ - "radroots", - "order", - "new", - "--listing", - "pasture-eggs", - "--listing-addr", - "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg", - "--bin", - "bin-1", - "--qty", - "2", - ]); - match order_new.command { - Command::Order(args) => match args.command { - OrderCommand::New(new) => { - assert_eq!(new.listing.as_deref(), Some("pasture-eggs")); - assert_eq!( - new.listing_addr.as_deref(), - Some("30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg") - ); - assert_eq!(new.bin_id.as_deref(), Some("bin-1")); - assert_eq!(new.bin_count, Some(2)); - } - _ => panic!("unexpected order subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let order_create = CliArgs::parse_from(["radroots", "order", "create"]); - match order_create.command { - Command::Order(args) => match args.command { - OrderCommand::New(_) => {} - _ => panic!("unexpected order subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let order_get = CliArgs::parse_from(["radroots", "order", "get", "ord_demo"]); - match order_get.command { - Command::Order(args) => match args.command { - OrderCommand::Get(key) => assert_eq!(key.key, "ord_demo"), - _ => panic!("unexpected order subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let order_view = CliArgs::parse_from(["radroots", "order", "view", "ord_demo"]); - match order_view.command { - Command::Order(args) => match args.command { - OrderCommand::Get(key) => assert_eq!(key.key, "ord_demo"), - _ => panic!("unexpected order subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let order_ls = CliArgs::parse_from(["radroots", "order", "ls"]); - match order_ls.command { - Command::Order(args) => match args.command { - OrderCommand::Ls => {} - _ => panic!("unexpected order subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let order_list = CliArgs::parse_from(["radroots", "order", "list"]); - match order_list.command { - Command::Order(args) => match args.command { - OrderCommand::Ls => {} - _ => panic!("unexpected order subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let order_submit = CliArgs::parse_from([ - "radroots", - "order", - "submit", - "ord_demo", - "--watch", - "--idempotency-key", - "submit-1", - "--signer-session-id", - "sess_456", - ]); - match order_submit.command { - Command::Order(args) => match args.command { - OrderCommand::Submit(submit) => { - assert_eq!(submit.key, "ord_demo"); - assert!(submit.watch); - assert_eq!(submit.idempotency_key.as_deref(), Some("submit-1")); - assert_eq!(submit.signer_session_id.as_deref(), Some("sess_456")); - } - _ => panic!("unexpected order subcommand"), - }, - _ => panic!("unexpected command variant"), - } - - let order_watch = CliArgs::parse_from([ - "radroots", - "order", - "watch", - "ord_demo", - "--frames", - "3", - "--interval-ms", - "25", - ]); - match order_watch.command { - Command::Order(args) => match args.command { - OrderCommand::Watch(OrderWatchArgs { - key, - frames, - interval_ms, - }) => { - assert_eq!(key, "ord_demo"); - assert_eq!(frames, Some(3)); - assert_eq!(interval_ms, 25); - } - _ => panic!("unexpected order subcommand"), - }, - _ => panic!("unexpected command variant"), - } - } - - #[test] - fn command_contract_helpers_report_supported_modes() { - let config_show = CliArgs::parse_from(["radroots", "config", "show"]); - assert!( - config_show - .command - .supports_output_format(OutputFormat::Human) - ); - assert!( - config_show - .command - .supports_output_format(OutputFormat::Json) - ); - assert!( - !config_show - .command - .supports_output_format(OutputFormat::Ndjson) - ); - assert!(config_show.command.supports_dry_run()); - - let account_create = CliArgs::parse_from(["radroots", "account", "create"]); - assert_eq!(account_create.command.display_name(), "account create"); - assert!(!account_create.command.supports_dry_run()); - - let account_import = - CliArgs::parse_from(["radroots", "account", "import", "./identity.json"]); - assert_eq!(account_import.command.display_name(), "account import"); - assert!(!account_import.command.supports_dry_run()); - - let account_clear_default = CliArgs::parse_from(["radroots", "account", "clear-default"]); - assert_eq!( - account_clear_default.command.display_name(), - "account clear-default" - ); - assert!(!account_clear_default.command.supports_dry_run()); - - let account_remove = CliArgs::parse_from(["radroots", "account", "remove", "market-main"]); - assert_eq!(account_remove.command.display_name(), "account remove"); - assert!(!account_remove.command.supports_dry_run()); - - let farm_init = CliArgs::parse_from(["radroots", "farm", "init"]); - assert_eq!(farm_init.command.display_name(), "farm init"); - assert!(!farm_init.command.supports_dry_run()); - - let farm_set = CliArgs::parse_from(["radroots", "farm", "set", "name", "La Huerta"]); - assert_eq!(farm_set.command.display_name(), "farm set"); - assert!(!farm_set.command.supports_dry_run()); - - let farm_setup = CliArgs::parse_from([ - "radroots", - "farm", - "setup", - "--name", - "La Huerta", - "--location", - "San Francisco, CA", - ]); - assert_eq!(farm_setup.command.display_name(), "farm setup"); - assert!(!farm_setup.command.supports_dry_run()); - - let farm_check = CliArgs::parse_from(["radroots", "farm", "check"]); - assert_eq!(farm_check.command.display_name(), "farm check"); - assert!(farm_check.command.supports_dry_run()); - assert!( - !farm_check - .command - .supports_output_format(OutputFormat::Ndjson) - ); - - let farm_publish = CliArgs::parse_from(["radroots", "farm", "publish"]); - assert_eq!(farm_publish.command.display_name(), "farm publish"); - assert!(farm_publish.command.supports_dry_run()); - - let find = CliArgs::parse_from(["radroots", "find", "eggs"]); - assert!(find.command.supports_output_format(OutputFormat::Ndjson)); - - let market_search = CliArgs::parse_from(["radroots", "market", "search", "eggs"]); - assert_eq!(market_search.command.display_name(), "market search"); - assert!( - market_search - .command - .supports_output_format(OutputFormat::Ndjson) - ); - - let sync_watch = CliArgs::parse_from(["radroots", "sync", "watch", "--frames", "1"]); - assert!( - sync_watch - .command - .supports_output_format(OutputFormat::Ndjson) - ); - - let sell_add = CliArgs::parse_from(["radroots", "sell", "add", "tomatoes"]); - assert_eq!(sell_add.command.display_name(), "sell add"); - assert!(!sell_add.command.supports_dry_run()); - - let order_create = CliArgs::parse_from(["radroots", "order", "create"]); - assert_eq!(order_create.command.display_name(), "order create"); - assert!(!order_create.command.supports_dry_run()); - - let order_view = CliArgs::parse_from(["radroots", "order", "view", "ord_demo"]); - assert_eq!(order_view.command.display_name(), "order view"); - assert!(order_view.command.supports_dry_run()); - - let order_list = CliArgs::parse_from(["radroots", "order", "list"]); - assert_eq!(order_list.command.display_name(), "order list"); - assert!(order_list.command.supports_dry_run()); - let order_watch = CliArgs::parse_from(["radroots", "order", "watch", "ord_demo"]); - assert!( - order_watch - .command - .supports_output_format(OutputFormat::Ndjson) - ); - - let order_submit = CliArgs::parse_from(["radroots", "order", "submit", "ord_demo"]); - assert_eq!(order_submit.command.display_name(), "order submit"); - assert!(order_submit.command.supports_dry_run()); - - let setup = CliArgs::parse_from(["radroots", "setup", "buyer"]); - assert_eq!(setup.command.display_name(), "setup buyer"); - assert!(!setup.command.supports_dry_run()); - - let status = CliArgs::parse_from(["radroots", "status"]); - assert_eq!(status.command.display_name(), "status"); - assert!(status.command.supports_dry_run()); - - let runtime_status = CliArgs::parse_from(["radroots", "runtime", "status", "radrootsd"]); - assert_eq!(runtime_status.command.display_name(), "runtime status"); - assert!(runtime_status.command.supports_dry_run()); - assert!( - !runtime_status - .command - .supports_output_format(OutputFormat::Ndjson) - ); - } -} diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use std::process::ExitCode; use radroots_events::farm::RadrootsFarm; diff --git a/src/main.rs b/src/main.rs @@ -1,7 +1,6 @@ #![forbid(unsafe_code)] mod cli; -mod commands; mod domain; mod operation_adapter; mod operation_basket; @@ -13,18 +12,30 @@ mod operation_order; mod operation_registry; mod operation_runtime; mod output_contract; -mod render; mod runtime; mod target_cli; use std::io::Write; use std::process::ExitCode; -use crate::cli::CliArgs; -use crate::commands::dispatch; -use crate::render::render_output; -use crate::runtime::config::{OutputFormat, RuntimeConfig}; +use clap::Parser; + +use crate::cli::{CliArgs, Command, ConfigArgs, ConfigCommand, OutputFormatArg}; +use crate::operation_adapter::{ + MvpOperationRequest, OperationAdapter, OperationAdapterError, OperationOutputFormat, + OperationRequest, OperationRequestPayload, OperationResultPayload, OperationService, +}; +use crate::operation_basket::BasketOperationService; +use crate::operation_core::CoreOperationService; +use crate::operation_farm::FarmOperationService; +use crate::operation_listing::ListingOperationService; +use crate::operation_market::MarketOperationService; +use crate::operation_order::OrderOperationService; +use crate::operation_runtime::RuntimeOperationService; +use crate::output_contract::OutputEnvelope; +use crate::runtime::config::RuntimeConfig; use crate::runtime::logging::initialize_logging; +use crate::target_cli::{TargetCliArgs, TargetOutputFormat}; fn main() -> ExitCode { match run() { @@ -39,43 +50,327 @@ fn main() -> ExitCode { fn run() -> Result<ExitCode, runtime::RuntimeError> { debug_assert!(operation_registry::registry_linkage_is_valid()); debug_assert!(operation_adapter::adapter_registry_linkage_is_valid()); - let args = CliArgs::parse(); - let config = RuntimeConfig::from_system(&args)?; - validate_command_contracts(&args.command, &config)?; + let args = TargetCliArgs::parse(); + let config = RuntimeConfig::from_system(&config_args_from_target(&args)?)?; let logging = initialize_logging(&config.logging)?; - let output = dispatch(&args.command, &config, &logging)?; - render_output(&output, &config.output)?; - Ok(output.exit_code()) + let request = MvpOperationRequest::from_target_args(&args).map_err(operation_config_error)?; + let envelope = match validate_request_contract(&request) { + Ok(()) => execute_request(request, &config, &logging), + Err(error) => failure_envelope(&request, error), + }; + render_envelope(&envelope, args.format)?; + Ok(envelope_exit_code(&envelope)) +} + +fn config_args_from_target(args: &TargetCliArgs) -> Result<CliArgs, runtime::RuntimeError> { + Ok(CliArgs { + output_format: Some(match args.format { + TargetOutputFormat::Human => OutputFormatArg::Human, + TargetOutputFormat::Json => OutputFormatArg::Json, + TargetOutputFormat::Ndjson => OutputFormatArg::Ndjson, + }), + json: false, + ndjson: false, + env_file: None, + quiet: args.quiet, + verbose: args.verbose, + trace: args.trace, + dry_run: args.dry_run, + no_color: args.no_color, + no_input: args.no_input, + yes: false, + log_filter: None, + log_dir: None, + log_stdout: false, + no_log_stdout: false, + account: args.account_id.clone(), + identity_path: None, + signer: None, + relay: args.relay.clone(), + myc_executable: None, + myc_status_timeout_ms: None, + hyf_enabled: false, + no_hyf_enabled: false, + hyf_executable: None, + command: Command::Config(ConfigArgs { + command: ConfigCommand::Show, + }), + }) } -fn validate_command_contracts( - command: &crate::cli::Command, +fn execute_request( + request: MvpOperationRequest, config: &RuntimeConfig, -) -> Result<(), runtime::RuntimeError> { - if let crate::cli::Command::Order(order) = command { - if let crate::cli::OrderCommand::Submit(args) = &order.command { - if args.watch && config.output.format != OutputFormat::Human { - return Err(runtime::RuntimeError::Config( - "`order submit --watch` only supports human output; use `order submit` and `order watch` for machine output".to_owned(), - )); - } + logging: &runtime::logging::LoggingState, +) -> OutputEnvelope { + match request { + MvpOperationRequest::WorkspaceInit(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } + MvpOperationRequest::WorkspaceGet(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } + MvpOperationRequest::HealthStatusGet(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } + MvpOperationRequest::HealthCheckRun(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } + MvpOperationRequest::ConfigGet(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } + MvpOperationRequest::AccountCreate(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } + MvpOperationRequest::AccountImport(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } + MvpOperationRequest::AccountGet(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } + MvpOperationRequest::AccountList(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } + MvpOperationRequest::AccountRemove(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } + MvpOperationRequest::AccountSelectionGet(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } + MvpOperationRequest::AccountSelectionUpdate(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } + MvpOperationRequest::AccountSelectionClear(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } + MvpOperationRequest::StoreInit(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } + MvpOperationRequest::StoreStatusGet(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } + MvpOperationRequest::StoreExport(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } + MvpOperationRequest::StoreBackupCreate(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } + MvpOperationRequest::SignerStatusGet(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + MvpOperationRequest::RelayList(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + MvpOperationRequest::SyncStatusGet(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + MvpOperationRequest::SyncPull(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + MvpOperationRequest::SyncPush(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + MvpOperationRequest::SyncWatch(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + MvpOperationRequest::RuntimeStatusGet(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + MvpOperationRequest::RuntimeStart(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + MvpOperationRequest::RuntimeStop(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + MvpOperationRequest::RuntimeRestart(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + MvpOperationRequest::RuntimeLogWatch(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + MvpOperationRequest::RuntimeConfigGet(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + MvpOperationRequest::JobGet(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + MvpOperationRequest::JobList(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + MvpOperationRequest::JobWatch(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + MvpOperationRequest::FarmCreate(request) => { + execute_with(FarmOperationService::new(config), request) + } + MvpOperationRequest::FarmGet(request) => { + execute_with(FarmOperationService::new(config), request) + } + MvpOperationRequest::FarmProfileUpdate(request) => { + execute_with(FarmOperationService::new(config), request) + } + MvpOperationRequest::FarmLocationUpdate(request) => { + execute_with(FarmOperationService::new(config), request) + } + MvpOperationRequest::FarmFulfillmentUpdate(request) => { + execute_with(FarmOperationService::new(config), request) + } + MvpOperationRequest::FarmReadinessCheck(request) => { + execute_with(FarmOperationService::new(config), request) + } + MvpOperationRequest::FarmPublish(request) => { + execute_with(FarmOperationService::new(config), request) + } + MvpOperationRequest::ListingCreate(request) => { + execute_with(ListingOperationService::new(config), request) + } + MvpOperationRequest::ListingGet(request) => { + execute_with(ListingOperationService::new(config), request) + } + MvpOperationRequest::ListingList(request) => { + execute_with(ListingOperationService::new(config), request) + } + MvpOperationRequest::ListingUpdate(request) => { + execute_with(ListingOperationService::new(config), request) + } + MvpOperationRequest::ListingValidate(request) => { + execute_with(ListingOperationService::new(config), request) + } + MvpOperationRequest::ListingPublish(request) => { + execute_with(ListingOperationService::new(config), request) + } + MvpOperationRequest::ListingArchive(request) => { + execute_with(ListingOperationService::new(config), request) + } + MvpOperationRequest::MarketRefresh(request) => { + execute_with(MarketOperationService::new(config), request) + } + MvpOperationRequest::MarketProductSearch(request) => { + execute_with(MarketOperationService::new(config), request) + } + MvpOperationRequest::MarketListingGet(request) => { + execute_with(MarketOperationService::new(config), request) + } + MvpOperationRequest::BasketCreate(request) => { + execute_with(BasketOperationService::new(config), request) + } + MvpOperationRequest::BasketGet(request) => { + execute_with(BasketOperationService::new(config), request) + } + MvpOperationRequest::BasketList(request) => { + execute_with(BasketOperationService::new(config), request) + } + MvpOperationRequest::BasketItemAdd(request) => { + execute_with(BasketOperationService::new(config), request) + } + MvpOperationRequest::BasketItemUpdate(request) => { + execute_with(BasketOperationService::new(config), request) + } + MvpOperationRequest::BasketItemRemove(request) => { + execute_with(BasketOperationService::new(config), request) + } + MvpOperationRequest::BasketValidate(request) => { + execute_with(BasketOperationService::new(config), request) + } + MvpOperationRequest::BasketQuoteCreate(request) => { + execute_with(BasketOperationService::new(config), request) + } + MvpOperationRequest::OrderSubmit(request) => { + execute_with(OrderOperationService::new(config), request) + } + MvpOperationRequest::OrderGet(request) => { + execute_with(OrderOperationService::new(config), request) + } + MvpOperationRequest::OrderList(request) => { + execute_with(OrderOperationService::new(config), request) + } + MvpOperationRequest::OrderEventList(request) => { + execute_with(OrderOperationService::new(config), request) + } + MvpOperationRequest::OrderEventWatch(request) => { + execute_with(OrderOperationService::new(config), request) } } +} - if !command.supports_output_format(config.output.format) { - return Err(runtime::RuntimeError::Config(format!( - "`{}` does not support --{}", - command.display_name(), - config.output.format.as_str() - ))); +fn execute_with<S, P>(service: S, request: OperationRequest<P>) -> OutputEnvelope +where + S: OperationService<P>, + P: OperationRequestPayload, + S::Result: OperationResultPayload, +{ + let operation_id = request.operation_id().to_owned(); + let envelope_context = request + .context + .envelope_context(format!("req_{}", operation_id.replace('.', "_"))); + match OperationAdapter::new(service) + .execute(request) + .and_then(|result| result.to_envelope(envelope_context.clone())) + { + Ok(envelope) => envelope, + Err(error) => { + OutputEnvelope::failure(operation_id, error.to_output_error(), envelope_context) + } } +} - if config.output.dry_run && !command.supports_dry_run() { - return Err(runtime::RuntimeError::Config(format!( - "`{}` does not support --dry-run yet", - command.display_name() - ))); +fn validate_request_contract(request: &MvpOperationRequest) -> Result<(), OperationAdapterError> { + let spec = request.spec(); + if matches!( + request.context().output_format, + OperationOutputFormat::Ndjson + ) && !spec.supports_ndjson + { + return Err(OperationAdapterError::InvalidInput { + operation_id: spec.operation_id.to_owned(), + message: format!("`{}` does not support --format ndjson", spec.cli_path), + }); + } + if request.context().dry_run && !spec.supports_dry_run { + return Err(OperationAdapterError::InvalidInput { + operation_id: spec.operation_id.to_owned(), + message: format!("`{}` does not support --dry-run", spec.cli_path), + }); } + Ok(()) +} +fn failure_envelope(request: &MvpOperationRequest, error: OperationAdapterError) -> OutputEnvelope { + OutputEnvelope::failure( + request.operation_id(), + error.to_output_error(), + request + .context() + .envelope_context(format!("req_{}", request.operation_id().replace('.', "_"))), + ) +} + +fn render_envelope( + envelope: &OutputEnvelope, + format: TargetOutputFormat, +) -> Result<(), runtime::RuntimeError> { + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + match format { + TargetOutputFormat::Human | TargetOutputFormat::Json => { + serde_json::to_writer_pretty(&mut handle, envelope)?; + } + TargetOutputFormat::Ndjson => { + serde_json::to_writer(&mut handle, envelope)?; + } + } + writeln!(handle)?; Ok(()) } + +fn envelope_exit_code(envelope: &OutputEnvelope) -> ExitCode { + envelope + .errors + .first() + .map(|error| ExitCode::from(error.exit_code)) + .unwrap_or_else(|| ExitCode::from(0)) +} + +fn operation_config_error(error: OperationAdapterError) -> runtime::RuntimeError { + runtime::RuntimeError::Config(error.to_string()) +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + pub mod accounts; pub mod config; pub mod daemon; diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -12,7 +12,7 @@ pub enum TargetOutputFormat { } #[derive(Debug, Parser, Clone)] -#[command(name = "radroots")] +#[command(name = "radroots", disable_help_subcommand = true)] pub struct TargetCliArgs { #[arg(long = "format", global = true, value_enum, default_value = "human")] pub format: TargetOutputFormat, diff --git a/tests/doctor.rs b/tests/doctor.rs @@ -1,154 +0,0 @@ -use std::fs; -use std::path::Path; -use std::process::Command; - -use assert_cmd::prelude::*; -use serde_json::Value; -use tempfile::tempdir; - -fn doctor_command_in(workdir: &Path) -> Command { - let mut command = Command::cargo_bin("radroots").expect("binary"); - command.current_dir(workdir); - command.env("HOME", workdir.join("home")); - for key in [ - "RADROOTS_ENV_FILE", - "RADROOTS_OUTPUT", - "RADROOTS_CLI_LOGGING_FILTER", - "RADROOTS_CLI_LOGGING_OUTPUT_DIR", - "RADROOTS_CLI_LOGGING_STDOUT", - "RADROOTS_CLI_PATHS_PROFILE", - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", - "RADROOTS_LOG_FILTER", - "RADROOTS_LOG_DIR", - "RADROOTS_LOG_STDOUT", - "RADROOTS_ACCOUNT", - "RADROOTS_ACCOUNT_SECRET_BACKEND", - "RADROOTS_ACCOUNT_SECRET_FALLBACK", - "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", - "RADROOTS_IDENTITY_PATH", - "RADROOTS_SIGNER", - "RADROOTS_RELAYS", - "RADROOTS_MYC_EXECUTABLE", - "RADROOTS_MYC_STATUS_TIMEOUT_MS", - "RADROOTS_RPC_URL", - "RADROOTS_RPC_BEARER_TOKEN", - ] { - command.env_remove(key); - } - command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); - command -} - -#[test] -fn doctor_reports_unconfigured_local_bootstrap_state() { - let dir = tempdir().expect("tempdir"); - let output = doctor_command_in(dir.path()) - .args(["--json", "doctor"]) - .output() - .expect("run doctor"); - - assert_eq!(output.status.code(), Some(3)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["ok"], false); - assert_eq!(json["state"], "warn"); - assert_eq!(json["checks"][0]["name"], "config"); - assert_eq!(json["checks"][0]["status"], "ok"); - assert_eq!(json["checks"][1]["name"], "account"); - assert_eq!(json["checks"][1]["status"], "warn"); - assert_eq!(json["account_resolution"]["source"], "none"); - assert_eq!(json["checks"][2]["name"], "relays"); - assert_eq!(json["checks"][2]["status"], "warn"); - assert_eq!(json["checks"][3]["name"], "signer"); - assert_eq!(json["checks"][3]["status"], "warn"); - assert_eq!(json["checks"][5]["name"], "workflow"); - assert_eq!(json["checks"][5]["status"], "warn"); - assert_eq!(json["source"], "local diagnostics"); - assert_eq!(json["actions"][0], "radroots account new"); - assert_eq!(json["actions"][1], "radroots relay ls"); -} - -#[test] -fn doctor_ignores_cwd_workspace_config_in_interactive_user() { - let dir = tempdir().expect("tempdir"); - let config_dir = dir.path().join("infra/local/runtime/radroots"); - fs::create_dir_all(&config_dir).expect("workspace config dir"); - fs::write( - config_dir.join("config.toml"), - "[relay]\nurls = [\"wss://relay.cwd\"]\n", - ) - .expect("write cwd workspace config"); - - let output = doctor_command_in(dir.path()) - .args(["--json", "doctor"]) - .output() - .expect("run doctor"); - - assert_eq!(output.status.code(), Some(3)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["checks"][0]["name"], "config"); - assert_eq!(json["checks"][0]["detail"], "defaults active"); - assert_eq!(json["checks"][2]["name"], "relays"); - assert_eq!(json["checks"][2]["status"], "warn"); -} - -#[test] -fn doctor_reports_warn_for_ready_local_bootstrap_without_workflow_provider() { - let dir = tempdir().expect("tempdir"); - let init = doctor_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(init.status.success()); - - let output = doctor_command_in(dir.path()) - .args(["--json", "--relay", "wss://relay.one", "doctor"]) - .output() - .expect("run doctor"); - - assert_eq!(output.status.code(), Some(3)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["ok"], false); - assert_eq!(json["state"], "warn"); - assert_eq!(json["checks"][1]["name"], "account"); - assert_eq!(json["checks"][1]["status"], "ok"); - assert_eq!(json["account_resolution"]["source"], "default_account"); - assert_eq!(json["checks"][2]["name"], "relays"); - assert_eq!(json["checks"][2]["status"], "ok"); - assert_eq!(json["checks"][3]["name"], "signer"); - assert_eq!(json["checks"][3]["status"], "ok"); - assert_eq!(json["checks"][5]["name"], "workflow"); - assert_eq!(json["checks"][5]["status"], "warn"); - assert!(json["actions"].is_null()); -} - -#[test] -fn doctor_reports_external_failure_for_missing_myc() { - let dir = tempdir().expect("tempdir"); - fs::write( - dir.path().join(".env"), - "RADROOTS_SIGNER=myc\nRADROOTS_RELAYS=wss://relay.one\n", - ) - .expect("write env file"); - - let output = doctor_command_in(dir.path()) - .args(["--json", "--myc-executable", "missing-myc", "doctor"]) - .output() - .expect("run doctor"); - - assert_eq!(output.status.code(), Some(4)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["state"], "fail"); - assert_eq!(json["checks"][2]["name"], "relays"); - assert_eq!(json["checks"][2]["status"], "ok"); - assert_eq!(json["checks"][3]["name"], "signer"); - assert_eq!(json["checks"][3]["status"], "fail"); - assert_eq!(json["checks"][4]["name"], "myc"); - assert_eq!(json["checks"][4]["status"], "fail"); - assert_eq!(json["checks"][6]["name"], "workflow"); - assert_eq!(json["checks"][6]["status"], "warn"); - assert_eq!(json["source"], "local diagnostics + myc status command"); -} diff --git a/tests/farm.rs b/tests/farm.rs @@ -1,274 +0,0 @@ -use std::path::Path; -use std::process::Command; - -use assert_cmd::prelude::*; -use serde_json::Value; -use tempfile::tempdir; - -fn cli_command_in(workdir: &Path) -> Command { - let mut command = Command::cargo_bin("radroots").expect("binary"); - command.current_dir(workdir); - command.env("HOME", workdir.join("home")); - command.env("APPDATA", workdir.join("roaming")); - command.env("LOCALAPPDATA", workdir.join("local")); - for key in [ - "RADROOTS_ENV_FILE", - "RADROOTS_OUTPUT", - "RADROOTS_CLI_LOGGING_FILTER", - "RADROOTS_CLI_LOGGING_OUTPUT_DIR", - "RADROOTS_CLI_LOGGING_STDOUT", - "RADROOTS_CLI_PATHS_PROFILE", - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", - "RADROOTS_LOG_FILTER", - "RADROOTS_LOG_DIR", - "RADROOTS_LOG_STDOUT", - "RADROOTS_ACCOUNT", - "RADROOTS_ACCOUNT_SECRET_BACKEND", - "RADROOTS_ACCOUNT_SECRET_FALLBACK", - "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", - "RADROOTS_IDENTITY_PATH", - "RADROOTS_SIGNER", - "RADROOTS_RELAYS", - "RADROOTS_MYC_EXECUTABLE", - "RADROOTS_MYC_STATUS_TIMEOUT_MS", - "RADROOTS_RPC_URL", - "RADROOTS_RPC_BEARER_TOKEN", - ] { - command.env_remove(key); - } - command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); - command -} - -fn json_output(output: &std::process::Output) -> Value { - serde_json::from_slice(output.stdout.as_slice()).expect("json output") -} - -fn json_string_array(json: &Value, field: &str) -> Vec<String> { - json[field] - .as_array() - .expect("array field") - .iter() - .map(|value| value.as_str().expect("string item").to_owned()) - .collect() -} - -#[test] -fn farm_init_requires_a_selected_account() { - let dir = tempdir().expect("tempdir"); - - let output = cli_command_in(dir.path()) - .args(["--json", "farm", "init"]) - .output() - .expect("run farm init"); - - assert_eq!(output.status.code(), Some(3)); - let json = json_output(&output); - assert_eq!(json["state"], "unconfigured"); - assert_eq!( - json["actions"], - serde_json::json!(["radroots account create"]) - ); -} - -#[test] -fn farm_init_creates_a_minimal_draft_and_reports_missing_fields() { - let dir = tempdir().expect("tempdir"); - - let account = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account.status.success()); - let account_json = json_output(&account); - let account_id = account_json["account"]["id"] - .as_str() - .expect("account id") - .to_owned(); - - let init = cli_command_in(dir.path()) - .args(["--json", "farm", "init"]) - .output() - .expect("run farm init"); - assert!(init.status.success()); - let init_json = json_output(&init); - assert_eq!(init_json["state"], "saved"); - assert_eq!(init_json["config"]["selected_account_id"], account_id); - assert_eq!( - init_json["actions"], - serde_json::json!(["radroots farm check"]) - ); - - let check = cli_command_in(dir.path()) - .args(["farm", "check"]) - .output() - .expect("run farm check"); - assert_eq!(check.status.code(), Some(3)); - let stdout = String::from_utf8(check.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Farm not ready yet")); - assert!(stdout.contains("Missing")); - assert!(stdout.contains("Location")); - assert!(stdout.contains("Delivery method")); - assert!(stdout.contains("radroots farm set location \"San Francisco, CA\"")); - assert!(stdout.contains("radroots farm set delivery pickup")); - - let publish = cli_command_in(dir.path()) - .args(["--json", "farm", "publish"]) - .output() - .expect("run farm publish"); - assert_eq!(publish.status.code(), Some(3)); - let publish_json = json_output(&publish); - assert_eq!(publish_json["state"], "unconfigured"); - let missing = json_string_array(&publish_json, "missing"); - assert!(missing.contains(&"Location".to_owned())); - assert!(missing.contains(&"Delivery method".to_owned())); -} - -#[test] -fn farm_set_updates_the_draft_and_farm_check_turns_ready() { - let dir = tempdir().expect("tempdir"); - - let account = cli_command_in(dir.path()) - .args(["account", "new"]) - .output() - .expect("run account new"); - assert!(account.status.success()); - - let init = cli_command_in(dir.path()) - .args([ - "farm", - "init", - "--name", - "La Huerta", - "--location", - "San Francisco, CA", - "--country", - "US", - ]) - .output() - .expect("run farm init"); - assert!(init.status.success()); - - let set = cli_command_in(dir.path()) - .args(["--json", "farm", "set", "delivery", "pickup"]) - .output() - .expect("run farm set"); - assert!(set.status.success()); - let set_json = json_output(&set); - assert_eq!(set_json["state"], "updated"); - assert_eq!(set_json["field"], "Delivery"); - assert_eq!(set_json["value"], "Pickup"); - - let check = cli_command_in(dir.path()) - .args(["--json", "farm", "check"]) - .output() - .expect("run farm check"); - assert!(check.status.success()); - let check_json = json_output(&check); - assert_eq!(check_json["state"], "ready"); - assert_eq!( - check_json["actions"], - serde_json::json!(["radroots farm publish"]) - ); - assert!(check_json.get("missing").is_none()); -} - -#[test] -fn farm_show_reports_a_missing_draft() { - let dir = tempdir().expect("tempdir"); - - let output = cli_command_in(dir.path()) - .args(["farm", "show"]) - .output() - .expect("run farm show"); - - assert_eq!(output.status.code(), Some(3)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Farm draft not found")); - assert!(stdout.contains("radroots farm init")); -} - -#[test] -fn farm_setup_compatibility_path_still_produces_a_publishable_draft() { - let dir = tempdir().expect("tempdir"); - - let account = cli_command_in(dir.path()) - .args(["account", "new"]) - .output() - .expect("run account new"); - assert!(account.status.success()); - - let setup = cli_command_in(dir.path()) - .args([ - "--json", - "farm", - "setup", - "--name", - "La Huerta", - "--location", - "San Francisco, CA", - "--country", - "US", - "--delivery-method", - "pickup", - ]) - .output() - .expect("run farm setup"); - assert!(setup.status.success()); - let setup_json = json_output(&setup); - assert_eq!(setup_json["state"], "configured"); - assert_eq!( - setup_json["actions"], - serde_json::json!(["radroots farm check", "radroots farm publish"]) - ); - - let check = cli_command_in(dir.path()) - .args(["--json", "farm", "check"]) - .output() - .expect("run farm check"); - assert!(check.status.success()); - let check_json = json_output(&check); - assert_eq!(check_json["state"], "ready"); -} - -#[test] -fn listing_new_points_back_to_farm_check_when_defaults_are_incomplete() { - let dir = tempdir().expect("tempdir"); - - let account = cli_command_in(dir.path()) - .args(["account", "new"]) - .output() - .expect("run account new"); - assert!(account.status.success()); - - let init = cli_command_in(dir.path()) - .args(["farm", "init", "--name", "La Huerta"]) - .output() - .expect("run farm init"); - assert!(init.status.success()); - - let listing = cli_command_in(dir.path()) - .args([ - "--json", - "listing", - "new", - "--key", - "eggs", - "--title", - "Pasture eggs", - "--category", - "protein", - "--summary", - "Fresh pasture-raised eggs.", - ]) - .output() - .expect("run listing new"); - assert!(listing.status.success()); - let listing_json = json_output(&listing); - assert_eq!( - listing_json["reason"], - "selected farm draft is missing delivery or location defaults; those fields were left blank" - ); - let actions = json_string_array(&listing_json, "actions"); - assert!(actions.iter().any(|action| action == "radroots farm check")); -} diff --git a/tests/find.rs b/tests/find.rs @@ -1,541 +0,0 @@ -use std::path::Path; -use std::process::Command; - -use assert_cmd::prelude::*; -use radroots_sql_core::{SqlExecutor, SqliteExecutor}; -use serde_json::{Value, json}; -use tempfile::tempdir; - -const ADDRESS_BACKED_LISTING_ADDR: &str = - "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; - -fn data_root(workdir: &Path) -> std::path::PathBuf { - if cfg!(windows) { - workdir.join("local").join("Radroots").join("data") - } else { - workdir.join("home").join(".radroots").join("data") - } -} - -fn cli_command_in(workdir: &Path) -> Command { - let mut command = Command::cargo_bin("radroots").expect("binary"); - command.current_dir(workdir); - command.env("HOME", workdir.join("home")); - command.env("APPDATA", workdir.join("roaming")); - command.env("LOCALAPPDATA", workdir.join("local")); - for key in [ - "RADROOTS_ENV_FILE", - "RADROOTS_OUTPUT", - "RADROOTS_CLI_LOGGING_FILTER", - "RADROOTS_CLI_LOGGING_OUTPUT_DIR", - "RADROOTS_CLI_LOGGING_STDOUT", - "RADROOTS_CLI_PATHS_PROFILE", - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", - "RADROOTS_LOG_FILTER", - "RADROOTS_LOG_DIR", - "RADROOTS_LOG_STDOUT", - "RADROOTS_ACCOUNT", - "RADROOTS_ACCOUNT_SECRET_BACKEND", - "RADROOTS_ACCOUNT_SECRET_FALLBACK", - "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", - "RADROOTS_HYF_ENABLED", - "RADROOTS_HYF_EXECUTABLE", - "RADROOTS_IDENTITY_PATH", - "RADROOTS_SIGNER", - "RADROOTS_RELAYS", - "RADROOTS_MYC_EXECUTABLE", - "RADROOTS_MYC_STATUS_TIMEOUT_MS", - "RADROOTS_RPC_URL", - "RADROOTS_RPC_BEARER_TOKEN", - ] { - command.env_remove(key); - } - command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); - command -} - -#[test] -fn find_reports_unconfigured_when_local_replica_is_missing() { - let dir = tempdir().expect("tempdir"); - let output = cli_command_in(dir.path()) - .args(["--json", "find", "eggs"]) - .output() - .expect("run find"); - - assert_eq!(output.status.code(), Some(3)); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); - assert_eq!(json["state"], "unconfigured"); - assert_eq!(json["actions"][0], "radroots local init"); -} - -#[test] -fn find_returns_json_and_ndjson_from_local_market_rows() { - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - seed_trade_product( - dir.path(), - "00000000-0000-0000-0000-000000000101", - "heirloom-tomato", - Some(ADDRESS_BACKED_LISTING_ADDR), - "produce", - "Heirloom Tomato", - "Bright red slicing tomatoes", - 18, - 12, - Some("Asheville"), - ); - seed_trade_product( - dir.path(), - "00000000-0000-0000-0000-000000000102", - "tomato-sauce", - None, - "prepared", - "Tomato Sauce", - "Slow cooked tomato sauce", - 8, - 6, - Some("Black Mountain"), - ); - - let json_output = cli_command_in(dir.path()) - .args(["--json", "find", "tomato"]) - .output() - .expect("run json find"); - assert!(json_output.status.success()); - let json: Value = serde_json::from_slice(json_output.stdout.as_slice()).expect("json"); - assert_eq!(json["state"], "ready"); - assert_eq!(json["count"], 2); - assert_eq!( - json["results"][0]["provenance"]["origin"], - "local_replica.trade_product" - ); - assert_eq!(json["results"][0]["location_primary"], "Asheville"); - assert_eq!( - json["results"][0]["listing_addr"], - ADDRESS_BACKED_LISTING_ADDR - ); - - let ndjson_output = cli_command_in(dir.path()) - .args(["--ndjson", "find", "tomato"]) - .output() - .expect("run ndjson find"); - assert!(ndjson_output.status.success()); - let stdout = String::from_utf8(ndjson_output.stdout).expect("utf8 stdout"); - let lines = stdout.lines().collect::<Vec<_>>(); - assert_eq!(lines.len(), 2); - assert!(lines[0].contains("\"title\":\"Heirloom Tomato\"")); - assert!(lines[0].contains("\"listing_addr\"")); - assert!(lines[1].contains("\"title\":\"Tomato Sauce\"")); -} - -#[test] -fn find_human_output_uses_market_cards_without_internal_footer() { - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - seed_trade_product( - dir.path(), - "00000000-0000-0000-0000-000000000103", - "fresh-eggs", - None, - "protein", - "Fresh Eggs", - "Pasture-raised eggs", - 36, - 24, - Some("Marshall"), - ); - - let output = cli_command_in(dir.path()) - .args(["find", "eggs"]) - .output() - .expect("run human find"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("1 listing for eggs")); - assert!(stdout.contains("Fresh Eggs")); - assert!(stdout.contains("Key")); - assert!(stdout.contains("Price")); - assert!(!stdout.contains("provenance:")); - assert!(!stdout.contains("source:")); -} - -#[test] -fn find_reports_empty_results_without_failing() { - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - let output = cli_command_in(dir.path()) - .args(["--json", "find", "saffron"]) - .output() - .expect("run empty find"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); - assert_eq!(json["state"], "empty"); - assert_eq!(json["count"], 0); - assert!( - json["reason"] - .as_str() - .is_some_and(|reason| reason.contains("no local market results matched")) - ); -} - -#[test] -fn find_uses_hyf_query_rewrite_when_available() { - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - seed_trade_product( - dir.path(), - "00000000-0000-0000-0000-000000000104", - "fresh-eggs", - None, - "protein", - "Fresh Eggs", - "Pasture-raised eggs", - 36, - 24, - Some("Marshall"), - ); - - let hyfd = write_fake_hyfd( - dir.path(), - r#"{"version":1,"request_id":"cli-doctor-hyf-status","trace_id":"cli-doctor-hyf-status","ok":true,"output":{"build_identity":{"protocol_version":1},"enabled_execution_modes":{"deterministic":true}}}"#, - r#"{"version":1,"request_id":"cli-find-query-rewrite","trace_id":"cli-find-query-rewrite","ok":true,"output":{"original_text":"henhouse","normalized_text":"henhouse","rewritten_text":"eggs","query_terms":["eggs"],"normalization_signals":["query_rewrite"],"ranking_hints":["local_first"],"extracted_filters":{"local_intent":false,"fulfillment":"any","time_window":"any"}}}"#, - ); - - let json_output = cli_command_in(dir.path()) - .env("RADROOTS_HYF_ENABLED", "true") - .env("RADROOTS_HYF_EXECUTABLE", &hyfd) - .args(["--json", "find", "henhouse"]) - .output() - .expect("run hyf json find"); - assert!(json_output.status.success()); - let json: Value = serde_json::from_slice(json_output.stdout.as_slice()).expect("json"); - assert_eq!(json["state"], "ready"); - assert_eq!(json["count"], 1); - assert_eq!(json["query"], "henhouse"); - assert_eq!(json["hyf"]["state"], "query_rewrite_applied"); - assert_eq!(json["hyf"]["rewritten_query"], "eggs"); - assert_eq!(json["hyf"]["query_terms"], json!(["eggs"])); - assert_eq!(json["results"][0]["title"], "Fresh Eggs"); - assert_eq!(json["results"][0]["hyf"]["rewritten_query"], "eggs"); - - let human_output = cli_command_in(dir.path()) - .env("RADROOTS_HYF_ENABLED", "true") - .env("RADROOTS_HYF_EXECUTABLE", &hyfd) - .args(["find", "henhouse"]) - .output() - .expect("run hyf human find"); - assert!(human_output.status.success()); - let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("1 listing for eggs")); - assert!(stdout.contains("Also searched for")); - assert!(stdout.contains("henhouse")); - - let ndjson_output = cli_command_in(dir.path()) - .env("RADROOTS_HYF_ENABLED", "true") - .env("RADROOTS_HYF_EXECUTABLE", &hyfd) - .args(["--ndjson", "find", "henhouse"]) - .output() - .expect("run hyf ndjson find"); - assert!(ndjson_output.status.success()); - let stdout = String::from_utf8(ndjson_output.stdout).expect("utf8 stdout"); - let lines = stdout.lines().collect::<Vec<_>>(); - assert_eq!(lines.len(), 1); - assert!(lines[0].contains("\"title\":\"Fresh Eggs\"")); - assert!(lines[0].contains("\"rewritten_query\":\"eggs\"")); -} - -#[test] -fn find_human_output_tiers_change_information_budget() { - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - seed_trade_product( - dir.path(), - "00000000-0000-0000-0000-000000000105", - "fresh-eggs", - None, - "protein", - "Fresh Eggs", - "Pasture-raised eggs", - 36, - 24, - Some("Marshall"), - ); - - let quiet_output = cli_command_in(dir.path()) - .args(["--quiet", "find", "eggs"]) - .output() - .expect("run quiet find"); - assert!(quiet_output.status.success()); - let quiet_stdout = String::from_utf8(quiet_output.stdout).expect("utf8 stdout"); - assert_eq!(quiet_stdout.trim(), "fresh-eggs"); - - let default_output = cli_command_in(dir.path()) - .args(["find", "eggs"]) - .output() - .expect("run default find"); - assert!(default_output.status.success()); - let default_stdout = String::from_utf8(default_output.stdout).expect("utf8 stdout"); - assert!(default_stdout.contains("1 listing for eggs")); - assert!(!default_stdout.contains("Details")); - assert!(!default_stdout.contains("Trace")); - assert!(!default_stdout.contains("Source")); - - let verbose_output = cli_command_in(dir.path()) - .args(["--verbose", "find", "eggs"]) - .output() - .expect("run verbose find"); - assert!(verbose_output.status.success()); - let verbose_stdout = String::from_utf8(verbose_output.stdout).expect("utf8 stdout"); - assert!(verbose_stdout.contains("1 listing for eggs")); - assert!(verbose_stdout.contains("Details")); - assert!(verbose_stdout.contains("Source")); - assert!(verbose_stdout.contains("Freshness")); - assert!(verbose_stdout.contains("Relay count")); - assert!(!verbose_stdout.contains("Trace")); - - let trace_output = cli_command_in(dir.path()) - .args(["--trace", "find", "eggs"]) - .output() - .expect("run trace find"); - assert!(trace_output.status.success()); - let trace_stdout = String::from_utf8(trace_output.stdout).expect("utf8 stdout"); - assert!(trace_stdout.contains("Details")); - assert!(trace_stdout.contains("Trace")); - assert!(trace_stdout.contains("Command")); - assert!(trace_stdout.contains("\"source\"")); -} - -#[test] -fn find_uses_hyf_query_rewrite_without_status_preflight() { - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - seed_trade_product( - dir.path(), - "00000000-0000-0000-0000-000000000106", - "fresh-eggs", - None, - "protein", - "Fresh Eggs", - "Pasture-raised eggs", - 36, - 24, - Some("Marshall"), - ); - - let hyfd = write_fake_hyfd_with_failing_status( - dir.path(), - r#"{"version":1,"request_id":"cli-find-query-rewrite","trace_id":"cli-find-query-rewrite","ok":true,"output":{"original_text":"henhouse","normalized_text":"henhouse","rewritten_text":"eggs","query_terms":["eggs"],"normalization_signals":["query_rewrite"],"ranking_hints":["local_first"],"extracted_filters":{"local_intent":false,"fulfillment":"any","time_window":"any"}}}"#, - ); - - let output = cli_command_in(dir.path()) - .env("RADROOTS_HYF_ENABLED", "true") - .env("RADROOTS_HYF_EXECUTABLE", &hyfd) - .args(["--json", "find", "henhouse"]) - .output() - .expect("run hyf json find"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); - assert_eq!(json["state"], "ready"); - assert_eq!(json["count"], 1); - assert_eq!(json["hyf"]["state"], "query_rewrite_applied"); - assert_eq!(json["hyf"]["rewritten_query"], "eggs"); - assert_eq!(json["results"][0]["title"], "Fresh Eggs"); -} - -#[test] -fn find_falls_back_cleanly_when_hyf_is_unavailable() { - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - seed_trade_product( - dir.path(), - "00000000-0000-0000-0000-000000000105", - "fresh-eggs", - None, - "protein", - "Fresh Eggs", - "Pasture-raised eggs", - 36, - 24, - Some("Marshall"), - ); - - let output = cli_command_in(dir.path()) - .env("RADROOTS_HYF_ENABLED", "true") - .env("RADROOTS_HYF_EXECUTABLE", dir.path().join("missing-hyfd")) - .args(["--json", "find", "eggs"]) - .output() - .expect("run fallback find"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); - assert_eq!(json["state"], "ready"); - assert_eq!(json["count"], 1); - assert!(json["hyf"].is_null()); - assert_eq!(json["results"][0]["title"], "Fresh Eggs"); -} - -fn seed_trade_product( - workdir: &Path, - product_id: &str, - key: &str, - listing_addr: Option<&str>, - category: &str, - title: &str, - summary: &str, - qty_amt: i64, - qty_avail: i64, - location_label: Option<&str>, -) { - let replica_db = data_root(workdir).join("apps/cli/replica/replica.sqlite"); - let executor = SqliteExecutor::open(&replica_db).expect("open replica db"); - let now = "2026-04-07T00:00:00.000Z"; - executor - .exec( - "INSERT INTO trade_product (id, created_at, updated_at, key, listing_addr, category, title, summary, process, lot, profile, year, qty_amt, qty_unit, qty_label, qty_avail, price_amt, price_currency, price_qty_amt, price_qty_unit, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", - json!([ - product_id, - now, - now, - key, - listing_addr, - category, - title, - summary, - "fresh", - "lot-a", - "standard", - 2026, - qty_amt, - "kg", - "kg", - qty_avail, - 12.5, - "USD", - 1, - "kg", - Value::Null - ]) - .to_string() - .as_str(), - ) - .expect("insert trade product"); - - if let Some(location_label) = location_label { - let location_id = format!("11111111-1111-1111-1111-{}", &product_id[24..]); - executor - .exec( - "INSERT INTO gcs_location (id, created_at, updated_at, d_tag, lat, lng, geohash, point, polygon, accuracy, altitude, tag_0, label, area, elevation, soil, climate, gc_id, gc_name, gc_admin1_id, gc_admin1_name, gc_country_id, gc_country_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", - json!([ - location_id, - now, - now, - format!("location-{product_id}"), - 35.0, - -82.0, - "dnrj", - "POINT(-82 35)", - "POLYGON EMPTY", - Value::Null, - Value::Null, - Value::Null, - location_label, - Value::Null, - Value::Null, - Value::Null, - Value::Null, - Value::Null, - location_label, - Value::Null, - Value::Null, - Value::Null, - "USA" - ]) - .to_string() - .as_str(), - ) - .expect("insert gcs location"); - executor - .exec( - "INSERT INTO trade_product_location (tb_tp, tb_gl) VALUES (?, ?);", - json!([product_id, location_id]).to_string().as_str(), - ) - .expect("insert trade product location"); - } -} - -fn write_fake_hyfd( - workdir: &Path, - status_response: &str, - rewrite_response: &str, -) -> std::path::PathBuf { - let path = workdir.join("fake-hyfd"); - let script = format!( - "#!/bin/sh\nread -r request || exit 64\ncase \"$request\" in\n *'\"capability\":\"sys.status\"'*)\n cat <<'JSON'\n{status_response}\nJSON\n ;;\n *'\"capability\":\"query_rewrite\"'*)\n cat <<'JSON'\n{rewrite_response}\nJSON\n ;;\n *)\n cat <<'JSON'\n{{\"version\":1,\"request_id\":\"unexpected\",\"ok\":false,\"error\":{{\"code\":\"unsupported_capability\",\"message\":\"unexpected request\"}}}}\nJSON\n ;;\nesac\n" - ); - std::fs::write(&path, script).expect("write fake hyfd"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut permissions = std::fs::metadata(&path).expect("metadata").permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&path, permissions).expect("chmod fake hyfd"); - } - path -} - -fn write_fake_hyfd_with_failing_status( - workdir: &Path, - rewrite_response: &str, -) -> std::path::PathBuf { - let path = workdir.join("fake-hyfd"); - let script = format!( - "#!/bin/sh\nread -r request || exit 64\ncase \"$request\" in\n *'\"capability\":\"sys.status\"'*)\n echo \"status should not be called\" >&2\n exit 23\n ;;\n *'\"capability\":\"query_rewrite\"'*)\n cat <<'JSON'\n{rewrite_response}\nJSON\n ;;\n *)\n cat <<'JSON'\n{{\"version\":1,\"request_id\":\"unexpected\",\"ok\":false,\"error\":{{\"code\":\"unsupported_capability\",\"message\":\"unexpected request\"}}}}\nJSON\n ;;\nesac\n" - ); - std::fs::write(&path, script).expect("write fake hyfd"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut permissions = std::fs::metadata(&path).expect("metadata").permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&path, permissions).expect("chmod fake hyfd"); - } - path -} diff --git a/tests/help.rs b/tests/help.rs @@ -1,168 +0,0 @@ -use std::process::Command; - -use assert_cmd::prelude::*; - -fn help_command() -> Command { - Command::cargo_bin("radroots").expect("binary") -} - -#[test] -fn root_help_is_workflow_grouped() { - let output = help_command() - .arg("--help") - .output() - .expect("run root help"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Start here")); - assert!(stdout.contains("Sell from your farm")); - assert!(stdout.contains("Buy from the market")); - assert!(stdout.contains("Accounts and settings")); - assert!(stdout.contains("Advanced and troubleshooting")); - assert!(stdout.contains("setup")); - assert!(stdout.contains("status")); - assert!(stdout.contains("market")); - assert!(stdout.contains("sell")); - assert!(stdout.contains("Examples")); -} - -#[test] -fn setup_help_describes_the_landed_workflow_layer() { - let output = help_command() - .args(["setup", "--help"]) - .output() - .expect("run setup help"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains( - "This workflow layer sits on top of the existing account, local, and farm commands." - )); - assert!(stdout.contains( - "Use `radroots account create` or `radroots account select` explicitly when no actor is resolved." - )); - assert!(!stdout.contains("being added")); -} - -#[test] -fn status_help_describes_current_readiness_surfaces() { - let output = help_command() - .args(["status", "--help"]) - .output() - .expect("run status help"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains( - "This workflow summary reflects the current readiness and configuration surfaces." - )); - assert!(stdout.contains( - "When no actor is resolved, it points to explicit account commands instead of mutating account state." - )); - assert!(!stdout.contains("being added")); -} - -#[test] -fn account_help_prefers_human_first_aliases() { - let output = help_command() - .args(["account", "--help"]) - .output() - .expect("run account help"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("create")); - assert!(stdout.contains("import")); - assert!(stdout.contains("view")); - assert!(stdout.contains("list")); - assert!(stdout.contains("select")); - assert!(stdout.contains("clear-default")); - assert!(stdout.contains("remove")); - assert!(stdout.contains("Select stores the default account.")); - assert!(stdout.contains("Compatibility aliases: new, whoami, ls, use.")); -} - -#[test] -fn farm_help_mentions_human_first_subcommands() { - let output = help_command() - .args(["farm", "--help"]) - .output() - .expect("run farm help"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("init")); - assert!(stdout.contains("set")); - assert!(stdout.contains("check")); - assert!(stdout.contains("show")); - assert!(stdout.contains("publish")); - assert!(stdout.contains( - "Compatibility paths: `farm setup`, `farm status`, and `farm get` remain available." - )); -} - -#[test] -fn market_help_is_example_first() { - let output = help_command() - .args(["market", "--help"]) - .output() - .expect("run market help"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("update")); - assert!(stdout.contains("search")); - assert!(stdout.contains("view")); - assert!(stdout.contains("radroots market search tomatoes")); - assert!( - stdout.contains( - "Compatibility paths: `sync pull`, `find`, and `listing get` remain available." - ) - ); -} - -#[test] -fn sell_help_mentions_listing_compatibility() { - let output = help_command() - .args(["sell", "--help"]) - .output() - .expect("run sell help"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("add")); - assert!(stdout.contains("show")); - assert!(stdout.contains("check")); - assert!(stdout.contains("publish")); - assert!(stdout.contains("update")); - assert!(stdout.contains("pause")); - assert!(stdout.contains("reprice")); - assert!(stdout.contains("restock")); - assert!( - stdout.contains( - "Compatibility path: the advanced `listing` command family remains available." - ) - ); -} - -#[test] -fn order_help_prefers_create_view_and_list() { - let output = help_command() - .args(["order", "--help"]) - .output() - .expect("run order help"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("create")); - assert!(stdout.contains("view")); - assert!(stdout.contains("list")); - assert!(stdout.contains("submit")); - assert!(stdout.contains("watch")); - assert!(stdout.contains("cancel")); - assert!(stdout.contains("history")); - assert!(stdout.contains("radroots order submit ord_demo --watch")); - assert!(stdout.contains("Compatibility aliases: new, get, ls.")); - assert!(stdout.contains("durable order cancel availability")); -} diff --git a/tests/identity_commands.rs b/tests/identity_commands.rs @@ -1,601 +0,0 @@ -use std::fs; -use std::path::Path; -use std::process::Command; - -use assert_cmd::prelude::*; -use radroots_identity::RadrootsIdentity; -use serde_json::Value; -use tempfile::tempdir; - -fn data_root(workdir: &Path) -> std::path::PathBuf { - if cfg!(windows) { - workdir.join("local").join("Radroots").join("data") - } else { - workdir.join("home").join(".radroots").join("data") - } -} - -fn secrets_root(workdir: &Path) -> std::path::PathBuf { - if cfg!(windows) { - workdir.join("roaming").join("Radroots").join("secrets") - } else { - workdir.join("home").join(".radroots").join("secrets") - } -} - -fn cli_command_in(workdir: &Path) -> Command { - let mut command = Command::cargo_bin("radroots").expect("binary"); - command.current_dir(workdir); - command.env("HOME", workdir.join("home")); - command.env("APPDATA", workdir.join("roaming")); - command.env("LOCALAPPDATA", workdir.join("local")); - for key in [ - "RADROOTS_ENV_FILE", - "RADROOTS_OUTPUT", - "RADROOTS_CLI_LOGGING_FILTER", - "RADROOTS_CLI_LOGGING_OUTPUT_DIR", - "RADROOTS_CLI_LOGGING_STDOUT", - "RADROOTS_CLI_PATHS_PROFILE", - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", - "RADROOTS_LOG_FILTER", - "RADROOTS_LOG_DIR", - "RADROOTS_LOG_STDOUT", - "RADROOTS_ACCOUNT", - "RADROOTS_ACCOUNT_SECRET_BACKEND", - "RADROOTS_ACCOUNT_SECRET_FALLBACK", - "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", - "RADROOTS_IDENTITY_PATH", - "RADROOTS_SIGNER", - "RADROOTS_RELAYS", - "RADROOTS_MYC_EXECUTABLE", - "RADROOTS_MYC_STATUS_TIMEOUT_MS", - "RADROOTS_RPC_URL", - "RADROOTS_RPC_BEARER_TOKEN", - ] { - command.env_remove(key); - } - command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); - command -} - -#[test] -fn account_new_json_creates_local_account_store_entry() { - let dir = tempdir().expect("tempdir"); - let store_path = data_root(dir.path()).join("shared/accounts/store.json"); - - let output = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - - assert!(output.status.success()); - assert!(store_path.exists()); - - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["state"], "created"); - assert_eq!(json["source"], "shared account store ยท local first"); - assert!(json["account"]["id"].is_string()); - assert_eq!(json["account"]["signer"], "local"); - assert_eq!(json["account"]["is_default"], true); - assert!(json["public_identity"]["id"].is_string()); - assert!(json["public_identity"]["public_key_hex"].is_string()); - assert!(json["public_identity"]["public_key_npub"].is_string()); -} - -#[test] -fn account_create_quiet_reports_created_account_id() { - let dir = tempdir().expect("tempdir"); - - let output = cli_command_in(dir.path()) - .args(["--quiet", "account", "create"]) - .output() - .expect("run quiet account create"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let line = stdout.trim(); - assert!(line.starts_with("Account created: ")); - assert!(line.len() > "Account created: ".len()); -} - -#[test] -fn account_new_encrypts_file_backed_secret_fallback_by_default() { - let dir = tempdir().expect("tempdir"); - - let output = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - let account_id = json["account"]["id"].as_str().expect("account id"); - let secrets_dir = secrets_root(dir.path()).join("shared/accounts"); - let envelope_path = secrets_dir.join(format!("{account_id}.secret.json")); - - assert!(secrets_dir.join(".vault.key").exists()); - assert!(envelope_path.exists()); - assert!(!secrets_dir.join(format!("{account_id}.secret")).exists()); - - let envelope: Value = serde_json::from_slice( - fs::read(envelope_path) - .expect("read encrypted envelope") - .as_slice(), - ) - .expect("envelope json"); - assert_eq!(envelope["header"]["cipher"], "x_cha_cha20_poly1305"); - assert_eq!(envelope["header"]["key_source"], "secret_vault_wrapped"); - assert!(envelope["ciphertext"].is_array()); -} - -#[test] -fn account_import_json_creates_watch_only_account_without_secret_material() { - let dir = tempdir().expect("tempdir"); - let identity = RadrootsIdentity::generate(); - let import_path = dir.path().join("watch-only-identity.json"); - fs::write( - &import_path, - serde_json::to_vec_pretty(&identity.to_public()).expect("serialize public identity"), - ) - .expect("write import file"); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "account", - "import", - import_path.to_str().expect("utf8 path"), - ]) - .output() - .expect("run account import"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - let account_id = json["account"]["id"].as_str().expect("account id"); - assert_eq!(json["state"], "imported"); - assert_eq!(json["source"], "shared account store ยท watch-only import"); - assert_eq!(json["account"]["signer"], "watch_only"); - assert_eq!(json["account"]["is_default"], true); - - let secrets_dir = secrets_root(dir.path()).join("shared/accounts"); - assert!(!secrets_dir.join(format!("{account_id}.secret")).exists()); - assert!( - !secrets_dir - .join(format!("{account_id}.secret.json")) - .exists() - ); - - let whoami = cli_command_in(dir.path()) - .args(["--json", "account", "whoami"]) - .output() - .expect("run account whoami"); - assert!(whoami.status.success()); - let whoami_json: Value = serde_json::from_slice(whoami.stdout.as_slice()).expect("whoami json"); - assert_eq!( - whoami_json["account_resolution"]["resolved_account"]["id"], - account_id - ); - assert_eq!( - whoami_json["account_resolution"]["resolved_account"]["signer"], - "watch_only" - ); -} - -#[test] -fn account_new_rejects_dry_run_without_creating_store_state() { - let dir = tempdir().expect("tempdir"); - let store_path = data_root(dir.path()).join("shared/accounts/store.json"); - - let output = cli_command_in(dir.path()) - .args(["--dry-run", "account", "new"]) - .output() - .expect("run account new"); - - assert_eq!(output.status.code(), Some(2)); - assert!(!store_path.exists()); - assert!(output.stdout.is_empty()); - let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); - assert!(stderr.contains("`account create` does not support --dry-run yet")); -} - -#[test] -fn account_new_rejects_plaintext_fallback_backend() { - let dir = tempdir().expect("tempdir"); - - let output = cli_command_in(dir.path()) - .env("RADROOTS_ACCOUNT_SECRET_BACKEND", "host_vault") - .env("RADROOTS_ACCOUNT_SECRET_FALLBACK", "plaintext_file") - .args(["account", "new"]) - .output() - .expect("run account new"); - - assert_eq!(output.status.code(), Some(2)); - let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); - assert!(stderr.contains("must be `host_vault`, `encrypted_file`, or `none`")); -} - -#[test] -fn account_new_rejects_memory_secret_backend_without_creating_store_state() { - let dir = tempdir().expect("tempdir"); - let store_path = data_root(dir.path()).join("shared/accounts/store.json"); - - let output = cli_command_in(dir.path()) - .env("RADROOTS_ACCOUNT_SECRET_BACKEND", "memory") - .args(["account", "new"]) - .output() - .expect("run account new"); - - assert_eq!(output.status.code(), Some(2)); - assert!(!store_path.exists()); - assert!(output.stdout.is_empty()); - let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); - assert!(stderr.contains("RADROOTS_ACCOUNT_SECRET_BACKEND")); - assert!(stderr.contains("must be `host_vault` or `encrypted_file`")); -} - -#[test] -fn account_new_rejects_memory_secret_fallback_without_creating_store_state() { - let dir = tempdir().expect("tempdir"); - let store_path = data_root(dir.path()).join("shared/accounts/store.json"); - - let output = cli_command_in(dir.path()) - .env("RADROOTS_ACCOUNT_SECRET_BACKEND", "host_vault") - .env("RADROOTS_ACCOUNT_SECRET_FALLBACK", "memory") - .args(["account", "new"]) - .output() - .expect("run account new"); - - assert_eq!(output.status.code(), Some(2)); - assert!(!store_path.exists()); - assert!(output.stdout.is_empty()); - let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); - assert!(stderr.contains("RADROOTS_ACCOUNT_SECRET_FALLBACK")); - assert!(stderr.contains("must be `host_vault`, `encrypted_file`, or `none`")); -} - -#[test] -fn account_whoami_json_reads_default_account() { - let dir = tempdir().expect("tempdir"); - - let init = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(init.status.success()); - - let output = cli_command_in(dir.path()) - .args(["--json", "account", "whoami"]) - .output() - .expect("run account whoami"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["state"], "ready"); - assert_eq!(json["source"], "shared account store ยท local first"); - assert_eq!(json["account_resolution"]["source"], "default_account"); - assert!(json["account_resolution"]["resolved_account"]["id"].is_string()); - assert_eq!( - json["account_resolution"]["resolved_account"]["signer"], - "local" - ); - assert_eq!( - json["account_resolution"]["resolved_account"]["is_default"], - true - ); - assert_eq!( - json["account_resolution"]["default_account"]["id"], - json["account_resolution"]["resolved_account"]["id"] - ); - assert!(json["public_identity"]["id"].is_string()); -} - -#[test] -fn account_new_does_not_replace_existing_default_account() { - let dir = tempdir().expect("tempdir"); - let store_path = data_root(dir.path()).join("shared/accounts/store.json"); - - let first = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run first account new"); - assert!(first.status.success()); - let first_json: Value = serde_json::from_slice(first.stdout.as_slice()).expect("first json"); - let first_id = first_json["account"]["id"] - .as_str() - .expect("first account id") - .to_owned(); - assert_eq!(first_json["account"]["is_default"], true); - - let second = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run second account new"); - assert!(second.status.success()); - let second_json: Value = serde_json::from_slice(second.stdout.as_slice()).expect("second json"); - assert_eq!(second_json["account"]["is_default"], false); - - let store_json: Value = - serde_json::from_slice(fs::read(&store_path).expect("read store").as_slice()) - .expect("parse store"); - assert_eq!(store_json["default_account_id"], first_id); - - let whoami = cli_command_in(dir.path()) - .args(["--json", "account", "whoami"]) - .output() - .expect("run account whoami"); - assert!(whoami.status.success()); - let whoami_json: Value = serde_json::from_slice(whoami.stdout.as_slice()).expect("whoami json"); - assert_eq!( - whoami_json["account_resolution"]["resolved_account"]["id"], - first_id - ); -} - -#[test] -fn account_clear_default_json_clears_stored_default_without_removing_accounts() { - let dir = tempdir().expect("tempdir"); - let store_path = data_root(dir.path()).join("shared/accounts/store.json"); - - let first = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run first account new"); - assert!(first.status.success()); - let first_json: Value = serde_json::from_slice(first.stdout.as_slice()).expect("first json"); - let first_id = first_json["account"]["id"] - .as_str() - .expect("first account id") - .to_owned(); - - let second = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run second account new"); - assert!(second.status.success()); - - let output = cli_command_in(dir.path()) - .args(["--json", "account", "clear-default"]) - .output() - .expect("run clear-default"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("clear-default json"); - assert_eq!(json["state"], "cleared"); - assert_eq!(json["cleared_account"]["id"], first_id); - assert_eq!(json["remaining_account_count"], 2); - - let store_json: Value = - serde_json::from_slice(fs::read(&store_path).expect("read store").as_slice()) - .expect("parse store"); - assert_eq!(store_json["default_account_id"], Value::Null); - assert_eq!( - store_json["accounts"] - .as_array() - .expect("accounts array") - .len(), - 2 - ); - - let whoami = cli_command_in(dir.path()) - .args(["--json", "account", "whoami"]) - .output() - .expect("run account whoami"); - assert_eq!(whoami.status.code(), Some(3)); - let whoami_json: Value = serde_json::from_slice(whoami.stdout.as_slice()).expect("whoami json"); - assert_eq!(whoami_json["account_resolution"]["source"], "none"); - assert_eq!( - whoami_json["account_resolution"]["default_account"], - Value::Null - ); -} - -#[test] -fn account_whoami_json_reports_unconfigured_without_accounts() { - let dir = tempdir().expect("tempdir"); - - let output = cli_command_in(dir.path()) - .args(["--json", "account", "whoami"]) - .output() - .expect("run account whoami"); - - assert_eq!(output.status.code(), Some(3)); - - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["state"], "unconfigured"); - assert_eq!(json["account_resolution"]["source"], "none"); - assert_eq!(json["account_resolution"]["resolved_account"], Value::Null); - assert_eq!(json["account_resolution"]["default_account"], Value::Null); - assert_eq!(json["public_identity"], Value::Null); - assert!( - json["reason"] - .as_str() - .is_some_and(|value| value.contains("no local accounts found")) - ); -} - -#[test] -fn account_ls_ndjson_emits_one_line_per_account() { - let dir = tempdir().expect("tempdir"); - - let first = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run first account new"); - assert!(first.status.success()); - - let second = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run second account new"); - assert!(second.status.success()); - - let output = cli_command_in(dir.path()) - .args(["--ndjson", "account", "ls"]) - .output() - .expect("run account ls"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let lines = stdout.lines().collect::<Vec<_>>(); - assert_eq!(lines.len(), 2); - assert!(lines[0].contains("\"id\":")); - assert!(lines[1].contains("\"id\":")); -} - -#[test] -fn account_use_selects_existing_account() { - let dir = tempdir().expect("tempdir"); - - let first = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run first account new"); - assert!(first.status.success()); - let first_json: Value = - serde_json::from_slice(first.stdout.as_slice()).expect("first account json"); - let first_id = first_json["account"]["id"] - .as_str() - .expect("first account id") - .to_owned(); - - let second = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run second account new"); - assert!(second.status.success()); - - let use_output = cli_command_in(dir.path()) - .args(["--json", "account", "use", first_id.as_str()]) - .output() - .expect("run account use"); - - assert!(use_output.status.success()); - let use_json: Value = - serde_json::from_slice(use_output.stdout.as_slice()).expect("account use json"); - assert_eq!(use_json["state"], "default"); - assert_eq!(use_json["default_account_id"], first_id); - assert_eq!(use_json["account"]["is_default"], true); - - let whoami = cli_command_in(dir.path()) - .args(["--json", "account", "whoami"]) - .output() - .expect("run account whoami"); - assert!(whoami.status.success()); - let whoami_json: Value = - serde_json::from_slice(whoami.stdout.as_slice()).expect("account whoami json"); - assert_eq!( - whoami_json["account_resolution"]["source"], - "default_account" - ); - assert_eq!( - whoami_json["account_resolution"]["resolved_account"]["id"], - first_id - ); - assert_eq!( - whoami_json["account_resolution"]["resolved_account"]["is_default"], - true - ); -} - -#[test] -fn account_remove_json_clears_default_when_removing_default_account() { - let dir = tempdir().expect("tempdir"); - let store_path = data_root(dir.path()).join("shared/accounts/store.json"); - - let first = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run first account new"); - assert!(first.status.success()); - let first_json: Value = serde_json::from_slice(first.stdout.as_slice()).expect("first json"); - let first_id = first_json["account"]["id"] - .as_str() - .expect("first account id") - .to_owned(); - - let second = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run second account new"); - assert!(second.status.success()); - - let output = cli_command_in(dir.path()) - .args(["--json", "account", "remove", first_id.as_str()]) - .output() - .expect("run account remove"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("remove json"); - assert_eq!(json["state"], "removed"); - assert_eq!(json["removed_account"]["id"], first_id); - assert_eq!(json["default_cleared"], true); - assert_eq!(json["remaining_account_count"], 1); - - let store_json: Value = - serde_json::from_slice(fs::read(&store_path).expect("read store").as_slice()) - .expect("parse store"); - assert_eq!(store_json["default_account_id"], Value::Null); - assert_eq!( - store_json["accounts"] - .as_array() - .expect("accounts array") - .len(), - 1 - ); - - let whoami = cli_command_in(dir.path()) - .args(["--json", "account", "whoami"]) - .output() - .expect("run account whoami"); - assert_eq!(whoami.status.code(), Some(3)); - let whoami_json: Value = serde_json::from_slice(whoami.stdout.as_slice()).expect("whoami json"); - assert_eq!(whoami_json["account_resolution"]["source"], "none"); - assert_eq!( - whoami_json["account_resolution"]["default_account"], - Value::Null - ); -} - -#[test] -fn account_use_rejects_ambiguous_label_selector() { - let dir = tempdir().expect("tempdir"); - let store_path = data_root(dir.path()).join("shared/accounts/store.json"); - - let first = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run first account new"); - assert!(first.status.success()); - - let second = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run second account new"); - assert!(second.status.success()); - - let mut store_json: Value = - serde_json::from_slice(fs::read(&store_path).expect("read store").as_slice()) - .expect("parse store"); - let accounts = store_json["accounts"] - .as_array_mut() - .expect("accounts array"); - accounts[0]["label"] = Value::from("shared"); - accounts[1]["label"] = Value::from("shared"); - fs::write( - &store_path, - serde_json::to_vec_pretty(&store_json).expect("serialize store"), - ) - .expect("write store"); - - let output = cli_command_in(dir.path()) - .args(["account", "use", "shared"]) - .output() - .expect("run account use"); - - assert_eq!(output.status.code(), Some(2)); - let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); - assert!(stderr.contains("matched multiple local accounts")); -} diff --git a/tests/job_rpc.rs b/tests/job_rpc.rs @@ -1,888 +0,0 @@ -use std::fs; -use std::io::{Read, Write}; -use std::net::{TcpListener, TcpStream}; -use std::path::Path; -use std::process::Command; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; -use std::thread::{self, JoinHandle}; -use std::time::Duration; - -use assert_cmd::prelude::*; -use serde_json::{Value, json}; -use tempfile::tempdir; - -fn job_rpc_command_in(workdir: &Path) -> Command { - let mut command = Command::cargo_bin("radroots").expect("binary"); - command.current_dir(workdir); - command.env("HOME", workdir.join("home")); - for key in [ - "RADROOTS_ENV_FILE", - "RADROOTS_OUTPUT", - "RADROOTS_CLI_LOGGING_FILTER", - "RADROOTS_CLI_LOGGING_OUTPUT_DIR", - "RADROOTS_CLI_LOGGING_STDOUT", - "RADROOTS_CLI_PATHS_PROFILE", - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", - "RADROOTS_LOG_FILTER", - "RADROOTS_LOG_DIR", - "RADROOTS_LOG_STDOUT", - "RADROOTS_ACCOUNT", - "RADROOTS_ACCOUNT_SECRET_BACKEND", - "RADROOTS_ACCOUNT_SECRET_FALLBACK", - "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", - "RADROOTS_IDENTITY_PATH", - "RADROOTS_SIGNER", - "RADROOTS_RELAYS", - "RADROOTS_MYC_EXECUTABLE", - "RADROOTS_MYC_STATUS_TIMEOUT_MS", - "RADROOTS_RPC_URL", - "RADROOTS_RPC_BEARER_TOKEN", - ] { - command.env_remove(key); - } - command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); - command -} - -fn job_rpc_test_guard() -> MutexGuard<'static, ()> { - static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - .lock() - .expect("job rpc test lock") -} - -fn write_user_config(workdir: &Path, contents: &str) { - let config_dir = workdir.join("home/.radroots/config/apps/cli"); - fs::create_dir_all(&config_dir).expect("user config dir"); - fs::write(config_dir.join("config.toml"), contents).expect("write user config"); -} - -fn signer_session_config(url: &str) -> String { - format!( - r#" -[[capability_binding]] -capability = "write_plane.trade_jsonrpc" -provider = "radrootsd" -target_kind = "explicit_endpoint" -target = "{url}" - -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "managed_instance" -target = "default" -managed_account_ref = "acct_user" -signer_session_ref = "myc_conn_1" -"# - ) -} - -fn run_signer_session_command<F>( - workdir: &Path, - args: &[&str], - handler: F, -) -> (Value, MockRpcRequest) -where - F: Fn(&MockRpcRequest) -> MockRpcResponse + Send + Sync + 'static, -{ - let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); - let recorded = Arc::clone(&requests); - let server = MockRpcServer::start(move |request| { - let response = handler(&request); - recorded.lock().expect("record requests").push(request); - response - }); - write_user_config( - workdir, - signer_session_config(server.url().as_str()).as_str(), - ); - let output = job_rpc_command_in(workdir) - .env("RADROOTS_RPC_BEARER_TOKEN", "secret") - .args(args) - .output() - .expect("run signer session command"); - assert!( - output.status.success(), - "command {:?} stdout: {}\nstderr: {}", - args, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - let request = requests - .lock() - .expect("recorded requests") - .first() - .expect("one recorded request") - .clone(); - (json, request) -} - -#[derive(Debug, Clone)] -struct MockRpcRequest { - method: String, - auth_header: Option<String>, - body: Value, -} - -#[derive(Debug, Clone)] -struct MockRpcResponse { - status_code: u16, - body: Value, -} - -impl MockRpcResponse { - fn success(result: Value) -> Self { - Self { - status_code: 200, - body: json!({ - "jsonrpc": "2.0", - "id": 1, - "result": result, - }), - } - } - - fn rpc_error(code: i64, message: &str) -> Self { - Self { - status_code: 200, - body: json!({ - "jsonrpc": "2.0", - "id": 1, - "error": { - "code": code, - "message": message, - } - }), - } - } -} - -struct MockRpcServer { - address: String, - shutdown: Arc<AtomicBool>, - handle: Option<JoinHandle<()>>, -} - -impl MockRpcServer { - fn start<F>(handler: F) -> Self - where - F: Fn(MockRpcRequest) -> MockRpcResponse + Send + Sync + 'static, - { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock rpc listener"); - listener - .set_nonblocking(true) - .expect("set mock rpc listener nonblocking"); - let address = listener - .local_addr() - .expect("mock rpc local addr") - .to_string(); - let shutdown = Arc::new(AtomicBool::new(false)); - let shutdown_flag = Arc::clone(&shutdown); - let handler: Arc<dyn Fn(MockRpcRequest) -> MockRpcResponse + Send + Sync> = - Arc::new(handler); - let handle = thread::spawn(move || { - while !shutdown_flag.load(Ordering::SeqCst) { - match listener.accept() { - Ok((mut stream, _)) => { - let _ = stream.set_nonblocking(false); - match read_request(&mut stream) { - Ok(request) => { - let response = handler(request); - let _ = write_response(&mut stream, &response); - } - Err(_) => {} - } - } - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(10)); - } - Err(_) => { - thread::sleep(Duration::from_millis(10)); - } - } - } - }); - - Self { - address, - shutdown, - handle: Some(handle), - } - } - - fn url(&self) -> String { - format!("http://{}", self.address) - } -} - -impl Drop for MockRpcServer { - fn drop(&mut self) { - self.shutdown.store(true, Ordering::SeqCst); - let _ = TcpStream::connect(&self.address); - if let Some(handle) = self.handle.take() { - handle.join().expect("join mock rpc server thread"); - } - } -} - -fn read_request(stream: &mut TcpStream) -> Result<MockRpcRequest, String> { - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .map_err(|error| format!("set mock rpc read timeout: {error}"))?; - - let mut buffer = Vec::new(); - let mut chunk = [0_u8; 4096]; - let mut header_end = None; - let mut content_length = 0_usize; - - loop { - let read = stream - .read(&mut chunk) - .map_err(|error| format!("read mock rpc request: {error}"))?; - if read == 0 { - break; - } - buffer.extend_from_slice(&chunk[..read]); - if header_end.is_none() { - header_end = find_subslice(&buffer, b"\r\n\r\n").map(|index| index + 4); - if let Some(end) = header_end { - content_length = parse_content_length(&buffer[..end])?; - if buffer.len() >= end + content_length { - break; - } - } - } else if let Some(end) = header_end { - if buffer.len() >= end + content_length { - break; - } - } - } - - let end = header_end.ok_or_else(|| "mock rpc request did not include headers".to_owned())?; - let headers = std::str::from_utf8(&buffer[..end]) - .map_err(|error| format!("mock rpc headers were not utf-8: {error}"))?; - let auth_header = parse_header(headers, "authorization"); - let body = std::str::from_utf8(&buffer[end..end + content_length]) - .map_err(|error| format!("mock rpc body was not utf-8: {error}"))?; - let envelope: Value = - serde_json::from_str(body).map_err(|error| format!("parse mock rpc json body: {error}"))?; - let method = envelope["method"] - .as_str() - .ok_or_else(|| "mock rpc body did not include method".to_owned())? - .to_owned(); - - Ok(MockRpcRequest { - method, - auth_header, - body: envelope, - }) -} - -fn parse_content_length(headers: &[u8]) -> Result<usize, String> { - let text = std::str::from_utf8(headers) - .map_err(|error| format!("mock rpc header parse failed: {error}"))?; - for line in text.lines() { - if let Some((name, value)) = line.split_once(':') { - if name.trim().eq_ignore_ascii_case("content-length") { - return value - .trim() - .parse::<usize>() - .map_err(|error| format!("mock rpc content-length parse failed: {error}")); - } - } - } - Ok(0) -} - -fn parse_header(headers: &str, wanted: &str) -> Option<String> { - headers.lines().find_map(|line| { - let (name, value) = line.split_once(':')?; - if name.trim().eq_ignore_ascii_case(wanted) { - Some(value.trim().to_owned()) - } else { - None - } - }) -} - -fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> { - haystack - .windows(needle.len()) - .position(|window| window == needle) -} - -fn write_response(stream: &mut TcpStream, response: &MockRpcResponse) -> Result<(), String> { - let body = response.body.to_string(); - write!( - stream, - "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - response.status_code, - status_text(response.status_code), - body.len(), - body - ) - .map_err(|error| format!("write mock rpc response: {error}"))?; - stream - .flush() - .map_err(|error| format!("flush mock rpc response: {error}")) -} - -fn status_text(status_code: u16) -> &'static str { - match status_code { - 200 => "OK", - 401 => "Unauthorized", - 500 => "Internal Server Error", - _ => "OK", - } -} - -fn sample_bridge_status() -> Value { - json!({ - "enabled": true, - "ready": true, - "auth_mode": "bearer_token", - "signer_mode": "selectable_per_request", - "default_signer_mode": "embedded_service_identity", - "supported_signer_modes": ["embedded_service_identity", "nip46_session"], - "available_nip46_signer_sessions": 2, - "relay_count": 3, - "job_status_retention": 32, - "retained_jobs": 1, - "accepted_jobs": 4, - "published_jobs": 3, - "failed_jobs": 1, - "recovered_failed_jobs": 0, - "methods": ["bridge.status", "bridge.job.list", "bridge.job.status", "nip46.session.list"] - }) -} - -fn sample_signer_session() -> Value { - json!({ - "session_id": "sess_1", - "role": "outbound_remote_signer", - "client_pubkey": "client_pubkey", - "signer_pubkey": "myc_signer_pubkey", - "user_pubkey": "user_pubkey", - "relays": ["wss://relay.one"], - "permissions": ["sign_event", "nip44_encrypt"], - "auth_required": false, - "authorized": true, - "auth_url": null, - "expires_in_secs": 60, - "signer_authority": { - "provider_runtime_id": "myc", - "account_identity_id": "acct_user", - "provider_signer_session_id": "myc_conn_1" - } - }) -} - -fn sample_job(job_id: &str, state: &str, terminal: bool, completed_at_unix: Option<u64>) -> Value { - json!({ - "job_id": job_id, - "command": "bridge.listing.publish", - "status": state, - "terminal": terminal, - "recovered_after_restart": false, - "requested_at_unix": 1_712_720_000, - "completed_at_unix": completed_at_unix, - "signer_mode": "nip46_session", - "signer_session_id": "session-1", - "event_id": "event-123", - "event_addr": "30023:npub1seller:listing-123", - "delivery_policy": "best_effort", - "delivery_quorum": 2, - "relay_count": 3, - "acknowledged_relay_count": if terminal { 2 } else { 1 }, - "required_acknowledged_relay_count": 2, - "attempt_count": if terminal { 2 } else { 1 }, - "relay_outcome_summary": if terminal { "published to 2 relays" } else { "awaiting quorum" }, - "attempt_summaries": if terminal { - json!(["attempt 1: relay.one accepted", "attempt 2: relay.two accepted"]) - } else { - json!(["attempt 1: relay.one accepted"]) - } - }) -} - -#[test] -fn rpc_status_reports_bridge_ready_via_daemon_rpc() { - let _guard = job_rpc_test_guard(); - let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); - let recorded = Arc::clone(&requests); - let server = MockRpcServer::start(move |request| { - let method = request.method.clone(); - recorded.lock().expect("record requests").push(request); - match method.as_str() { - "bridge.status" => MockRpcResponse::success(sample_bridge_status()), - _ => MockRpcResponse::rpc_error(-32601, "method not found"), - } - }); - - let dir = tempdir().expect("tempdir"); - let output = job_rpc_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) - .env("RADROOTS_RPC_BEARER_TOKEN", "secret") - .args(["--json", "rpc", "status"]) - .output() - .expect("run rpc status"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - assert_eq!(json["state"], "ready"); - assert_eq!(json["url"], server.url()); - assert_eq!(json["bridge_ready"], true); - assert_eq!(json["retained_jobs"], 1); - assert_eq!(json["session_surface_enabled"], true); - - let recorded = requests.lock().expect("recorded requests"); - assert_eq!(recorded.len(), 1); - assert_eq!(recorded[0].method, "bridge.status"); - assert_eq!(recorded[0].auth_header.as_deref(), Some("Bearer secret")); -} - -#[test] -fn rpc_sessions_ndjson_emits_public_session_entries() { - let _guard = job_rpc_test_guard(); - let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); - let recorded = Arc::clone(&requests); - let server = MockRpcServer::start(move |request| { - let method = request.method.clone(); - recorded.lock().expect("record requests").push(request); - match method.as_str() { - "nip46.session.list" => MockRpcResponse::success(json!([ - { - "session_id": "session-1", - "role": "client", - "client_pubkey": "client-1", - "signer_pubkey": "signer-1", - "user_pubkey": "user-1", - "relays": ["wss://relay.one"], - "permissions": ["sign_event"], - "auth_required": false, - "authorized": true, - "expires_in_secs": 60 - }, - { - "session_id": "session-2", - "role": "admin", - "client_pubkey": "client-2", - "signer_pubkey": "signer-2", - "user_pubkey": null, - "relays": ["wss://relay.two", "wss://relay.three"], - "permissions": ["describe"], - "auth_required": true, - "authorized": false, - "expires_in_secs": null - } - ])), - _ => MockRpcResponse::rpc_error(-32601, "method not found"), - } - }); - - let dir = tempdir().expect("tempdir"); - let output = job_rpc_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) - .args(["--ndjson", "rpc", "sessions"]) - .output() - .expect("run rpc sessions"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let lines = stdout.lines().collect::<Vec<_>>(); - assert_eq!(lines.len(), 2); - assert!(lines[0].contains("\"session_id\":\"session-1\"")); - assert!(lines[1].contains("\"authorized\":false")); - - let recorded = requests.lock().expect("recorded requests"); - assert_eq!(recorded.len(), 1); - assert_eq!(recorded[0].method, "nip46.session.list"); - assert_eq!(recorded[0].auth_header, None); -} - -#[test] -fn signer_session_connect_preserves_configured_myc_authority() { - let _guard = job_rpc_test_guard(); - let dir = tempdir().expect("tempdir"); - - let (bunker_json, bunker_request) = run_signer_session_command( - dir.path(), - &[ - "--json", - "signer", - "session", - "connect-bunker", - "bunker://remote", - ], - |_| { - MockRpcResponse::success(json!({ - "session_id": "sess_bunker", - "mode": "Bunker", - "remote_signer_pubkey": "myc_signer_pubkey", - "client_pubkey": "client_pubkey", - "relays": ["wss://relay.one"] - })) - }, - ); - assert_eq!(bunker_json["action"], "connect_bunker"); - assert_eq!(bunker_json["session_id"], "sess_bunker"); - assert_eq!(bunker_json["remote_signer_pubkey"], "myc_signer_pubkey"); - - let nostr_dir = tempdir().expect("tempdir"); - let (nostr_json, nostr_request) = run_signer_session_command( - nostr_dir.path(), - &[ - "--json", - "signer", - "session", - "connect-nostrconnect", - "nostrconnect://remote", - "--client-secret-key", - "client-secret", - ], - |_| { - MockRpcResponse::success(json!({ - "session_id": "sess_nostrconnect", - "mode": "Nostrconnect", - "remote_signer_pubkey": "myc_signer_pubkey", - "client_pubkey": "client_pubkey", - "relays": ["wss://relay.two"] - })) - }, - ); - assert_eq!(nostr_json["action"], "connect_nostrconnect"); - assert_eq!(nostr_json["session_id"], "sess_nostrconnect"); - - for request in [&bunker_request, &nostr_request] { - assert_eq!(request.method, "nip46.connect"); - assert_eq!(request.auth_header.as_deref(), Some("Bearer secret")); - assert_eq!( - request.body["params"]["signer_authority"]["provider_runtime_id"], - "myc" - ); - assert_eq!( - request.body["params"]["signer_authority"]["account_identity_id"], - "acct_user" - ); - assert_eq!( - request.body["params"]["signer_authority"]["provider_signer_session_id"], - "myc_conn_1" - ); - } - assert_eq!(bunker_request.body["params"]["url"], "bunker://remote"); - assert_eq!(nostr_request.body["params"]["url"], "nostrconnect://remote"); - assert_eq!( - nostr_request.body["params"]["client_secret_key"], - "client-secret" - ); -} - -#[test] -fn signer_session_commands_cover_inspect_hydrate_authorize_require_auth_and_close() { - let _guard = job_rpc_test_guard(); - let dir = tempdir().expect("tempdir"); - let mut recorded = Vec::<MockRpcRequest>::new(); - - let (list_json, request) = - run_signer_session_command(dir.path(), &["--json", "signer", "session", "list"], |_| { - MockRpcResponse::success(json!([sample_signer_session()])) - }); - recorded.push(request); - assert_eq!(list_json["state"], "ready"); - assert_eq!(list_json["sessions"][0]["session_id"], "sess_1"); - assert_eq!(list_json["sessions"][0]["user_pubkey"], "user_pubkey"); - - let (show_json, request) = run_signer_session_command( - dir.path(), - &["--json", "signer", "session", "show", "sess_1"], - |_| MockRpcResponse::success(sample_signer_session()), - ); - recorded.push(request); - assert_eq!(show_json["action"], "show"); - assert_eq!(show_json["session_id"], "sess_1"); - assert_eq!(show_json["user_pubkey"], "user_pubkey"); - - let (public_key_json, request) = run_signer_session_command( - dir.path(), - &["--json", "signer", "session", "public-key", "sess_1"], - |_| MockRpcResponse::success(json!({ "pubkey": "user_pubkey" })), - ); - recorded.push(request); - assert_eq!(public_key_json["action"], "public_key"); - assert_eq!(public_key_json["pubkey"], "user_pubkey"); - - let (authorize_json, request) = run_signer_session_command( - dir.path(), - &["--json", "signer", "session", "authorize", "sess_1"], - |_| MockRpcResponse::success(json!({ "authorized": true, "replayed": false })), - ); - recorded.push(request); - assert_eq!(authorize_json["action"], "authorize"); - assert_eq!(authorize_json["authorized"], true); - assert_eq!(authorize_json["replayed"], false); - - let (require_auth_json, request) = run_signer_session_command( - dir.path(), - &[ - "--json", - "signer", - "session", - "require-auth", - "sess_1", - "--auth-url", - "https://auth.example", - ], - |_| MockRpcResponse::success(json!({ "required": true })), - ); - recorded.push(request); - assert_eq!(require_auth_json["action"], "require_auth"); - assert_eq!(require_auth_json["required"], true); - assert_eq!(require_auth_json["auth_url"], "https://auth.example"); - - let (close_json, request) = run_signer_session_command( - dir.path(), - &["--json", "signer", "session", "close", "sess_1"], - |_| MockRpcResponse::success(json!({ "closed": true })), - ); - recorded.push(request); - assert_eq!(close_json["action"], "close"); - assert_eq!(close_json["closed"], true); - - let methods = recorded - .iter() - .map(|request| request.method.as_str()) - .collect::<Vec<_>>(); - assert_eq!( - methods, - [ - "nip46.session.list", - "nip46.session.status", - "nip46.get_public_key", - "nip46.session.authorize", - "nip46.session.require_auth", - "nip46.session.close", - ] - ); - assert!( - recorded - .iter() - .all(|request| request.auth_header.as_deref() == Some("Bearer secret")) - ); - for request in recorded.iter().skip(1) { - assert_eq!(request.body["params"]["session_id"], "sess_1"); - } - assert_eq!( - recorded[4].body["params"]["auth_url"], - "https://auth.example" - ); -} - -#[test] -fn job_commands_require_bridge_bearer_token() { - let _guard = job_rpc_test_guard(); - let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); - let recorded = Arc::clone(&requests); - let server = MockRpcServer::start(move |request| { - recorded.lock().expect("record requests").push(request); - MockRpcResponse::rpc_error(-32601, "method not found") - }); - - let dir = tempdir().expect("tempdir"); - let output = job_rpc_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) - .args(["--json", "job", "ls"]) - .output() - .expect("run job ls"); - - assert_eq!(output.status.code(), Some(3)); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - assert_eq!(json["state"], "unconfigured"); - assert!( - json["reason"] - .as_str() - .expect("reason") - .contains("bridge bearer token is not configured") - ); - assert!(requests.lock().expect("recorded requests").is_empty()); -} - -#[test] -fn job_ls_and_get_report_retained_bridge_jobs() { - let _guard = job_rpc_test_guard(); - let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); - let recorded = Arc::clone(&requests); - let server = MockRpcServer::start(move |request| { - let method = request.method.clone(); - recorded.lock().expect("record requests").push(request); - match method.as_str() { - "bridge.job.list" => { - MockRpcResponse::success(json!([sample_job("job-123", "publishing", false, None)])) - } - "bridge.job.status" => MockRpcResponse::success(sample_job( - "job-123", - "published", - true, - Some(1_712_720_030), - )), - _ => MockRpcResponse::rpc_error(-32601, "method not found"), - } - }); - - let dir = tempdir().expect("tempdir"); - let list = job_rpc_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) - .env("RADROOTS_RPC_BEARER_TOKEN", "secret") - .args(["--json", "job", "ls"]) - .output() - .expect("run job ls"); - assert!(list.status.success()); - let list_json: Value = serde_json::from_slice(list.stdout.as_slice()).expect("list json"); - assert_eq!(list_json["state"], "ready"); - assert_eq!(list_json["count"], 1); - assert_eq!(list_json["jobs"][0]["id"], "job-123"); - assert_eq!(list_json["jobs"][0]["command"], "listing.publish"); - assert_eq!(list_json["jobs"][0]["signer"], "nip46_session"); - assert_eq!(list_json["jobs"][0]["signer_session_id"], "session-1"); - - let get = job_rpc_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) - .env("RADROOTS_RPC_BEARER_TOKEN", "secret") - .args(["--json", "job", "get", "job-123"]) - .output() - .expect("run job get"); - assert!(get.status.success()); - let get_json: Value = serde_json::from_slice(get.stdout.as_slice()).expect("get json"); - assert_eq!(get_json["state"], "ready"); - assert_eq!(get_json["job"]["id"], "job-123"); - assert_eq!(get_json["job"]["signer"], "nip46_session"); - assert_eq!(get_json["job"]["signer_session_id"], "session-1"); - assert_eq!( - get_json["job"]["relay_outcome_summary"], - "published to 2 relays" - ); - - let recorded = requests.lock().expect("recorded requests"); - assert_eq!(recorded.len(), 2); - assert!( - recorded - .iter() - .all(|request| request.auth_header.as_deref() == Some("Bearer secret")) - ); -} - -#[test] -fn job_watch_ndjson_emits_one_frame_per_poll_until_terminal() { - let _guard = job_rpc_test_guard(); - let sequence = Arc::new(Mutex::new(0_usize)); - let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); - let observed = Arc::clone(&requests); - let counter = Arc::clone(&sequence); - let server = MockRpcServer::start(move |request| { - let method = request.method.clone(); - observed.lock().expect("record requests").push(request); - match method.as_str() { - "bridge.job.status" => { - let mut count = counter.lock().expect("watch count"); - *count += 1; - if *count == 1 { - MockRpcResponse::success(sample_job("job-123", "publishing", false, None)) - } else { - MockRpcResponse::success(sample_job( - "job-123", - "published", - true, - Some(1_712_720_030), - )) - } - } - _ => MockRpcResponse::rpc_error(-32601, "method not found"), - } - }); - - let dir = tempdir().expect("tempdir"); - let output = job_rpc_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) - .env("RADROOTS_RPC_BEARER_TOKEN", "secret") - .args([ - "--ndjson", - "job", - "watch", - "job-123", - "--frames", - "3", - "--interval-ms", - "5", - ]) - .output() - .expect("run job watch"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let lines = stdout.lines().collect::<Vec<_>>(); - assert_eq!(lines.len(), 2); - assert!(lines[0].contains("\"sequence\":1")); - assert!(lines[0].contains("\"state\":\"publishing\"")); - assert!(lines[0].contains("\"signer\":\"nip46_session\"")); - assert!(lines[0].contains("\"signer_session_id\":\"session-1\"")); - assert!(lines[1].contains("\"sequence\":2")); - assert!(lines[1].contains("\"terminal\":true")); - assert!(lines[1].contains("\"signer_session_id\":\"session-1\"")); -} - -#[test] -fn job_watch_human_appends_snapshots_without_screen_clear() { - let _guard = job_rpc_test_guard(); - let sequence = Arc::new(Mutex::new(0_usize)); - let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); - let observed = Arc::clone(&requests); - let counter = Arc::clone(&sequence); - let server = MockRpcServer::start(move |request| { - let method = request.method.clone(); - observed.lock().expect("record requests").push(request); - match method.as_str() { - "bridge.job.status" => { - let mut count = counter.lock().expect("watch count"); - *count += 1; - if *count == 1 { - MockRpcResponse::success(sample_job("job-123", "publishing", false, None)) - } else { - MockRpcResponse::success(sample_job( - "job-123", - "published", - true, - Some(1_712_720_030), - )) - } - } - _ => MockRpcResponse::rpc_error(-32601, "method not found"), - } - }); - - let dir = tempdir().expect("tempdir"); - let output = job_rpc_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) - .env("RADROOTS_RPC_BEARER_TOKEN", "secret") - .args([ - "job", - "watch", - "job-123", - "--frames", - "3", - "--interval-ms", - "5", - ]) - .output() - .expect("run human job watch"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Watching job job-123")); - assert!(stdout.contains("Publishing")); - assert!(stdout.contains("Published")); - assert!(stdout.contains("Summary")); - assert!(stdout.contains("Signer")); - assert!(!stdout.contains("activity ยท")); - assert!(!stdout.contains("\u{1b}")); -} diff --git a/tests/listing.rs b/tests/listing.rs @@ -1,1860 +0,0 @@ -use std::fs; -use std::io::{Read, Write}; -use std::net::{TcpListener, TcpStream}; -use std::os::unix::fs::PermissionsExt; -use std::path::Path; -use std::process::Command; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; -use std::thread::{self, JoinHandle}; -use std::time::Duration; - -use assert_cmd::prelude::*; -use radroots_identity::RadrootsIdentity; -use radroots_sql_core::{SqlExecutor, SqliteExecutor}; -use serde_json::{Value, json}; -use tempfile::tempdir; - -const ADDRESS_BACKED_LISTING_ADDR: &str = - "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; - -fn data_root(workdir: &Path) -> std::path::PathBuf { - if cfg!(windows) { - workdir.join("local").join("Radroots").join("data") - } else { - workdir.join("home").join(".radroots").join("data") - } -} - -fn cli_command_in(workdir: &Path) -> Command { - let mut command = Command::cargo_bin("radroots").expect("binary"); - command.current_dir(workdir); - command.env("HOME", workdir.join("home")); - command.env("APPDATA", workdir.join("roaming")); - command.env("LOCALAPPDATA", workdir.join("local")); - for key in [ - "RADROOTS_ENV_FILE", - "RADROOTS_OUTPUT", - "RADROOTS_CLI_LOGGING_FILTER", - "RADROOTS_CLI_LOGGING_OUTPUT_DIR", - "RADROOTS_CLI_LOGGING_STDOUT", - "RADROOTS_CLI_PATHS_PROFILE", - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", - "RADROOTS_LOG_FILTER", - "RADROOTS_LOG_DIR", - "RADROOTS_LOG_STDOUT", - "RADROOTS_ACCOUNT", - "RADROOTS_ACCOUNT_SECRET_BACKEND", - "RADROOTS_ACCOUNT_SECRET_FALLBACK", - "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", - "RADROOTS_IDENTITY_PATH", - "RADROOTS_SIGNER", - "RADROOTS_RELAYS", - "RADROOTS_MYC_EXECUTABLE", - "RADROOTS_MYC_STATUS_TIMEOUT_MS", - "RADROOTS_RPC_URL", - "RADROOTS_RPC_BEARER_TOKEN", - ] { - command.env_remove(key); - } - command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); - command -} - -fn write_user_config(workdir: &Path, contents: &str) { - let config_dir = workdir.join("home/.radroots/config/apps/cli"); - fs::create_dir_all(&config_dir).expect("user config dir"); - fs::write(config_dir.join("config.toml"), contents).expect("write user config"); -} - -fn config_with_write_plane(extra: &str, url: &str) -> String { - let mut rendered = String::new(); - if !extra.trim().is_empty() { - rendered.push_str(extra.trim()); - rendered.push_str("\n\n"); - } - rendered.push_str( - format!( - r#"[[capability_binding]] -capability = "write_plane.trade_jsonrpc" -provider = "radrootsd" -target_kind = "explicit_endpoint" -target = "{url}" -"# - ) - .as_str(), - ); - rendered -} - -fn write_fake_myc(dir: &Path, script: &str) -> std::path::PathBuf { - let path = dir.join("fake-myc"); - fs::write(&path, script).expect("write fake myc"); - let mut permissions = fs::metadata(&path).expect("metadata").permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&path, permissions).expect("chmod fake myc"); - path -} - -fn successful_status_script(payload_json: String) -> String { - format!( - "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"signer\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\ncat <<'JSON'\n{payload_json}\nJSON\n" - ) -} - -fn sample_myc_status_payload( - account_id: &str, - public_identity: &Value, - connection_id: &str, -) -> Value { - let signer_identity = - serde_json::to_value(RadrootsIdentity::generate().to_public()).expect("signer identity"); - let signer_account_id = signer_identity["id"] - .as_str() - .expect("signer id") - .to_owned(); - assert_ne!(signer_account_id, account_id); - json!({ - "status_contract_version": 1, - "status": "healthy", - "ready": true, - "reasons": [], - "signer_backend": { - "local_signer": { - "account_id": signer_account_id, - "public_identity": signer_identity.clone(), - "availability": "SecretBacked" - }, - "remote_session_count": 1, - "remote_sessions": [ - { - "connection_id": connection_id, - "signer_identity": signer_identity, - "user_identity": public_identity, - "relays": ["wss://relay.one"], - "permissions": "sign_event" - } - ] - } - }) -} - -fn listing_test_guard() -> MutexGuard<'static, ()> { - static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - .lock() - .expect("listing test lock") -} - -#[test] -fn listing_new_uses_selected_farm_config_and_product_inputs() { - let _guard = listing_test_guard(); - let dir = tempdir().expect("tempdir"); - - let account_output = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - let account_json: Value = - serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); - let seller_pubkey = account_json["public_identity"]["public_key_hex"] - .as_str() - .expect("seller pubkey"); - let account_id = account_json["account"]["id"] - .as_str() - .expect("account id") - .to_owned(); - - let setup_output = cli_command_in(dir.path()) - .args([ - "--json", - "farm", - "setup", - "--name", - "La Huerta", - "--location", - "San Francisco, CA", - "--city", - "San Francisco", - "--region", - "CA", - "--country", - "US", - "--delivery-method", - "pickup", - ]) - .output() - .expect("run farm setup"); - assert!(setup_output.status.success()); - let setup_json: Value = - serde_json::from_slice(setup_output.stdout.as_slice()).expect("setup json"); - let farm_d_tag = setup_json["config"]["farm_d_tag"] - .as_str() - .expect("farm d_tag") - .to_owned(); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "listing", - "new", - "--key", - "sf-tomatoes", - "--title", - "San Francisco Early Girl Tomatoes", - "--category", - "produce.vegetables.tomatoes", - "--summary", - "Fresh local tomatoes packed for pickup from the seller's standard market location.", - "--quantity-amount", - "1000", - "--quantity-unit", - "g", - "--price-amount", - "0.01", - "--available", - "25", - "--label", - "1 kg tomato lot", - ]) - .output() - .expect("run listing new"); - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); - assert_eq!(json["state"], "draft created"); - assert_eq!(json["selected_account_id"], account_id); - assert_eq!(json["seller_pubkey"], seller_pubkey); - assert_eq!(json["farm_d_tag"], farm_d_tag); - assert_eq!(json["delivery_method"], "pickup"); - assert_eq!(json["location_primary"], "San Francisco, CA"); - let file = json["file"].as_str().expect("draft file"); - let contents = fs::read_to_string(file).expect("draft contents"); - assert!(contents.contains("kind = \"listing_draft_v1\"")); - assert!(contents.contains(&format!("seller_pubkey = \"{seller_pubkey}\""))); - assert!(contents.contains(&format!("farm_d_tag = \"{farm_d_tag}\""))); - assert!(contents.contains("key = \"sf-tomatoes\"")); - assert!(contents.contains("title = \"San Francisco Early Girl Tomatoes\"")); - assert!(contents.contains("category = \"produce.vegetables.tomatoes\"")); - assert!(contents.contains("method = \"pickup\"")); - assert!(contents.contains("primary = \"San Francisco, CA\"")); - assert!(contents.contains("price_currency = \"USD\"")); - assert!(contents.contains("price_per_amount = \"1\"")); - assert!(contents.contains("price_per_unit = \"g\"")); -} - -#[test] -fn listing_validate_resolves_selected_farm_config_defaults() { - let _guard = listing_test_guard(); - let dir = tempdir().expect("tempdir"); - - let account_output = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - let account_json: Value = - serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); - let seller_pubkey = account_json["public_identity"]["public_key_hex"] - .as_str() - .expect("seller pubkey") - .to_owned(); - let setup_output = cli_command_in(dir.path()) - .args([ - "--json", - "farm", - "setup", - "--name", - "La Huerta", - "--location", - "San Francisco, CA", - "--delivery-method", - "pickup", - ]) - .output() - .expect("run farm setup"); - assert!(setup_output.status.success()); - let setup_json: Value = - serde_json::from_slice(setup_output.stdout.as_slice()).expect("setup json"); - let farm_d_tag = setup_json["config"]["farm_d_tag"] - .as_str() - .expect("farm d_tag") - .to_owned(); - - let draft_path = dir.path().join("eggs.toml"); - fs::write( - &draft_path, - valid_listing_draft( - "AAAAAAAAAAAAAAAAAAAAAg", - "", - "", - "eggs", - "Pasture eggs", - "Protein", - "Fresh pasture-raised eggs collected daily.", - "12", - "each", - "4.50", - "USD", - "1", - "each", - "18", - "pickup", - "La Huerta del Sur", - ), - ) - .expect("write listing draft"); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "listing", - "validate", - draft_path.to_str().expect("draft path"), - ]) - .output() - .expect("run listing validate"); - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); - assert_eq!(json["state"], "valid"); - assert_eq!(json["valid"], true); - assert_eq!(json["seller_pubkey"], seller_pubkey); - assert_eq!(json["farm_d_tag"], farm_d_tag); -} - -#[test] -fn listing_validate_reports_invalid_drafts_with_field_lines() { - let _guard = listing_test_guard(); - let dir = tempdir().expect("tempdir"); - let draft_path = dir.path().join("invalid.toml"); - fs::write( - &draft_path, - valid_listing_draft( - "AAAAAAAAAAAAAAAAAAAAAg", - "AAAAAAAAAAAAAAAAAAAAAw", - &"b".repeat(64), - "eggs", - "Pasture eggs", - "Protein", - "Fresh pasture-raised eggs collected daily.", - "12", - "each", - "oops", - "USD", - "1", - "each", - "18", - "pickup", - "La Huerta del Sur", - ), - ) - .expect("write invalid draft"); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "listing", - "validate", - draft_path.to_str().expect("draft path"), - ]) - .output() - .expect("run listing validate"); - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); - assert_eq!(json["state"], "invalid"); - assert_eq!(json["valid"], false); - assert_eq!(json["issues"][0]["field"], "primary_bin.price_amount"); - assert!(json["issues"][0]["line"].as_u64().is_some()); -} - -#[test] -fn listing_get_reads_real_local_rows_and_reports_missing() { - let _guard = listing_test_guard(); - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - seed_trade_product( - dir.path(), - "00000000-0000-0000-0000-000000000301", - "pasture-eggs", - Some(ADDRESS_BACKED_LISTING_ADDR), - "protein", - "Pasture Eggs", - "Fresh pasture-raised eggs collected daily.", - 36, - 18, - Some("Marshall"), - ); - - let json_output = cli_command_in(dir.path()) - .args(["--json", "listing", "get", "pasture-eggs"]) - .output() - .expect("run listing get"); - assert!(json_output.status.success()); - let json: Value = serde_json::from_slice(json_output.stdout.as_slice()).expect("json"); - assert_eq!(json["state"], "ready"); - assert_eq!(json["product_key"], "pasture-eggs"); - assert_eq!(json["listing_addr"], ADDRESS_BACKED_LISTING_ADDR); - assert_eq!(json["title"], "Pasture Eggs"); - assert_eq!(json["location_primary"], "Marshall"); - assert_eq!(json["provenance"]["origin"], "local_replica.trade_product"); - - let human_output = cli_command_in(dir.path()) - .args(["listing", "get", "pasture-eggs"]) - .output() - .expect("run human listing get"); - assert!(human_output.status.success()); - let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Pasture Eggs")); - assert!(stdout.contains("Listing")); - assert!(stdout.contains("Key")); - assert!(stdout.contains("Place")); - assert!(stdout.contains("About")); - assert!(!stdout.contains("listing ยท")); - assert!(!stdout.contains("provenance:")); - - let missing_output = cli_command_in(dir.path()) - .args(["--json", "listing", "get", "missing-listing"]) - .output() - .expect("run missing listing get"); - assert!(missing_output.status.success()); - let missing_json: Value = - serde_json::from_slice(missing_output.stdout.as_slice()).expect("json"); - assert_eq!(missing_json["state"], "missing"); -} - -#[test] -fn listing_publish_and_update_use_durable_bridge_publish() { - let _guard = listing_test_guard(); - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - let account_output = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - let account_json: Value = - serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); - let account_id = account_json["account"]["id"] - .as_str() - .expect("account id") - .to_owned(); - let seller_pubkey = account_json["public_identity"]["public_key_hex"] - .as_str() - .expect("seller pubkey") - .to_owned(); - let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw"; - seed_farm(dir.path(), seller_pubkey.as_str(), farm_d_tag, "La Huerta"); - - let draft_path = dir.path().join("eggs.toml"); - fs::write( - &draft_path, - valid_listing_draft( - "AAAAAAAAAAAAAAAAAAAAAg", - "", - "", - "eggs", - "Pasture eggs", - "Protein", - "Fresh pasture-raised eggs collected daily.", - "12", - "each", - "4.50", - "USD", - "1", - "each", - "18", - "pickup", - "La Huerta del Sur", - ), - ) - .expect("write listing draft"); - - let requests = Arc::new(Mutex::new(Vec::<Value>::new())); - let recorded = Arc::clone(&requests); - let server = MockRpcServer::start(move |body, auth_header| { - recorded.lock().expect("recorded").push(body.clone()); - match body["method"].as_str().unwrap_or_default() { - "nip46.session.list" => { - assert_eq!(auth_header, None); - MockRpcResponse::success(json!([sample_session_with_authority( - "sess_publish_01", - seller_pubkey.as_str(), - &["sign_event"], - true, - Some(account_id.as_str()), - Some("conn_listing_binding_01") - )])) - } - "bridge.listing.publish" => { - assert_eq!(auth_header.as_deref(), Some("Bearer bridge-secret")); - MockRpcResponse::success(json!({ - "deduplicated": false, - "job": sample_listing_job( - "job_listing_01", - "published", - "event_listing_01", - "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg", - "sess_publish_01" - ) - })) - } - other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")), - } - }); - write_user_config( - dir.path(), - config_with_write_plane("", server.url().as_str()).as_str(), - ); - - let publish_output = cli_command_in(dir.path()) - .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") - .args([ - "--json", - "listing", - "publish", - "--idempotency-key", - "publish-key", - "--signer-session-id", - "sess_publish_01", - "--print-job", - "--print-event", - draft_path.to_str().expect("draft path"), - ]) - .output() - .expect("run listing publish"); - assert!(publish_output.status.success()); - let publish_json: Value = - serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json"); - assert_eq!(publish_json["operation"], "publish"); - assert_eq!(publish_json["job_id"], "job_listing_01"); - assert_eq!(publish_json["job_status"], "published"); - assert_eq!(publish_json["event_id"], "event_listing_01"); - assert_eq!(publish_json["event"]["kind"], 30402); - assert_eq!(publish_json["signer_mode"], "nip46_session"); - assert_eq!(publish_json["signer_session_id"], "sess_publish_01"); - assert_eq!(publish_json["job"]["rpc_method"], "bridge.listing.publish"); - assert_eq!(publish_json["job"]["signer_mode"], "nip46_session"); - assert_eq!(publish_json["job"]["signer_session_id"], "sess_publish_01"); - assert_eq!( - publish_json["requested_signer_session_id"], - "sess_publish_01" - ); - assert_eq!( - publish_json["job"]["requested_signer_session_id"], - "sess_publish_01" - ); - - let update_output = cli_command_in(dir.path()) - .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") - .args([ - "--json", - "listing", - "update", - draft_path.to_str().expect("draft path"), - ]) - .output() - .expect("run listing update"); - assert!(update_output.status.success()); - let update_json: Value = - serde_json::from_slice(update_output.stdout.as_slice()).expect("update json"); - assert_eq!(update_json["operation"], "update"); - - let recorded = requests.lock().expect("requests"); - assert_eq!(recorded.len(), 4); - assert_eq!(recorded[0]["method"], "nip46.session.list"); - assert_eq!(recorded[1]["params"]["kind"], 30402); - assert_eq!(recorded[1]["params"]["idempotency_key"], "publish-key"); - assert_eq!( - recorded[1]["params"]["signer_session_id"], - "sess_publish_01" - ); - assert_eq!(recorded[2]["method"], "nip46.session.list"); - assert_eq!(recorded[3]["params"]["kind"], 30402); - assert_eq!( - recorded[3]["params"]["signer_session_id"], - "sess_publish_01" - ); -} - -#[test] -fn listing_archive_and_dry_run_are_truthful() { - let _guard = listing_test_guard(); - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - let account_output = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - let account_json: Value = - serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); - let seller_pubkey = account_json["public_identity"]["public_key_hex"] - .as_str() - .expect("seller pubkey") - .to_owned(); - seed_farm( - dir.path(), - seller_pubkey.as_str(), - "AAAAAAAAAAAAAAAAAAAAAw", - "La Huerta", - ); - - let draft_path = dir.path().join("archive.toml"); - fs::write( - &draft_path, - valid_listing_draft( - "AAAAAAAAAAAAAAAAAAAAAg", - "", - "", - "eggs", - "Pasture eggs", - "Protein", - "Fresh pasture-raised eggs collected daily.", - "12", - "each", - "4.50", - "USD", - "1", - "each", - "18", - "pickup", - "La Huerta del Sur", - ), - ) - .expect("write listing draft"); - - let requests = Arc::new(Mutex::new(Vec::<String>::new())); - let recorded = Arc::clone(&requests); - let server = MockRpcServer::start(move |body, _auth_header| { - recorded.lock().expect("recorded").push(body.to_string()); - match body["method"].as_str().unwrap_or_default() { - "nip46.session.list" => MockRpcResponse::success(json!([sample_session( - "sess_archive_01", - seller_pubkey.as_str(), - &["sign_event"], - true - )])), - "bridge.listing.publish" => MockRpcResponse::success(json!({ - "deduplicated": false, - "job": sample_listing_job( - "job_listing_archive", - "published", - "event_listing_archive", - "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg", - "sess_archive_01" - ) - })), - other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")), - } - }); - write_user_config( - dir.path(), - config_with_write_plane("", server.url().as_str()).as_str(), - ); - - let archive_output = cli_command_in(dir.path()) - .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") - .args([ - "--json", - "listing", - "archive", - "--signer-session-id", - "sess_archive_01", - draft_path.to_str().expect("draft path"), - ]) - .output() - .expect("run listing archive"); - assert!(archive_output.status.success()); - let archive_json: Value = - serde_json::from_slice(archive_output.stdout.as_slice()).expect("archive json"); - assert_eq!(archive_json["operation"], "archive"); - assert_eq!(archive_json["job_status"], "published"); - assert_eq!(archive_json["signer_mode"], "nip46_session"); - assert_eq!(archive_json["signer_session_id"], "sess_archive_01"); - assert_eq!( - archive_json["requested_signer_session_id"], - "sess_archive_01" - ); - - let dry_run_output = cli_command_in(dir.path()) - .args([ - "--json", - "--dry-run", - "listing", - "publish", - "--signer-session-id", - "sess_dry_run_01", - "--print-event", - "--print-job", - draft_path.to_str().expect("draft path"), - ]) - .output() - .expect("run listing publish dry run"); - assert!(dry_run_output.status.success()); - let dry_run_json: Value = - serde_json::from_slice(dry_run_output.stdout.as_slice()).expect("dry run json"); - assert_eq!(dry_run_json["state"], "dry_run"); - assert_eq!(dry_run_json["dry_run"], true); - assert_eq!(dry_run_json["job"]["state"], "not_submitted"); - assert!(dry_run_json["signer_mode"].is_null()); - assert!(dry_run_json["signer_session_id"].is_null()); - assert_eq!(dry_run_json["job"]["signer_mode"], "local"); - assert!(dry_run_json["job"]["signer_session_id"].is_null()); - assert_eq!(dry_run_json["event"]["kind"], 30402); - assert!(dry_run_json["event"]["event_id"].is_null()); - assert_eq!( - dry_run_json["requested_signer_session_id"], - "sess_dry_run_01" - ); - assert_eq!( - dry_run_json["job"]["requested_signer_session_id"], - "sess_dry_run_01" - ); - - let recorded = requests.lock().expect("requests"); - assert_eq!(recorded.len(), 2); - assert!(recorded[1].contains("archived")); -} - -#[test] -fn listing_publish_uses_myc_binding_before_resolving_daemon_signer_session() { - let _guard = listing_test_guard(); - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - let account_output = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - let account_json: Value = - serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); - let account_id = account_json["account"]["id"] - .as_str() - .expect("account id") - .to_owned(); - let public_identity = account_json["public_identity"].clone(); - let seller_pubkey = public_identity["public_key_hex"] - .as_str() - .expect("seller pubkey") - .to_owned(); - seed_farm( - dir.path(), - seller_pubkey.as_str(), - "AAAAAAAAAAAAAAAAAAAAAw", - "La Huerta", - ); - - let draft_path = dir.path().join("myc-listing.toml"); - fs::write( - &draft_path, - valid_listing_draft( - "AAAAAAAAAAAAAAAAAAAAAg", - "AAAAAAAAAAAAAAAAAAAAAw", - seller_pubkey.as_str(), - "eggs", - "Pasture eggs", - "Protein", - "Fresh pasture-raised eggs collected daily.", - "12", - "each", - "4.50", - "USD", - "1", - "each", - "18", - "pickup", - "La Huerta del Sur", - ), - ) - .expect("write listing draft"); - - let myc = write_fake_myc( - dir.path(), - successful_status_script( - sample_myc_status_payload( - account_id.as_str(), - &public_identity, - "conn_listing_binding_01", - ) - .to_string(), - ) - .as_str(), - ); - - let requests = Arc::new(Mutex::new(Vec::<Value>::new())); - let recorded = Arc::clone(&requests); - let session_account_id = account_id.clone(); - let provider_pubkey = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - let server = MockRpcServer::start(move |body, auth_header| { - recorded.lock().expect("recorded").push(body.clone()); - match body["method"].as_str().unwrap_or_default() { - "nip46.session.list" => { - assert_eq!(auth_header, None); - let mut session = sample_session_with_authority( - "sess_publish_01", - provider_pubkey, - &["sign_event"], - true, - Some(session_account_id.as_str()), - Some("conn_listing_binding_01"), - ); - session["user_pubkey"] = Value::Null; - MockRpcResponse::success(json!([session])) - } - "nip46.get_public_key" => { - assert_eq!(auth_header.as_deref(), Some("Bearer bridge-secret")); - assert_eq!(body["params"]["session_id"], "sess_publish_01"); - MockRpcResponse::success(json!({ - "pubkey": seller_pubkey.as_str() - })) - } - "bridge.listing.publish" => { - assert_eq!(auth_header.as_deref(), Some("Bearer bridge-secret")); - MockRpcResponse::success(json!({ - "deduplicated": false, - "job": sample_listing_job( - "job_listing_02", - "published", - "event_listing_02", - "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg", - "sess_publish_01" - ) - })) - } - other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")), - } - }); - write_user_config( - dir.path(), - config_with_write_plane( - format!( - r#" -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "managed_instance" -target = "default" -managed_account_ref = "{account_id}" -"# - ) - .as_str(), - server.url().as_str(), - ) - .as_str(), - ); - - let output = cli_command_in(dir.path()) - .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") - .args([ - "--json", - "--signer", - "myc", - "--myc-executable", - myc.to_str().expect("myc path"), - "listing", - "publish", - draft_path.to_str().expect("draft path"), - ]) - .output() - .expect("run listing publish"); - - assert!( - output.status.success(), - "stdout:\n{}\n\nstderr:\n{}", - String::from_utf8_lossy(output.stdout.as_slice()), - String::from_utf8_lossy(output.stderr.as_slice()) - ); - let publish_json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); - assert_eq!(publish_json["state"], "published"); - assert_eq!(publish_json["signer_mode"], "nip46_session"); - assert_eq!(publish_json["signer_session_id"], "sess_publish_01"); - assert_eq!(publish_json["requested_signer_session_id"], Value::Null); - - let recorded = requests.lock().expect("requests"); - assert_eq!(recorded.len(), 3); - assert_eq!(recorded[0]["method"], "nip46.session.list"); - assert_eq!(recorded[1]["method"], "nip46.get_public_key"); - assert_eq!(recorded[2]["method"], "bridge.listing.publish"); - assert_eq!( - recorded[2]["params"]["signer_session_id"], - "sess_publish_01" - ); - assert_eq!( - recorded[2]["params"]["signer_authority"]["provider_runtime_id"], - "myc" - ); - assert_eq!( - recorded[2]["params"]["signer_authority"]["account_identity_id"], - account_id - ); - assert_eq!( - recorded[2]["params"]["signer_authority"]["provider_signer_session_id"], - "conn_listing_binding_01" - ); -} - -#[test] -fn listing_publish_rejects_myc_binding_that_resolves_the_wrong_actor() { - let _guard = listing_test_guard(); - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - let account_output = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - let account_json: Value = - serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); - let seller_pubkey = account_json["public_identity"]["public_key_hex"] - .as_str() - .expect("seller pubkey") - .to_owned(); - seed_farm( - dir.path(), - seller_pubkey.as_str(), - "AAAAAAAAAAAAAAAAAAAAAw", - "La Huerta", - ); - - let mismatch_account_output = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run mismatch account new"); - assert!(mismatch_account_output.status.success()); - let mismatch_account_json: Value = - serde_json::from_slice(mismatch_account_output.stdout.as_slice()).expect("mismatch json"); - let mismatch_account_id = mismatch_account_json["account"]["id"] - .as_str() - .expect("mismatch account id"); - let mismatch_public_identity = mismatch_account_json["public_identity"].clone(); - - let draft_path = dir.path().join("wrong-myc-listing.toml"); - fs::write( - &draft_path, - valid_listing_draft( - "AAAAAAAAAAAAAAAAAAAAAg", - "AAAAAAAAAAAAAAAAAAAAAw", - seller_pubkey.as_str(), - "eggs", - "Pasture eggs", - "Protein", - "Fresh pasture-raised eggs collected daily.", - "12", - "each", - "4.50", - "USD", - "1", - "each", - "18", - "pickup", - "La Huerta del Sur", - ), - ) - .expect("write listing draft"); - - let myc = write_fake_myc( - dir.path(), - successful_status_script( - sample_myc_status_payload( - mismatch_account_id, - &mismatch_public_identity, - "conn_listing_binding_02", - ) - .to_string(), - ) - .as_str(), - ); - - let requests = Arc::new(Mutex::new(Vec::<Value>::new())); - let recorded = Arc::clone(&requests); - let server = MockRpcServer::start(move |body, _auth_header| { - recorded.lock().expect("recorded").push(body.clone()); - MockRpcResponse::rpc_error(-32601, "daemon write path should not be reached") - }); - write_user_config( - dir.path(), - config_with_write_plane( - format!( - r#" -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "managed_instance" -target = "default" -managed_account_ref = "{mismatch_account_id}" -"# - ) - .as_str(), - server.url().as_str(), - ) - .as_str(), - ); - - let output = cli_command_in(dir.path()) - .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") - .args([ - "--json", - "--signer", - "myc", - "--myc-executable", - myc.to_str().expect("myc path"), - "listing", - "publish", - draft_path.to_str().expect("draft path"), - ]) - .output() - .expect("run listing publish"); - - assert_eq!(output.status.code(), Some(3)); - let publish_json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); - assert_eq!(publish_json["state"], "unconfigured"); - assert_eq!(publish_json["signer_mode"], "myc"); - assert!(publish_json["reason"].as_str().is_some_and(|value| { - value.contains("configured myc signer binding resolves user pubkey") - })); - assert!(requests.lock().expect("requests").is_empty()); -} - -#[test] -fn listing_publish_rejects_daemon_session_with_mismatched_myc_authority() { - let _guard = listing_test_guard(); - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - let account_output = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - let account_json: Value = - serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); - let account_id = account_json["account"]["id"] - .as_str() - .expect("account id") - .to_owned(); - let public_identity = account_json["public_identity"].clone(); - let seller_pubkey = public_identity["public_key_hex"] - .as_str() - .expect("seller pubkey") - .to_owned(); - seed_farm( - dir.path(), - seller_pubkey.as_str(), - "AAAAAAAAAAAAAAAAAAAAAw", - "La Huerta", - ); - - let draft_path = dir.path().join("mismatched-authority.toml"); - fs::write( - &draft_path, - valid_listing_draft( - "AAAAAAAAAAAAAAAAAAAAAg", - "", - "", - "eggs", - "Pasture eggs", - "Protein", - "Fresh pasture-raised eggs collected daily.", - "12", - "each", - "4.50", - "USD", - "1", - "each", - "18", - "pickup", - "La Huerta del Sur", - ), - ) - .expect("write listing draft"); - - let myc = write_fake_myc( - dir.path(), - successful_status_script( - sample_myc_status_payload( - account_id.as_str(), - &public_identity, - "conn_listing_binding_03", - ) - .to_string(), - ) - .as_str(), - ); - - let requests = Arc::new(Mutex::new(Vec::<Value>::new())); - let recorded = Arc::clone(&requests); - let server = MockRpcServer::start(move |body, _auth_header| { - recorded.lock().expect("recorded").push(body.clone()); - match body["method"].as_str().unwrap_or_default() { - "nip46.session.list" => { - MockRpcResponse::success(json!([sample_session_with_authority( - "sess_mismatch_01", - seller_pubkey.as_str(), - &["sign_event:30402"], - true, - Some("acct_wrong"), - Some("conn_listing_binding_03"), - )])) - } - _ => MockRpcResponse::rpc_error(-32601, "unexpected rpc method"), - } - }); - write_user_config( - dir.path(), - config_with_write_plane( - format!( - r#" -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "managed_instance" -target = "default" -managed_account_ref = "{account_id}" -"# - ) - .as_str(), - server.url().as_str(), - ) - .as_str(), - ); - - let output = cli_command_in(dir.path()) - .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") - .args([ - "--json", - "--signer", - "myc", - "--myc-executable", - myc.to_str().expect("myc path"), - "listing", - "publish", - draft_path.to_str().expect("draft path"), - ]) - .output() - .expect("run listing publish"); - - assert_eq!(output.status.code(), Some(3)); - let publish_json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); - assert_eq!(publish_json["state"], "unconfigured"); - assert!(publish_json["reason"].as_str().is_some()); - - let recorded = requests.lock().expect("requests"); - assert_eq!(recorded.len(), 1); - assert_eq!(recorded[0]["method"], "nip46.session.list"); -} - -#[test] -fn listing_publish_without_matching_signer_session_exits_unconfigured() { - let _guard = listing_test_guard(); - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - let account_output = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - let account_json: Value = - serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); - let seller_pubkey = account_json["public_identity"]["public_key_hex"] - .as_str() - .expect("seller pubkey") - .to_owned(); - seed_farm( - dir.path(), - seller_pubkey.as_str(), - "AAAAAAAAAAAAAAAAAAAAAw", - "La Huerta", - ); - - let draft_path = dir.path().join("no-session.toml"); - fs::write( - &draft_path, - valid_listing_draft( - "AAAAAAAAAAAAAAAAAAAAAg", - "", - "", - "eggs", - "Pasture eggs", - "Protein", - "Fresh pasture-raised eggs collected daily.", - "12", - "each", - "4.50", - "USD", - "1", - "each", - "18", - "pickup", - "La Huerta del Sur", - ), - ) - .expect("write listing draft"); - - let requests = Arc::new(Mutex::new(Vec::<Value>::new())); - let recorded = Arc::clone(&requests); - let server = MockRpcServer::start(move |body, _auth_header| { - recorded.lock().expect("recorded").push(body.clone()); - match body["method"].as_str().unwrap_or_default() { - "nip46.session.list" => MockRpcResponse::success(json!([sample_session( - "sess_other_01", - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - &["sign_event"], - true - )])), - other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")), - } - }); - write_user_config( - dir.path(), - config_with_write_plane("", server.url().as_str()).as_str(), - ); - - let publish_output = cli_command_in(dir.path()) - .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") - .args([ - "--json", - "listing", - "publish", - draft_path.to_str().expect("draft path"), - ]) - .output() - .expect("run listing publish"); - assert_eq!(publish_output.status.code(), Some(3)); - let publish_json: Value = - serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json"); - assert_eq!(publish_json["state"], "unconfigured"); - assert!( - publish_json["reason"] - .as_str() - .expect("reason") - .contains("no authorized signer session matched seller pubkey") - ); - - let recorded = requests.lock().expect("requests"); - assert_eq!(recorded.len(), 1); - assert_eq!(recorded[0]["method"], "nip46.session.list"); -} - -#[test] -fn listing_publish_rejects_requested_session_that_mismatches_seller_pubkey() { - let _guard = listing_test_guard(); - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - let account_output = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - let account_json: Value = - serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); - let seller_pubkey = account_json["public_identity"]["public_key_hex"] - .as_str() - .expect("seller pubkey") - .to_owned(); - seed_farm( - dir.path(), - seller_pubkey.as_str(), - "AAAAAAAAAAAAAAAAAAAAAw", - "La Huerta", - ); - - let draft_path = dir.path().join("mismatch-session.toml"); - fs::write( - &draft_path, - valid_listing_draft( - "AAAAAAAAAAAAAAAAAAAAAg", - "", - "", - "eggs", - "Pasture eggs", - "Protein", - "Fresh pasture-raised eggs collected daily.", - "12", - "each", - "4.50", - "USD", - "1", - "each", - "18", - "pickup", - "La Huerta del Sur", - ), - ) - .expect("write listing draft"); - - let requests = Arc::new(Mutex::new(Vec::<Value>::new())); - let recorded = Arc::clone(&requests); - let server = MockRpcServer::start(move |body, _auth_header| { - recorded.lock().expect("recorded").push(body.clone()); - match body["method"].as_str().unwrap_or_default() { - "nip46.session.list" => MockRpcResponse::success(json!([sample_session( - "sess_wrong_01", - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - &["sign_event"], - true - )])), - other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")), - } - }); - write_user_config( - dir.path(), - config_with_write_plane("", server.url().as_str()).as_str(), - ); - - let publish_output = cli_command_in(dir.path()) - .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") - .args([ - "--json", - "listing", - "publish", - "--signer-session-id", - "sess_wrong_01", - draft_path.to_str().expect("draft path"), - ]) - .output() - .expect("run listing publish"); - assert_eq!(publish_output.status.code(), Some(3)); - let publish_json: Value = - serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json"); - assert_eq!(publish_json["state"], "unconfigured"); - assert!( - publish_json["reason"] - .as_str() - .expect("reason") - .contains("does not match seller pubkey") - ); - - let recorded = requests.lock().expect("requests"); - assert_eq!(recorded.len(), 1); - assert_eq!(recorded[0]["method"], "nip46.session.list"); -} - -#[test] -fn listing_publish_requires_authoritative_write_plane_binding() { - let _guard = listing_test_guard(); - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - let account_output = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - let account_json: Value = - serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); - let seller_pubkey = account_json["public_identity"]["public_key_hex"] - .as_str() - .expect("seller pubkey") - .to_owned(); - seed_farm( - dir.path(), - seller_pubkey.as_str(), - "AAAAAAAAAAAAAAAAAAAAAw", - "La Huerta", - ); - - let draft_path = dir.path().join("missing-write-binding.toml"); - fs::write( - &draft_path, - valid_listing_draft( - "AAAAAAAAAAAAAAAAAAAAAg", - "", - "", - "eggs", - "Pasture eggs", - "Protein", - "Fresh pasture-raised eggs collected daily.", - "12", - "each", - "4.50", - "USD", - "1", - "each", - "18", - "pickup", - "La Huerta del Sur", - ), - ) - .expect("write listing draft"); - - let requests = Arc::new(Mutex::new(Vec::<Value>::new())); - let recorded = Arc::clone(&requests); - let server = MockRpcServer::start(move |body, _auth_header| { - recorded.lock().expect("recorded").push(body); - MockRpcResponse::rpc_error(-32601, "daemon write path should not be reached") - }); - - let publish_output = cli_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) - .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") - .args([ - "--json", - "listing", - "publish", - draft_path.to_str().expect("draft path"), - ]) - .output() - .expect("run listing publish"); - assert_eq!(publish_output.status.code(), Some(3)); - let publish_json: Value = - serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json"); - assert_eq!(publish_json["state"], "unconfigured"); - assert!( - publish_json["reason"].as_str().expect("reason").contains( - "explicit write-plane capability binding or managed radrootsd instance `local`" - ) - ); - assert!(requests.lock().expect("requests").is_empty()); -} - -fn seed_farm(workdir: &Path, pubkey: &str, d_tag: &str, name: &str) { - let replica_db = data_root(workdir).join("apps/cli/replica/replica.sqlite"); - let executor = SqliteExecutor::open(&replica_db).expect("open replica db"); - let now = "2026-04-07T00:00:00.000Z"; - executor - .exec( - "INSERT INTO farm (id, created_at, updated_at, d_tag, pubkey, name, about, website, picture, banner, location_primary, location_city, location_region, location_country) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", - json!([ - "11111111-1111-1111-1111-111111111111", - now, - now, - d_tag, - pubkey, - name, - Value::Null, - Value::Null, - Value::Null, - Value::Null, - Value::Null, - Value::Null, - Value::Null, - Value::Null - ]) - .to_string() - .as_str(), - ) - .expect("insert farm"); -} - -#[derive(Debug, Clone)] -struct MockRpcRequest { - body: Value, - auth_header: Option<String>, -} - -#[derive(Debug, Clone)] -struct MockRpcResponse { - body: Value, -} - -impl MockRpcResponse { - fn success(result: Value) -> Self { - Self { - body: json!({ - "jsonrpc": "2.0", - "id": 1, - "result": result, - }), - } - } - - fn rpc_error(code: i64, message: &str) -> Self { - Self { - body: json!({ - "jsonrpc": "2.0", - "id": 1, - "error": { - "code": code, - "message": message, - } - }), - } - } -} - -struct MockRpcServer { - address: String, - shutdown: Arc<AtomicBool>, - handle: Option<JoinHandle<()>>, -} - -impl MockRpcServer { - fn start<F>(handler: F) -> Self - where - F: Fn(Value, Option<String>) -> MockRpcResponse + Send + Sync + 'static, - { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock rpc listener"); - listener - .set_nonblocking(true) - .expect("set listener nonblocking"); - let address = listener.local_addr().expect("local addr").to_string(); - let shutdown = Arc::new(AtomicBool::new(false)); - let shutdown_flag = Arc::clone(&shutdown); - let handler: Arc<dyn Fn(Value, Option<String>) -> MockRpcResponse + Send + Sync> = - Arc::new(handler); - let handle = thread::spawn(move || { - while !shutdown_flag.load(Ordering::SeqCst) { - match listener.accept() { - Ok((mut stream, _)) => { - let _ = stream.set_nonblocking(false); - if let Ok(request) = read_request(&mut stream) { - let response = - handler(request.body.clone(), request.auth_header.clone()); - let _ = write_response(&mut stream, &response); - } - } - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(10)); - } - Err(_) => { - thread::sleep(Duration::from_millis(10)); - } - } - } - }); - Self { - address, - shutdown, - handle: Some(handle), - } - } - - fn url(&self) -> String { - format!("http://{}", self.address) - } -} - -impl Drop for MockRpcServer { - fn drop(&mut self) { - self.shutdown.store(true, Ordering::SeqCst); - let _ = TcpStream::connect(&self.address); - if let Some(handle) = self.handle.take() { - handle.join().expect("join mock rpc server"); - } - } -} - -fn read_request(stream: &mut TcpStream) -> Result<MockRpcRequest, String> { - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .map_err(|error| format!("set mock rpc read timeout: {error}"))?; - let mut buffer = Vec::new(); - let mut chunk = [0_u8; 4096]; - let mut header_end = None; - let mut content_length = 0usize; - - loop { - let read = stream - .read(&mut chunk) - .map_err(|error| format!("read mock rpc request: {error}"))?; - if read == 0 { - break; - } - buffer.extend_from_slice(&chunk[..read]); - if header_end.is_none() { - header_end = find_subslice(&buffer, b"\r\n\r\n").map(|index| index + 4); - if let Some(end) = header_end { - content_length = parse_content_length(&buffer[..end])?; - if buffer.len() >= end + content_length { - break; - } - } - } else if let Some(end) = header_end { - if buffer.len() >= end + content_length { - break; - } - } - } - - let end = header_end.ok_or_else(|| "mock rpc request missing headers".to_owned())?; - let headers = std::str::from_utf8(&buffer[..end]) - .map_err(|error| format!("mock rpc headers not utf-8: {error}"))?; - let auth_header = parse_header(headers, "authorization"); - let body = std::str::from_utf8(&buffer[end..end + content_length]) - .map_err(|error| format!("mock rpc body not utf-8: {error}"))?; - let json: Value = - serde_json::from_str(body).map_err(|error| format!("parse mock rpc body: {error}"))?; - - Ok(MockRpcRequest { - body: json, - auth_header, - }) -} - -fn parse_content_length(headers: &[u8]) -> Result<usize, String> { - let text = std::str::from_utf8(headers) - .map_err(|error| format!("header utf-8 parse failed: {error}"))?; - for line in text.lines() { - if let Some((name, value)) = line.split_once(':') { - if name.trim().eq_ignore_ascii_case("content-length") { - return value - .trim() - .parse::<usize>() - .map_err(|error| format!("content-length parse failed: {error}")); - } - } - } - Ok(0) -} - -fn parse_header(headers: &str, wanted: &str) -> Option<String> { - headers.lines().find_map(|line| { - let (name, value) = line.split_once(':')?; - if name.trim().eq_ignore_ascii_case(wanted) { - Some(value.trim().to_owned()) - } else { - None - } - }) -} - -fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> { - haystack - .windows(needle.len()) - .position(|window| window == needle) -} - -fn write_response(stream: &mut TcpStream, response: &MockRpcResponse) -> Result<(), String> { - let body = response.body.to_string(); - write!( - stream, - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ) - .map_err(|error| format!("write mock rpc response: {error}"))?; - stream - .flush() - .map_err(|error| format!("flush mock rpc response: {error}")) -} - -fn sample_listing_job( - job_id: &str, - status: &str, - event_id: &str, - event_addr: &str, - signer_session_id: &str, -) -> Value { - json!({ - "job_id": job_id, - "command": "bridge.listing.publish", - "idempotency_key": "publish-key", - "status": status, - "terminal": status != "accepted", - "recovered_after_restart": false, - "requested_at_unix": 1_712_720_000, - "completed_at_unix": 1_712_720_010, - "signer_mode": "nip46_session", - "signer_session_id": signer_session_id, - "event_kind": 30402, - "event_id": event_id, - "event_addr": event_addr, - "delivery_policy": "best_effort", - "delivery_quorum": 2, - "relay_count": 2, - "acknowledged_relay_count": 2, - "required_acknowledged_relay_count": 2, - "attempt_count": 1, - "attempt_summaries": ["attempt 1: relay.one accepted"], - "relay_results": [], - "relay_outcome_summary": "published to 2 relays" - }) -} - -fn sample_session( - session_id: &str, - signer_pubkey: &str, - permissions: &[&str], - authorized: bool, -) -> Value { - sample_session_with_authority( - session_id, - signer_pubkey, - permissions, - authorized, - None, - None, - ) -} - -fn sample_session_with_authority( - session_id: &str, - signer_pubkey: &str, - permissions: &[&str], - authorized: bool, - account_identity_id: Option<&str>, - provider_signer_session_id: Option<&str>, -) -> Value { - json!({ - "session_id": session_id, - "role": "remote_signer", - "client_pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "signer_pubkey": signer_pubkey, - "user_pubkey": signer_pubkey, - "relays": ["wss://relay.one"], - "permissions": permissions, - "auth_required": false, - "authorized": authorized, - "expires_in_secs": Value::Null, - "signer_authority": account_identity_id.map(|account_identity_id| json!({ - "provider_runtime_id": "myc", - "account_identity_id": account_identity_id, - "provider_signer_session_id": provider_signer_session_id - })) - }) -} - -fn seed_trade_product( - workdir: &Path, - product_id: &str, - key: &str, - listing_addr: Option<&str>, - category: &str, - title: &str, - summary: &str, - qty_amt: i64, - qty_avail: i64, - location_label: Option<&str>, -) { - let replica_db = data_root(workdir).join("apps/cli/replica/replica.sqlite"); - let executor = SqliteExecutor::open(&replica_db).expect("open replica db"); - let now = "2026-04-07T00:00:00.000Z"; - executor - .exec( - "INSERT INTO trade_product (id, created_at, updated_at, key, listing_addr, category, title, summary, process, lot, profile, year, qty_amt, qty_unit, qty_label, qty_avail, price_amt, price_currency, price_qty_amt, price_qty_unit, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", - json!([ - product_id, - now, - now, - key, - listing_addr, - category, - title, - summary, - "fresh", - "lot-a", - "standard", - 2026, - qty_amt, - "each", - "dozen", - qty_avail, - 4.5, - "USD", - 1, - "each", - Value::Null - ]) - .to_string() - .as_str(), - ) - .expect("insert trade product"); - - if let Some(location_label) = location_label { - let location_id = format!("11111111-1111-1111-1111-{}", &product_id[24..]); - executor - .exec( - "INSERT INTO gcs_location (id, created_at, updated_at, d_tag, lat, lng, geohash, point, polygon, accuracy, altitude, tag_0, label, area, elevation, soil, climate, gc_id, gc_name, gc_admin1_id, gc_admin1_name, gc_country_id, gc_country_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", - json!([ - location_id, - now, - now, - format!("location-{product_id}"), - 35.0, - -82.0, - "dnrj", - "POINT(-82 35)", - "POLYGON EMPTY", - Value::Null, - Value::Null, - Value::Null, - location_label, - Value::Null, - Value::Null, - Value::Null, - Value::Null, - Value::Null, - location_label, - Value::Null, - Value::Null, - Value::Null, - "USA" - ]) - .to_string() - .as_str(), - ) - .expect("insert gcs location"); - executor - .exec( - "INSERT INTO trade_product_location (tb_tp, tb_gl) VALUES (?, ?);", - json!([product_id, location_id]).to_string().as_str(), - ) - .expect("insert trade product location"); - } -} - -fn valid_listing_draft( - d_tag: &str, - farm_d_tag: &str, - seller_pubkey: &str, - key: &str, - title: &str, - category: &str, - summary: &str, - quantity_amount: &str, - quantity_unit: &str, - price_amount: &str, - price_currency: &str, - price_per_amount: &str, - price_per_unit: &str, - available: &str, - delivery_method: &str, - location_primary: &str, -) -> String { - format!( - "version = 1\nkind = \"listing_draft_v1\"\n\n[listing]\nd_tag = \"{d_tag}\"\nfarm_d_tag = \"{farm_d_tag}\"\nseller_pubkey = \"{seller_pubkey}\"\n\n[product]\nkey = \"{key}\"\ntitle = \"{title}\"\ncategory = \"{category}\"\nsummary = \"{summary}\"\n\n[primary_bin]\nbin_id = \"bin-1\"\nquantity_amount = \"{quantity_amount}\"\nquantity_unit = \"{quantity_unit}\"\nprice_amount = \"{price_amount}\"\nprice_currency = \"{price_currency}\"\nprice_per_amount = \"{price_per_amount}\"\nprice_per_unit = \"{price_per_unit}\"\nlabel = \"dozen\"\n\n[inventory]\navailable = \"{available}\"\n\n[availability]\nkind = \"status\"\nstatus = \"active\"\n\n[delivery]\nmethod = \"{delivery_method}\"\n\n[location]\nprimary = \"{location_primary}\"\n" - ) -} diff --git a/tests/local.rs b/tests/local.rs @@ -1,164 +0,0 @@ -use std::fs; -use std::path::Path; -use std::process::Command; - -use assert_cmd::prelude::*; -use serde_json::Value; -use tempfile::tempdir; - -fn data_root(workdir: &Path) -> std::path::PathBuf { - if cfg!(windows) { - workdir.join("local").join("Radroots").join("data") - } else { - workdir.join("home").join(".radroots").join("data") - } -} - -fn local_command_in(workdir: &Path) -> Command { - let mut command = Command::cargo_bin("radroots").expect("binary"); - command.current_dir(workdir); - command.env("HOME", workdir.join("home")); - command.env("APPDATA", workdir.join("roaming")); - command.env("LOCALAPPDATA", workdir.join("local")); - for key in [ - "RADROOTS_ENV_FILE", - "RADROOTS_OUTPUT", - "RADROOTS_CLI_LOGGING_FILTER", - "RADROOTS_CLI_LOGGING_OUTPUT_DIR", - "RADROOTS_CLI_LOGGING_STDOUT", - "RADROOTS_CLI_PATHS_PROFILE", - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", - "RADROOTS_LOG_FILTER", - "RADROOTS_LOG_DIR", - "RADROOTS_LOG_STDOUT", - "RADROOTS_ACCOUNT", - "RADROOTS_ACCOUNT_SECRET_BACKEND", - "RADROOTS_ACCOUNT_SECRET_FALLBACK", - "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", - "RADROOTS_IDENTITY_PATH", - "RADROOTS_SIGNER", - "RADROOTS_RELAYS", - "RADROOTS_MYC_EXECUTABLE", - "RADROOTS_MYC_STATUS_TIMEOUT_MS", - "RADROOTS_RPC_URL", - "RADROOTS_RPC_BEARER_TOKEN", - ] { - command.env_remove(key); - } - command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); - command -} - -#[test] -fn local_init_json_creates_replica_db_and_roots() { - let dir = tempdir().expect("tempdir"); - let output = local_command_in(dir.path()) - .args(["--json", "local", "init"]) - .output() - .expect("run local init"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - assert_eq!(json["state"], "initialized"); - assert_eq!(json["replica_db"], "ready"); - - let replica_db = data_root(dir.path()).join("apps/cli/replica/replica.sqlite"); - assert!(replica_db.exists()); - assert!( - data_root(dir.path()) - .join("apps/cli/replica/backups") - .exists() - ); - assert!( - data_root(dir.path()) - .join("apps/cli/replica/exports") - .exists() - ); -} - -#[test] -fn local_status_reports_unconfigured_when_replica_is_missing() { - let dir = tempdir().expect("tempdir"); - let output = local_command_in(dir.path()) - .args(["--json", "local", "status"]) - .output() - .expect("run local status"); - - assert_eq!(output.status.code(), Some(3)); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - assert_eq!(json["state"], "unconfigured"); - assert_eq!(json["replica_db"], "missing"); - assert_eq!(json["actions"][0], "radroots local init"); -} - -#[test] -fn local_status_reports_real_replica_metadata_after_init() { - let dir = tempdir().expect("tempdir"); - let init = local_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - let output = local_command_in(dir.path()) - .args(["--json", "local", "status"]) - .output() - .expect("run local status"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - assert_eq!(json["state"], "ready"); - assert_eq!(json["counts"]["farms"], 0); - assert_eq!(json["counts"]["listings"], 0); - assert_eq!(json["sync"]["expected_count"], 0); - assert_eq!(json["sync"]["pending_count"], 0); -} - -#[test] -fn local_backup_and_export_write_files() { - let dir = tempdir().expect("tempdir"); - let init = local_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - let backup_path = dir.path().join("backup").join("local.radb"); - let backup = local_command_in(dir.path()) - .args([ - "--json", - "local", - "backup", - "--output", - backup_path.to_str().expect("backup path"), - ]) - .output() - .expect("run local backup"); - assert!(backup.status.success()); - let backup_json: Value = serde_json::from_slice(backup.stdout.as_slice()).expect("json"); - assert_eq!(backup_json["state"], "backup created"); - assert!(backup_path.exists()); - assert!(fs::metadata(&backup_path).expect("backup metadata").len() > 0); - - let export_path = dir.path().join("export").join("local.ndjson"); - let export = local_command_in(dir.path()) - .args([ - "--json", - "local", - "export", - "--format", - "ndjson", - "--output", - export_path.to_str().expect("export path"), - ]) - .output() - .expect("run local export"); - assert!(export.status.success()); - let export_json: Value = serde_json::from_slice(export.stdout.as_slice()).expect("json"); - assert_eq!(export_json["state"], "exported"); - assert_eq!(export_json["format"], "ndjson"); - let export_raw = fs::read_to_string(&export_path).expect("read export"); - let lines = export_raw.lines().collect::<Vec<_>>(); - assert!(lines.len() >= 3); - assert!(lines[0].contains("\"kind\":\"local_export_manifest\"")); -} diff --git a/tests/market.rs b/tests/market.rs @@ -1,437 +0,0 @@ -use std::fs; -use std::path::Path; -use std::process::Command; - -use assert_cmd::prelude::*; -use radroots_sql_core::{SqlExecutor, SqliteExecutor}; -use serde_json::{Value, json}; -use tempfile::tempdir; - -const ADDRESS_BACKED_LISTING_ADDR: &str = - "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; - -fn data_root(workdir: &Path) -> std::path::PathBuf { - if cfg!(windows) { - workdir.join("local").join("Radroots").join("data") - } else { - workdir.join("home").join(".radroots").join("data") - } -} - -fn cli_command_in(workdir: &Path) -> Command { - let mut command = Command::cargo_bin("radroots").expect("binary"); - command.current_dir(workdir); - command.env("HOME", workdir.join("home")); - command.env("APPDATA", workdir.join("roaming")); - command.env("LOCALAPPDATA", workdir.join("local")); - for key in [ - "RADROOTS_ENV_FILE", - "RADROOTS_OUTPUT", - "RADROOTS_CLI_LOGGING_FILTER", - "RADROOTS_CLI_LOGGING_OUTPUT_DIR", - "RADROOTS_CLI_LOGGING_STDOUT", - "RADROOTS_CLI_PATHS_PROFILE", - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", - "RADROOTS_LOG_FILTER", - "RADROOTS_LOG_DIR", - "RADROOTS_LOG_STDOUT", - "RADROOTS_ACCOUNT", - "RADROOTS_ACCOUNT_SECRET_BACKEND", - "RADROOTS_ACCOUNT_SECRET_FALLBACK", - "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", - "RADROOTS_HYF_ENABLED", - "RADROOTS_HYF_EXECUTABLE", - "RADROOTS_IDENTITY_PATH", - "RADROOTS_SIGNER", - "RADROOTS_RELAYS", - "RADROOTS_MYC_EXECUTABLE", - "RADROOTS_MYC_STATUS_TIMEOUT_MS", - "RADROOTS_RPC_URL", - "RADROOTS_RPC_BEARER_TOKEN", - ] { - command.env_remove(key); - } - command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); - command -} - -fn seed_trade_product( - workdir: &Path, - product_id: &str, - key: &str, - listing_addr: Option<&str>, - category: &str, - title: &str, - summary: &str, - qty_amt: i64, - qty_avail: i64, - location_label: Option<&str>, -) { - let replica_db = data_root(workdir).join("apps/cli/replica/replica.sqlite"); - let executor = SqliteExecutor::open(&replica_db).expect("open replica db"); - let now = "2026-04-07T00:00:00.000Z"; - executor - .exec( - "INSERT INTO trade_product (id, created_at, updated_at, key, listing_addr, category, title, summary, process, lot, profile, year, qty_amt, qty_unit, qty_label, qty_avail, price_amt, price_currency, price_qty_amt, price_qty_unit, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", - json!([ - product_id, - now, - now, - key, - listing_addr, - category, - title, - summary, - "fresh", - "lot-a", - "standard", - 2026, - qty_amt, - "kg", - "1 kg tomato lot", - qty_avail, - 10.0, - "USD", - 1, - "kg", - Value::Null - ]) - .to_string() - .as_str(), - ) - .expect("insert trade product"); - - if let Some(location_label) = location_label { - let location_id = format!("11111111-1111-1111-1111-{}", &product_id[24..]); - executor - .exec( - "INSERT INTO gcs_location (id, created_at, updated_at, d_tag, lat, lng, geohash, point, polygon, accuracy, altitude, tag_0, label, area, elevation, soil, climate, gc_id, gc_name, gc_admin1_id, gc_admin1_name, gc_country_id, gc_country_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", - json!([ - location_id, - now, - now, - format!("location-{product_id}"), - 35.0, - -82.0, - "dnrj", - "POINT(-82 35)", - "POLYGON EMPTY", - Value::Null, - Value::Null, - Value::Null, - location_label, - Value::Null, - Value::Null, - Value::Null, - Value::Null, - Value::Null, - location_label, - Value::Null, - Value::Null, - Value::Null, - "USA" - ]) - .to_string() - .as_str(), - ) - .expect("insert gcs location"); - executor - .exec( - "INSERT INTO trade_product_location (tb_tp, tb_gl) VALUES (?, ?);", - json!([product_id, location_id]).to_string().as_str(), - ) - .expect("insert trade product location"); - } -} - -fn write_fake_hyfd( - workdir: &Path, - status_response: &str, - rewrite_response: &str, -) -> std::path::PathBuf { - let path = workdir.join("fake-hyfd"); - let script = format!( - "#!/bin/sh\nread -r request || exit 64\ncase \"$request\" in\n *'\"capability\":\"sys.status\"'*)\n cat <<'JSON'\n{status_response}\nJSON\n ;;\n *'\"capability\":\"query_rewrite\"'*)\n cat <<'JSON'\n{rewrite_response}\nJSON\n ;;\n *)\n cat <<'JSON'\n{{\"version\":1,\"request_id\":\"unexpected\",\"ok\":false,\"error\":{{\"code\":\"unsupported_capability\",\"message\":\"unexpected request\"}}}}\nJSON\n ;;\nesac\n" - ); - fs::write(&path, script).expect("write fake hyfd"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut permissions = fs::metadata(&path).expect("metadata").permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&path, permissions).expect("chmod fake hyfd"); - } - path -} - -#[test] -fn market_update_reports_missing_local_data_and_relay_setup() { - let dir = tempdir().expect("tempdir"); - let output = cli_command_in(dir.path()) - .args(["market", "update"]) - .output() - .expect("run market update"); - - assert_eq!(output.status.code(), Some(3)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Not ready yet")); - assert!(stdout.contains("Missing")); - assert!(stdout.contains("Local market data")); - assert!(stdout.contains("Relay configuration")); - assert!(stdout.contains("radroots local init")); - assert!(stdout.contains("radroots relay list --relay wss://relay.example.com")); -} - -#[test] -fn market_update_stays_honest_about_unavailable_ingest() { - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - let config_dir = dir.path().join("home/.radroots/config/apps/cli"); - fs::create_dir_all(&config_dir).expect("user config dir"); - fs::write( - config_dir.join("config.toml"), - "[relay]\nurls = [\"wss://relay.one\"]\npublish_policy = \"any\"\n", - ) - .expect("write user config"); - - let json_output = cli_command_in(dir.path()) - .args(["--json", "market", "update"]) - .output() - .expect("run market update json"); - assert_eq!(json_output.status.code(), Some(4)); - let json: Value = serde_json::from_slice(json_output.stdout.as_slice()).expect("json"); - assert_eq!(json["direction"], "pull"); - assert_eq!(json["state"], "unavailable"); - assert_eq!(json["relay_count"], 1); - assert_eq!(json["actions"][0], "radroots rpc status"); - assert_eq!(json["actions"][1], "radroots runtime status radrootsd"); - assert_eq!(json["actions"][2], "radroots sync status"); - assert!( - json["reason"] - .as_str() - .is_some_and(|reason| reason.contains("relay ingest")) - ); - - let human_output = cli_command_in(dir.path()) - .args(["market", "update"]) - .output() - .expect("run market update human"); - assert_eq!(human_output.status.code(), Some(4)); - let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Unavailable right now")); - assert!(stdout.contains("relay ingest is not wired into `radroots sync pull` yet")); - assert!(stdout.contains("Next")); - assert!(stdout.contains("radroots rpc status")); -} - -#[test] -fn market_search_preserves_machine_shape_and_renders_card_list() { - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - seed_trade_product( - dir.path(), - "00000000-0000-0000-0000-000000000401", - "sf-tomatoes", - Some(ADDRESS_BACKED_LISTING_ADDR), - "produce", - "San Francisco Early Girl Tomatoes", - "Fresh local tomatoes packed for pickup from the farm.", - 18, - 12, - Some("San Francisco, CA"), - ); - - let json_output = cli_command_in(dir.path()) - .args(["--json", "market", "search", "tomatoes"]) - .output() - .expect("run market search json"); - assert!(json_output.status.success()); - let json: Value = serde_json::from_slice(json_output.stdout.as_slice()).expect("json"); - assert_eq!(json["state"], "ready"); - assert_eq!(json["count"], 1); - assert_eq!(json["results"][0]["product_key"], "sf-tomatoes"); - assert_eq!( - json["results"][0]["listing_addr"], - ADDRESS_BACKED_LISTING_ADDR - ); - assert_eq!( - json["results"][0]["title"], - "San Francisco Early Girl Tomatoes" - ); - assert_eq!(json["results"][0]["location_primary"], "San Francisco, CA"); - assert_eq!(json["actions"][0], "radroots market view sf-tomatoes"); - assert_eq!( - json["actions"][1], - "radroots order create --listing sf-tomatoes" - ); - - let ndjson_output = cli_command_in(dir.path()) - .args(["--ndjson", "market", "search", "tomatoes"]) - .output() - .expect("run market search ndjson"); - assert!(ndjson_output.status.success()); - let stdout = String::from_utf8(ndjson_output.stdout).expect("utf8 stdout"); - let lines = stdout.lines().collect::<Vec<_>>(); - assert_eq!(lines.len(), 1); - assert!(lines[0].contains("\"product_key\":\"sf-tomatoes\"")); - assert!(lines[0].contains("\"title\":\"San Francisco Early Girl Tomatoes\"")); - - let human_output = cli_command_in(dir.path()) - .args(["market", "search", "tomatoes"]) - .output() - .expect("run market search human"); - assert!(human_output.status.success()); - let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("1 listing for tomatoes")); - assert!(stdout.contains("San Francisco Early Girl Tomatoes")); - assert!(stdout.contains("Key")); - assert!(stdout.contains("Place")); - assert!(stdout.contains("Offer")); - assert!(stdout.contains("Next")); - assert!(stdout.contains("radroots market view sf-tomatoes")); - assert!(stdout.contains("radroots order create --listing sf-tomatoes")); - assert!(!stdout.contains("market ยท local first")); - - let quiet_output = cli_command_in(dir.path()) - .args(["--quiet", "market", "search", "tomatoes"]) - .output() - .expect("run quiet market search"); - assert!(quiet_output.status.success()); - let quiet_stdout = String::from_utf8(quiet_output.stdout).expect("utf8 stdout"); - assert_eq!(quiet_stdout.trim(), "sf-tomatoes"); -} - -#[test] -fn market_search_uses_also_searched_for_when_hyf_rewrites_query() { - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - seed_trade_product( - dir.path(), - "00000000-0000-0000-0000-000000000402", - "fresh-eggs", - None, - "protein", - "Fresh Eggs", - "Pasture-raised eggs", - 36, - 24, - Some("Marshall"), - ); - - let hyfd = write_fake_hyfd( - dir.path(), - r#"{"version":1,"request_id":"cli-doctor-hyf-status","trace_id":"cli-doctor-hyf-status","ok":true,"output":{"build_identity":{"protocol_version":1},"enabled_execution_modes":{"deterministic":true}}}"#, - r#"{"version":1,"request_id":"cli-find-query-rewrite","trace_id":"cli-find-query-rewrite","ok":true,"output":{"original_text":"henhouse","normalized_text":"henhouse","rewritten_text":"eggs","query_terms":["eggs"],"normalization_signals":["query_rewrite"],"ranking_hints":["local_first"],"extracted_filters":{"local_intent":false,"fulfillment":"any","time_window":"any"}}}"#, - ); - - let json_output = cli_command_in(dir.path()) - .env("RADROOTS_HYF_ENABLED", "true") - .env("RADROOTS_HYF_EXECUTABLE", &hyfd) - .args(["--json", "market", "search", "henhouse"]) - .output() - .expect("run market search json"); - assert!(json_output.status.success()); - let json: Value = serde_json::from_slice(json_output.stdout.as_slice()).expect("json"); - assert_eq!(json["state"], "ready"); - assert_eq!(json["hyf"]["state"], "query_rewrite_applied"); - assert_eq!(json["hyf"]["rewritten_query"], "eggs"); - assert_eq!(json["actions"][0], "radroots market view fresh-eggs"); - assert_eq!(json["actions"].as_array().expect("actions").len(), 1); - - let human_output = cli_command_in(dir.path()) - .env("RADROOTS_HYF_ENABLED", "true") - .env("RADROOTS_HYF_EXECUTABLE", &hyfd) - .args(["market", "search", "henhouse"]) - .output() - .expect("run market search human"); - assert!(human_output.status.success()); - let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("1 listing for eggs")); - assert!(stdout.contains("Also searched for")); - assert!(stdout.contains("henhouse")); - assert!(stdout.contains("radroots market view fresh-eggs")); - assert!(!stdout.contains("radroots order create --listing fresh-eggs")); - assert!(!stdout.contains("hyf: query rewritten")); -} - -#[test] -fn market_view_wraps_listing_reads_and_guides_to_order_create() { - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - seed_trade_product( - dir.path(), - "00000000-0000-0000-0000-000000000403", - "pasture-eggs", - Some(ADDRESS_BACKED_LISTING_ADDR), - "protein", - "Pasture Eggs", - "Fresh pasture-raised eggs collected daily.", - 36, - 18, - Some("Marshall"), - ); - - let json_output = cli_command_in(dir.path()) - .args(["--json", "market", "view", "pasture-eggs"]) - .output() - .expect("run market view json"); - assert!(json_output.status.success()); - let json: Value = serde_json::from_slice(json_output.stdout.as_slice()).expect("json"); - assert_eq!(json["state"], "ready"); - assert_eq!(json["product_key"], "pasture-eggs"); - assert_eq!(json["listing_addr"], ADDRESS_BACKED_LISTING_ADDR); - assert_eq!(json["title"], "Pasture Eggs"); - assert_eq!(json["location_primary"], "Marshall"); - assert_eq!( - json["actions"][0], - "radroots order create --listing pasture-eggs" - ); - - let human_output = cli_command_in(dir.path()) - .args(["market", "view", "pasture-eggs"]) - .output() - .expect("run market view human"); - assert!(human_output.status.success()); - let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Pasture Eggs")); - assert!(stdout.contains("Listing")); - assert!(stdout.contains("Key")); - assert!(stdout.contains("Place")); - assert!(stdout.contains("About")); - assert!(stdout.contains("radroots order create --listing pasture-eggs")); - assert!(!stdout.contains("listing ยท")); - - let missing_output = cli_command_in(dir.path()) - .args(["--json", "market", "view", "missing-listing"]) - .output() - .expect("run missing market view"); - assert!(missing_output.status.success()); - let missing_json: Value = - serde_json::from_slice(missing_output.stdout.as_slice()).expect("json"); - assert_eq!(missing_json["state"], "missing"); - assert_eq!( - missing_json["actions"][0], - "radroots market search tomatoes" - ); - assert_eq!(missing_json["actions"][1], "radroots market update"); -} diff --git a/tests/myc_status.rs b/tests/myc_status.rs @@ -1,922 +0,0 @@ -use std::fs; -use std::os::unix::fs::PermissionsExt; -use std::path::Path; -use std::process::Command; -use std::sync::{Mutex, MutexGuard, OnceLock}; - -use assert_cmd::prelude::*; -use radroots_identity::RadrootsIdentity; -use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionId; -use serde_json::{Value, json}; -use tempfile::tempdir; - -fn cli_command_in(workdir: &Path) -> Command { - let mut command = Command::cargo_bin("radroots").expect("binary"); - command.current_dir(workdir); - command.env("HOME", workdir.join("home")); - for key in [ - "RADROOTS_ENV_FILE", - "RADROOTS_OUTPUT", - "RADROOTS_CLI_LOGGING_FILTER", - "RADROOTS_CLI_LOGGING_OUTPUT_DIR", - "RADROOTS_CLI_LOGGING_STDOUT", - "RADROOTS_CLI_PATHS_PROFILE", - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", - "RADROOTS_LOG_FILTER", - "RADROOTS_LOG_DIR", - "RADROOTS_LOG_STDOUT", - "RADROOTS_ACCOUNT", - "RADROOTS_ACCOUNT_SECRET_BACKEND", - "RADROOTS_ACCOUNT_SECRET_FALLBACK", - "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", - "RADROOTS_IDENTITY_PATH", - "RADROOTS_SIGNER", - "RADROOTS_RELAYS", - "RADROOTS_MYC_EXECUTABLE", - "RADROOTS_MYC_STATUS_TIMEOUT_MS", - "RADROOTS_RPC_URL", - "RADROOTS_RPC_BEARER_TOKEN", - ] { - command.env_remove(key); - } - command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); - command -} - -fn write_user_config(workdir: &Path, contents: &str) { - let config_dir = workdir.join("home/.radroots/config/apps/cli"); - fs::create_dir_all(&config_dir).expect("user config dir"); - fs::write(config_dir.join("config.toml"), contents).expect("write user config"); -} - -#[test] -fn myc_status_reports_ready_for_valid_signer_status_payload() { - let _guard = myc_test_guard(); - let dir = tempdir().expect("tempdir"); - let executable = write_fake_myc( - dir.path(), - successful_status_script(sample_status_payload(true).to_string()).as_str(), - ); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--myc-executable", - executable.to_str().expect("executable path"), - "myc", - "status", - ]) - .output() - .expect("run myc status"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["state"], "ready"); - assert_eq!(json["source"], "myc status command ยท local first"); - assert_eq!(json["ready"], true); - assert_eq!(json["service_status"], "healthy"); - assert_eq!(json["remote_session_count"], 1); - assert_eq!(json["remote_sessions"][0]["permissions"][0], "sign_event"); - assert_eq!(json["local_signer"]["availability"], "secret_backed"); - assert_eq!(json["custody"]["signer"]["resolved"], true); - assert_eq!( - json["custody"]["user"]["selected_account_state"], - "public_only" - ); -} - -#[test] -fn myc_status_reports_unavailable_for_invalid_status_payload() { - let _guard = myc_test_guard(); - let dir = tempdir().expect("tempdir"); - let executable = write_fake_myc(dir.path(), "#!/bin/sh\nprintf '%s\\n' 'this is not json'\n"); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--myc-executable", - executable.to_str().expect("executable path"), - "myc", - "status", - ]) - .output() - .expect("run myc status"); - - assert_eq!(output.status.code(), Some(4)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["state"], "unavailable"); - assert_eq!(json["ready"], false); - assert!( - json["reason"] - .as_str() - .is_some_and(|value| value.contains("not valid JSON")) - ); -} - -#[test] -fn myc_status_rejects_missing_signer_status_contract_version() { - let _guard = myc_test_guard(); - let dir = tempdir().expect("tempdir"); - let mut payload = sample_status_payload(true); - payload - .as_object_mut() - .expect("payload object") - .remove("status_contract_version"); - let executable = write_fake_myc( - dir.path(), - successful_status_script(payload.to_string()).as_str(), - ); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--myc-executable", - executable.to_str().expect("executable path"), - "myc", - "status", - ]) - .output() - .expect("run myc status"); - - assert_eq!(output.status.code(), Some(4)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["state"], "unavailable"); - assert!( - json["reason"] - .as_str() - .is_some_and(|value| value.contains("contract version 1")) - ); -} - -#[test] -fn myc_status_rejects_incompatible_signer_status_contract_version() { - let _guard = myc_test_guard(); - let dir = tempdir().expect("tempdir"); - let mut payload = sample_status_payload(true); - payload["status_contract_version"] = json!(99); - let executable = write_fake_myc( - dir.path(), - successful_status_script(payload.to_string()).as_str(), - ); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--myc-executable", - executable.to_str().expect("executable path"), - "myc", - "status", - ]) - .output() - .expect("run myc status"); - - assert_eq!(output.status.code(), Some(4)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["state"], "unavailable"); - assert!( - json["reason"] - .as_str() - .is_some_and(|value| value.contains("contract version 99")) - ); -} - -#[test] -fn myc_status_reports_degraded_service_as_external_unavailable() { - let _guard = myc_test_guard(); - let dir = tempdir().expect("tempdir"); - let executable = write_fake_myc( - dir.path(), - successful_status_script(sample_status_payload(false).to_string()).as_str(), - ); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--myc-executable", - executable.to_str().expect("executable path"), - "myc", - "status", - ]) - .output() - .expect("run myc status"); - - assert_eq!(output.status.code(), Some(4)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["state"], "degraded"); - assert_eq!(json["service_status"], "degraded"); - assert_eq!(json["ready"], false); - assert!( - json["reason"] - .as_str() - .is_some_and(|value| value.contains("transport quorum is below target")) - ); -} - -#[test] -fn signer_status_reports_degraded_myc_backend_as_external_unavailable() { - let _guard = myc_test_guard(); - let dir = tempdir().expect("tempdir"); - let executable = write_fake_myc( - dir.path(), - successful_status_script(sample_status_payload(false).to_string()).as_str(), - ); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--signer", - "myc", - "--myc-executable", - executable.to_str().expect("executable path"), - "signer", - "status", - ]) - .output() - .expect("run signer status"); - - assert_eq!(output.status.code(), Some(4)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["mode"], "myc"); - assert_eq!(json["state"], "degraded"); - assert_eq!(json["source"], "myc status command ยท local first"); - assert_eq!(json["signer_account_id"], Value::Null); - assert_eq!(json["myc"]["state"], "degraded"); - assert_eq!(json["myc"]["service_status"], "degraded"); - assert_eq!(json["binding"]["state"], "unconfigured"); - assert!( - json["reason"] - .as_str() - .is_some_and(|value| value.contains("transport quorum is below target")) - ); -} - -#[test] -fn signer_status_reports_ready_for_configured_myc_managed_account_binding() { - let _guard = myc_test_guard(); - let dir = tempdir().expect("tempdir"); - let payload = sample_status_payload(true); - let executable = write_fake_myc( - dir.path(), - successful_status_script(payload.to_string()).as_str(), - ); - let managed_account_ref = - payload["signer_backend"]["remote_sessions"][0]["user_identity"]["id"] - .as_str() - .expect("managed account ref"); - let provider_account_ref = payload["signer_backend"]["local_signer"]["account_id"] - .as_str() - .expect("provider account ref"); - assert_ne!(managed_account_ref, provider_account_ref); - let signer_session_ref = payload["signer_backend"]["remote_sessions"][0]["connection_id"] - .as_str() - .expect("signer session ref"); - write_user_config( - dir.path(), - format!( - r#" -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "managed_instance" -target = "default" -managed_account_ref = "{managed_account_ref}" -signer_session_ref = "{signer_session_ref}" -"# - ) - .as_str(), - ); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--signer", - "myc", - "--myc-executable", - executable.to_str().expect("executable path"), - "signer", - "status", - ]) - .output() - .expect("run signer status"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - assert_eq!(json["mode"], "myc"); - assert_eq!(json["state"], "ready"); - assert_eq!(json["source"], "user config [[capability_binding]]"); - assert_eq!(json["signer_account_id"], managed_account_ref); - assert_eq!(json["binding"]["state"], "ready"); - assert_eq!( - json["binding"]["resolved_signer_session_id"], - signer_session_ref - ); - assert_eq!(json["binding"]["managed_account_ref"], managed_account_ref); - assert_eq!(json["binding"]["signer_session_ref"], signer_session_ref); - assert_eq!(json["myc"]["remote_session_count"], 1); - assert_eq!( - json["myc"]["remote_sessions"][0]["user_identity"]["id"], - managed_account_ref - ); - assert_ne!( - json["myc"]["remote_sessions"][0]["signer_identity"]["id"], - json["myc"]["remote_sessions"][0]["user_identity"]["id"] - ); - assert_ne!( - json["myc"]["local_signer"]["account_id"], - json["myc"]["remote_sessions"][0]["user_identity"]["id"] - ); - assert!( - json["write_kinds"] - .as_array() - .expect("write kinds") - .iter() - .all(|kind| kind["ready"] == true) - ); -} - -#[test] -fn signer_status_reports_kind_specific_myc_write_readiness() { - let _guard = myc_test_guard(); - let dir = tempdir().expect("tempdir"); - let payload = sample_status_payload_with_permissions(true, &["sign_event:30402"]); - let executable = write_fake_myc( - dir.path(), - successful_status_script(payload.to_string()).as_str(), - ); - let managed_account_ref = - payload["signer_backend"]["remote_sessions"][0]["user_identity"]["id"] - .as_str() - .expect("managed account ref"); - let signer_session_ref = payload["signer_backend"]["remote_sessions"][0]["connection_id"] - .as_str() - .expect("signer session ref"); - write_user_config( - dir.path(), - format!( - r#" -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "managed_instance" -target = "default" -managed_account_ref = "{managed_account_ref}" -signer_session_ref = "{signer_session_ref}" -"# - ) - .as_str(), - ); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--signer", - "myc", - "--myc-executable", - executable.to_str().expect("executable path"), - "signer", - "status", - ]) - .output() - .expect("run signer status"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - assert_eq!(json["state"], "ready"); - assert_eq!(json["binding"]["state"], "ready"); - let write_kinds = json["write_kinds"].as_array().expect("write kinds"); - let listing = write_kinds - .iter() - .find(|kind| kind["event_kind"] == 30402) - .expect("listing kind"); - assert_eq!(listing["command"], "listing publish"); - assert_eq!(listing["ready"], true); - let order = write_kinds - .iter() - .find(|kind| kind["event_kind"] == 3422) - .expect("order kind"); - assert_eq!(order["command"], "order submit"); - assert_eq!(order["ready"], false); - assert!( - order["reason"] - .as_str() - .is_some_and(|value| value.contains("sign_event:3422")) - ); -} - -#[test] -fn signer_status_reports_unconfigured_when_myc_binding_is_missing() { - let _guard = myc_test_guard(); - let dir = tempdir().expect("tempdir"); - let executable = write_fake_myc( - dir.path(), - successful_status_script(sample_status_payload(true).to_string()).as_str(), - ); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--signer", - "myc", - "--myc-executable", - executable.to_str().expect("executable path"), - "signer", - "status", - ]) - .output() - .expect("run signer status"); - - assert_eq!(output.status.code(), Some(3)); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - assert_eq!(json["state"], "unconfigured"); - assert_eq!(json["binding"]["state"], "unconfigured"); - assert!( - json["binding"]["reason"] - .as_str() - .is_some_and(|value| value.contains("configure [[capability_binding]]")) - ); -} - -#[test] -fn signer_status_reports_unsupported_for_explicit_endpoint_binding() { - let _guard = myc_test_guard(); - let dir = tempdir().expect("tempdir"); - let executable = write_fake_myc( - dir.path(), - successful_status_script(sample_status_payload(true).to_string()).as_str(), - ); - write_user_config( - dir.path(), - r#" -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "explicit_endpoint" -target = "https://myc.example.invalid" -"#, - ); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--signer", - "myc", - "--myc-executable", - executable.to_str().expect("executable path"), - "signer", - "status", - ]) - .output() - .expect("run signer status"); - - assert_eq!(output.status.code(), Some(3)); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - assert_eq!(json["state"], "unconfigured"); - assert_eq!(json["binding"]["state"], "unsupported"); - assert!( - json["binding"]["reason"] - .as_str() - .is_some_and(|value| value.contains("only supports target_kind `managed_instance`")) - ); -} - -#[test] -fn signer_status_reports_ambiguous_for_accountless_myc_binding() { - let _guard = myc_test_guard(); - let dir = tempdir().expect("tempdir"); - let payload = sample_multi_session_status_payload(); - let executable = write_fake_myc( - dir.path(), - successful_status_script(payload.to_string()).as_str(), - ); - write_user_config( - dir.path(), - r#" -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "managed_instance" -target = "default" -"#, - ); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--signer", - "myc", - "--myc-executable", - executable.to_str().expect("executable path"), - "signer", - "status", - ]) - .output() - .expect("run signer status"); - - assert_eq!(output.status.code(), Some(3)); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - assert_eq!(json["state"], "unconfigured"); - assert_eq!(json["binding"]["state"], "ambiguous"); - assert_eq!(json["binding"]["matched_session_count"], 2); -} - -#[test] -fn signer_status_reports_unauthorized_for_session_without_sign_event_permission() { - let _guard = myc_test_guard(); - let dir = tempdir().expect("tempdir"); - let payload = sample_status_payload_with_permissions(true, &["nip44_encrypt"]); - let executable = write_fake_myc( - dir.path(), - successful_status_script(payload.to_string()).as_str(), - ); - let managed_account_ref = - payload["signer_backend"]["remote_sessions"][0]["user_identity"]["id"] - .as_str() - .expect("managed account ref"); - let signer_session_ref = payload["signer_backend"]["remote_sessions"][0]["connection_id"] - .as_str() - .expect("signer session ref"); - write_user_config( - dir.path(), - format!( - r#" -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "managed_instance" -target = "default" -managed_account_ref = "{managed_account_ref}" -signer_session_ref = "{signer_session_ref}" -"# - ) - .as_str(), - ); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--signer", - "myc", - "--myc-executable", - executable.to_str().expect("executable path"), - "signer", - "status", - ]) - .output() - .expect("run signer status"); - - assert_eq!(output.status.code(), Some(3)); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - assert_eq!(json["state"], "unconfigured"); - assert_eq!(json["binding"]["state"], "unauthorized"); - assert!( - json["binding"]["reason"] - .as_str() - .is_some_and(|value| value.contains("not approved for any cli write event kind")) - ); -} - -#[test] -fn signer_status_does_not_treat_sign_event_star_as_wildcard() { - let _guard = myc_test_guard(); - let dir = tempdir().expect("tempdir"); - let payload = sample_status_payload_with_permissions(true, &["sign_event:*"]); - let executable = write_fake_myc( - dir.path(), - successful_status_script(payload.to_string()).as_str(), - ); - let managed_account_ref = - payload["signer_backend"]["remote_sessions"][0]["user_identity"]["id"] - .as_str() - .expect("managed account ref"); - let signer_session_ref = payload["signer_backend"]["remote_sessions"][0]["connection_id"] - .as_str() - .expect("signer session ref"); - write_user_config( - dir.path(), - format!( - r#" -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "managed_instance" -target = "default" -managed_account_ref = "{managed_account_ref}" -signer_session_ref = "{signer_session_ref}" -"# - ) - .as_str(), - ); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--signer", - "myc", - "--myc-executable", - executable.to_str().expect("executable path"), - "signer", - "status", - ]) - .output() - .expect("run signer status"); - - assert_eq!(output.status.code(), Some(3)); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - assert_eq!(json["state"], "unconfigured"); - assert_eq!(json["binding"]["state"], "unauthorized"); - assert!( - json["write_kinds"] - .as_array() - .expect("write kinds") - .iter() - .all(|kind| kind["ready"] == false) - ); -} - -#[test] -fn signer_status_reports_unavailable_for_missing_bound_session() { - let _guard = myc_test_guard(); - let dir = tempdir().expect("tempdir"); - let payload = sample_status_payload(true); - let executable = write_fake_myc( - dir.path(), - successful_status_script(payload.to_string()).as_str(), - ); - let managed_account_ref = - payload["signer_backend"]["remote_sessions"][0]["user_identity"]["id"] - .as_str() - .expect("managed account ref"); - write_user_config( - dir.path(), - format!( - r#" -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "managed_instance" -target = "default" -managed_account_ref = "{managed_account_ref}" -signer_session_ref = "missing-session" -"# - ) - .as_str(), - ); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--signer", - "myc", - "--myc-executable", - executable.to_str().expect("executable path"), - "signer", - "status", - ]) - .output() - .expect("run signer status"); - - assert_eq!(output.status.code(), Some(4)); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - assert_eq!(json["state"], "unavailable"); - assert_eq!(json["binding"]["state"], "unavailable"); - assert!( - json["binding"]["reason"] - .as_str() - .is_some_and(|value| value.contains("missing-session")) - ); -} - -#[test] -fn myc_status_reports_unavailable_when_executable_is_missing() { - let _guard = myc_test_guard(); - let dir = tempdir().expect("tempdir"); - let missing = dir.path().join("missing-myc"); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--myc-executable", - missing.to_str().expect("missing path"), - "myc", - "status", - ]) - .output() - .expect("run myc status"); - - assert_eq!(output.status.code(), Some(4)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["state"], "unavailable"); - assert!( - json["reason"] - .as_str() - .is_some_and(|value| value.contains("was not found")) - ); -} - -#[test] -fn myc_status_reports_unavailable_for_non_zero_exit() { - let _guard = myc_test_guard(); - let dir = tempdir().expect("tempdir"); - let executable = write_fake_myc( - dir.path(), - "#!/bin/sh\nprintf '%s\\n' 'transport unavailable' >&2\nexit 42\n", - ); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--myc-executable", - executable.to_str().expect("executable path"), - "myc", - "status", - ]) - .output() - .expect("run myc status"); - - assert_eq!(output.status.code(), Some(4)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["state"], "unavailable"); - let reason = json["reason"].as_str().expect("reason string"); - assert!(reason.contains("status code 42") || reason.contains("transport unavailable")); -} - -#[test] -fn myc_status_reports_unavailable_for_timeout() { - let _guard = myc_test_guard(); - let dir = tempdir().expect("tempdir"); - let executable = write_fake_myc( - dir.path(), - "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"signer\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\nexec sleep 5\n", - ); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--myc-status-timeout-ms", - "25", - "--myc-executable", - executable.to_str().expect("executable path"), - "myc", - "status", - ]) - .output() - .expect("run myc status"); - - assert_eq!(output.status.code(), Some(4)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["state"], "unavailable"); - assert!( - json["reason"] - .as_str() - .is_some_and(|value| value.contains("timed out after 25ms")) - ); -} - -fn write_fake_myc(dir: &std::path::Path, script: &str) -> std::path::PathBuf { - let path = dir.join("fake-myc"); - fs::write(&path, script).expect("write fake myc"); - let mut permissions = fs::metadata(&path).expect("metadata").permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&path, permissions).expect("chmod fake myc"); - path -} - -fn myc_test_guard() -> MutexGuard<'static, ()> { - static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - .lock() - .expect("lock myc integration tests") -} - -fn successful_status_script(payload_json: String) -> String { - format!( - "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"signer\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\ncat <<'JSON'\n{payload_json}\nJSON\n" - ) -} - -fn sample_status_payload(ready: bool) -> Value { - sample_status_payload_with_permissions(ready, &["sign_event"]) -} - -fn sample_status_payload_with_permissions(ready: bool, permissions: &[&str]) -> Value { - let signer_identity = RadrootsIdentity::generate().to_public(); - let user_identity = RadrootsIdentity::generate().to_public(); - let session_id = RadrootsNostrSignerConnectionId::new_v7().to_string(); - let signer_id = signer_identity.id.clone(); - let signer_public_key_hex = signer_identity.public_key_hex.clone(); - let user_id = user_identity.id.clone(); - let user_public_key_hex = user_identity.public_key_hex.clone(); - let service_status = if ready { "healthy" } else { "degraded" }; - let reasons = if ready { - Vec::<String>::new() - } else { - vec!["transport quorum is below target".to_owned()] - }; - - json!({ - "status_contract_version": 1, - "status": service_status, - "ready": ready, - "reasons": reasons, - "signer_backend": { - "local_signer": { - "account_id": signer_id, - "public_identity": signer_identity.clone(), - "availability": "SecretBacked" - }, - "remote_session_count": 1, - "remote_sessions": [ - { - "connection_id": session_id, - "signer_identity": signer_identity, - "user_identity": user_identity.clone(), - "relays": ["wss://relay.example.test"], - "permissions": permissions.join(",") - } - ] - }, - "custody": { - "signer": { - "resolved": true, - "selected_account_id": "signer-account", - "selected_account_state": "ready", - "identity_id": signer_id, - "public_key_hex": signer_public_key_hex - }, - "user": { - "resolved": true, - "selected_account_id": "user-account", - "selected_account_state": "public_only", - "identity_id": user_id, - "public_key_hex": user_public_key_hex - } - } - }) -} - -fn sample_multi_session_status_payload() -> Value { - let first_signer = RadrootsIdentity::generate().to_public(); - let first_user = RadrootsIdentity::generate().to_public(); - let second_signer = RadrootsIdentity::generate().to_public(); - let second_user = RadrootsIdentity::generate().to_public(); - let first_signer_id = first_signer.id.clone(); - let first_signer_public_key_hex = first_signer.public_key_hex.clone(); - let first_user_id = first_user.id.clone(); - let first_user_public_key_hex = first_user.public_key_hex.clone(); - - json!({ - "status_contract_version": 1, - "status": "healthy", - "ready": true, - "reasons": [], - "signer_backend": { - "local_signer": { - "account_id": first_signer_id, - "public_identity": first_signer.clone(), - "availability": "SecretBacked" - }, - "remote_session_count": 2, - "remote_sessions": [ - { - "connection_id": RadrootsNostrSignerConnectionId::new_v7().to_string(), - "signer_identity": first_signer, - "user_identity": first_user.clone(), - "relays": ["wss://relay.example.test"], - "permissions": "sign_event" - }, - { - "connection_id": RadrootsNostrSignerConnectionId::new_v7().to_string(), - "signer_identity": second_signer, - "user_identity": second_user, - "relays": ["wss://relay-secondary.example.test"], - "permissions": "sign_event" - } - ] - }, - "custody": { - "signer": { - "resolved": true, - "selected_account_id": "signer-account", - "selected_account_state": "ready", - "identity_id": first_signer_id, - "public_key_hex": first_signer_public_key_hex - }, - "user": { - "resolved": true, - "selected_account_id": "user-account", - "selected_account_state": "public_only", - "identity_id": first_user_id, - "public_key_hex": first_user_public_key_hex - } - } - }) -} diff --git a/tests/order.rs b/tests/order.rs @@ -1,1817 +0,0 @@ -use std::fs; -use std::io::{Read, Write}; -use std::net::{TcpListener, TcpStream}; -use std::os::unix::fs::PermissionsExt; -use std::path::Path; -use std::process::Command; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; -use std::thread::{self, JoinHandle}; -use std::time::Duration; - -use assert_cmd::prelude::*; -use radroots_identity::RadrootsIdentity; -use radroots_sql_core::{SqlExecutor, SqliteExecutor}; -use serde_json::{Value, json}; -use tempfile::tempdir; - -const ORDER_SELLER_PUBKEY: &str = - "1111111111111111111111111111111111111111111111111111111111111111"; -const ORDER_LISTING_ADDR: &str = - "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; -const ORDER_DRAFT_LISTING_ADDR: &str = - "30403:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; - -fn data_root(workdir: &Path) -> std::path::PathBuf { - if cfg!(windows) { - workdir.join("local").join("Radroots").join("data") - } else { - workdir.join("home").join(".radroots").join("data") - } -} - -fn order_command_in(workdir: &Path) -> Command { - let mut command = Command::cargo_bin("radroots").expect("binary"); - command.current_dir(workdir); - command.env("HOME", workdir.join("home")); - command.env("APPDATA", workdir.join("roaming")); - command.env("LOCALAPPDATA", workdir.join("local")); - for key in [ - "RADROOTS_ENV_FILE", - "RADROOTS_OUTPUT", - "RADROOTS_CLI_LOGGING_FILTER", - "RADROOTS_CLI_LOGGING_OUTPUT_DIR", - "RADROOTS_CLI_LOGGING_STDOUT", - "RADROOTS_CLI_PATHS_PROFILE", - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", - "RADROOTS_LOG_FILTER", - "RADROOTS_LOG_DIR", - "RADROOTS_LOG_STDOUT", - "RADROOTS_ACCOUNT", - "RADROOTS_ACCOUNT_SECRET_BACKEND", - "RADROOTS_ACCOUNT_SECRET_FALLBACK", - "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", - "RADROOTS_IDENTITY_PATH", - "RADROOTS_SIGNER", - "RADROOTS_RELAYS", - "RADROOTS_MYC_EXECUTABLE", - "RADROOTS_MYC_STATUS_TIMEOUT_MS", - "RADROOTS_RPC_URL", - "RADROOTS_RPC_BEARER_TOKEN", - ] { - command.env_remove(key); - } - command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); - command -} - -fn write_user_config(workdir: &Path, contents: &str) { - let config_dir = workdir.join("home/.radroots/config/apps/cli"); - fs::create_dir_all(&config_dir).expect("user config dir"); - fs::write(config_dir.join("config.toml"), contents).expect("write user config"); -} - -fn init_local_replica(workdir: &Path) { - let init = order_command_in(workdir) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); -} - -fn seed_trade_product(workdir: &Path, product_id: &str, key: &str, listing_addr: Option<&str>) { - let replica_db = data_root(workdir).join("apps/cli/replica/replica.sqlite"); - let executor = SqliteExecutor::open(&replica_db).expect("open replica db"); - let now = "2026-04-07T00:00:00.000Z"; - executor - .exec( - "INSERT INTO trade_product (id, created_at, updated_at, key, listing_addr, category, title, summary, process, lot, profile, year, qty_amt, qty_unit, qty_label, qty_avail, price_amt, price_currency, price_qty_amt, price_qty_unit, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", - json!([ - product_id, - now, - now, - key, - listing_addr, - "produce", - "Pasture Eggs", - "Fresh pasture-raised eggs", - "fresh", - "lot-a", - "standard", - 2026, - 36, - "each", - "dozen", - 18, - 4.5, - "USD", - 1, - "each", - Value::Null - ]) - .to_string() - .as_str(), - ) - .expect("insert trade product"); -} - -fn assert_no_order_drafts(workdir: &Path) { - let drafts_dir = data_root(workdir).join("apps/cli/orders/drafts"); - if !drafts_dir.exists() { - return; - } - assert!( - fs::read_dir(&drafts_dir) - .expect("read drafts dir") - .next() - .is_none() - ); -} - -fn run_order_lookup_failure(seed: impl FnOnce(&Path), expected_stderr: &str) { - let dir = tempdir().expect("tempdir"); - seed(dir.path()); - - let output = order_command_in(dir.path()) - .args([ - "--json", - "order", - "new", - "--listing", - "pasture-eggs", - "--bin", - "bin-1", - "--qty", - "1", - ]) - .output() - .expect("run order new failure"); - assert_eq!(output.status.code(), Some(2)); - let stderr = String::from_utf8(output.stderr).expect("stderr utf8"); - assert!( - stderr.contains(expected_stderr), - "stderr did not contain `{expected_stderr}`: {stderr}" - ); - assert_no_order_drafts(dir.path()); -} - -fn config_with_write_plane(extra: &str, url: &str) -> String { - let mut rendered = String::new(); - if !extra.trim().is_empty() { - rendered.push_str(extra.trim()); - rendered.push_str("\n\n"); - } - rendered.push_str( - format!( - r#"[[capability_binding]] -capability = "write_plane.trade_jsonrpc" -provider = "radrootsd" -target_kind = "explicit_endpoint" -target = "{url}" -"# - ) - .as_str(), - ); - rendered -} - -fn write_fake_myc(dir: &Path, script: &str) -> std::path::PathBuf { - let path = dir.join("fake-myc"); - fs::write(&path, script).expect("write fake myc"); - let mut permissions = fs::metadata(&path).expect("metadata").permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&path, permissions).expect("chmod fake myc"); - path -} - -fn successful_status_script(payload_json: String) -> String { - format!( - "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"signer\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\ncat <<'JSON'\n{payload_json}\nJSON\n" - ) -} - -fn sample_myc_status_payload( - account_id: &str, - public_identity: &Value, - connection_id: &str, -) -> Value { - let signer_identity = - serde_json::to_value(RadrootsIdentity::generate().to_public()).expect("signer identity"); - let signer_account_id = signer_identity["id"] - .as_str() - .expect("signer id") - .to_owned(); - assert_ne!(signer_account_id, account_id); - json!({ - "status_contract_version": 1, - "status": "healthy", - "ready": true, - "reasons": [], - "signer_backend": { - "local_signer": { - "account_id": signer_account_id, - "public_identity": signer_identity.clone(), - "availability": "SecretBacked" - }, - "remote_session_count": 1, - "remote_sessions": [ - { - "connection_id": connection_id, - "signer_identity": signer_identity, - "user_identity": public_identity, - "relays": ["wss://relay.one"], - "permissions": "sign_event" - } - ] - } - }) -} - -fn order_test_guard() -> MutexGuard<'static, ()> { - static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - .lock() - .expect("order test lock") -} - -#[derive(Debug, Clone)] -struct MockRpcRequest { - body: Value, - method: String, - auth_header: Option<String>, -} - -#[derive(Debug, Clone)] -struct MockRpcResponse { - body: Value, -} - -impl MockRpcResponse { - fn success(result: Value) -> Self { - Self { - body: serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "result": result, - }), - } - } -} - -struct MockRpcServer { - address: String, - shutdown: Arc<AtomicBool>, - handle: Option<JoinHandle<()>>, -} - -impl MockRpcServer { - fn start<F>(handler: F) -> Self - where - F: Fn(Value, Option<String>) -> MockRpcResponse + Send + Sync + 'static, - { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock rpc listener"); - listener - .set_nonblocking(true) - .expect("set mock rpc listener nonblocking"); - let address = listener - .local_addr() - .expect("mock rpc local addr") - .to_string(); - let shutdown = Arc::new(AtomicBool::new(false)); - let shutdown_flag = Arc::clone(&shutdown); - let handler: Arc<dyn Fn(Value, Option<String>) -> MockRpcResponse + Send + Sync> = - Arc::new(handler); - let handle = thread::spawn(move || { - while !shutdown_flag.load(Ordering::SeqCst) { - match listener.accept() { - Ok((mut stream, _)) => { - let _ = stream.set_nonblocking(false); - if let Ok(request) = read_request(&mut stream) { - let response = - handler(request.body.clone(), request.auth_header.clone()); - let _ = write_response(&mut stream, &response); - } - } - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(10)); - } - Err(_) => { - thread::sleep(Duration::from_millis(10)); - } - } - } - }); - - Self { - address, - shutdown, - handle: Some(handle), - } - } - - fn url(&self) -> String { - format!("http://{}", self.address) - } -} - -impl Drop for MockRpcServer { - fn drop(&mut self) { - self.shutdown.store(true, Ordering::SeqCst); - let _ = TcpStream::connect(&self.address); - if let Some(handle) = self.handle.take() { - handle.join().expect("join mock rpc thread"); - } - } -} - -fn read_request(stream: &mut TcpStream) -> Result<MockRpcRequest, String> { - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .map_err(|error| format!("set mock rpc read timeout: {error}"))?; - - let mut buffer = Vec::new(); - let mut chunk = [0_u8; 4096]; - let mut header_end = None; - let mut content_length = 0_usize; - - loop { - let read = stream - .read(&mut chunk) - .map_err(|error| format!("read mock rpc request: {error}"))?; - if read == 0 { - break; - } - buffer.extend_from_slice(&chunk[..read]); - if header_end.is_none() { - header_end = find_subslice(&buffer, b"\r\n\r\n").map(|index| index + 4); - if let Some(end) = header_end { - content_length = parse_content_length(&buffer[..end])?; - if buffer.len() >= end + content_length { - break; - } - } - } else if let Some(end) = header_end { - if buffer.len() >= end + content_length { - break; - } - } - } - - let end = header_end.ok_or_else(|| "mock rpc request did not include headers".to_owned())?; - let headers = std::str::from_utf8(&buffer[..end]) - .map_err(|error| format!("mock rpc headers were not utf-8: {error}"))?; - let auth_header = parse_header(headers, "authorization"); - let body = std::str::from_utf8(&buffer[end..end + content_length]) - .map_err(|error| format!("mock rpc body was not utf-8: {error}"))?; - let envelope: Value = - serde_json::from_str(body).map_err(|error| format!("parse mock rpc body: {error}"))?; - let method = envelope["method"] - .as_str() - .ok_or_else(|| "mock rpc body did not include method".to_owned())? - .to_owned(); - - Ok(MockRpcRequest { - body: envelope, - method, - auth_header, - }) -} - -fn parse_content_length(headers: &[u8]) -> Result<usize, String> { - let text = - std::str::from_utf8(headers).map_err(|error| format!("parse mock rpc headers: {error}"))?; - for line in text.lines() { - if let Some((name, value)) = line.split_once(':') { - if name.trim().eq_ignore_ascii_case("content-length") { - return value - .trim() - .parse::<usize>() - .map_err(|error| format!("parse content-length: {error}")); - } - } - } - Ok(0) -} - -fn parse_header(headers: &str, wanted: &str) -> Option<String> { - headers.lines().find_map(|line| { - let (name, value) = line.split_once(':')?; - if name.trim().eq_ignore_ascii_case(wanted) { - Some(value.trim().to_owned()) - } else { - None - } - }) -} - -fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> { - haystack - .windows(needle.len()) - .position(|window| window == needle) -} - -fn write_response(stream: &mut TcpStream, response: &MockRpcResponse) -> Result<(), String> { - let body = response.body.to_string(); - write!( - stream, - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ) - .map_err(|error| format!("write mock rpc response: {error}"))?; - stream - .flush() - .map_err(|error| format!("flush mock rpc response: {error}")) -} - -fn sample_bridge_job(job_id: &str, state: &str, terminal: bool, signer_session_id: &str) -> Value { - serde_json::json!({ - "job_id": job_id, - "command": "bridge.order.request", - "idempotency_key": "order-submit-1", - "status": state, - "terminal": terminal, - "recovered_after_restart": false, - "requested_at_unix": 1_712_720_000, - "completed_at_unix": terminal.then_some(1_712_720_030), - "signer_mode": "nip46_session", - "signer_session_id": signer_session_id, - "event_kind": 30420, - "event_id": "evt_order_01", - "event_addr": "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", - "delivery_policy": "best_effort", - "delivery_quorum": 2, - "relay_count": 2, - "acknowledged_relay_count": if terminal { 2 } else { 1 }, - "required_acknowledged_relay_count": 2, - "attempt_count": if terminal { 2 } else { 1 }, - "relay_outcome_summary": if terminal { "submitted to 2 relays" } else { "awaiting relay quorum" }, - "attempt_summaries": if terminal { - serde_json::json!(["attempt 1: relay.one accepted", "attempt 2: relay.two accepted"]) - } else { - serde_json::json!(["attempt 1: relay.one accepted"]) - } - }) -} - -#[test] -fn order_new_creates_a_local_draft_with_selected_account_defaults() { - let _guard = order_test_guard(); - let dir = tempdir().expect("tempdir"); - - let account_output = order_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - let account_json: Value = - serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); - let account_id = account_json["account"]["id"] - .as_str() - .expect("account id") - .to_owned(); - let buyer_pubkey = account_json["public_identity"]["public_key_hex"] - .as_str() - .expect("buyer pubkey"); - - init_local_replica(dir.path()); - seed_trade_product( - dir.path(), - "00000000-0000-0000-0000-000000000901", - "pasture-eggs", - Some(ORDER_LISTING_ADDR), - ); - - let output = order_command_in(dir.path()) - .args([ - "--json", - "order", - "new", - "--listing", - "pasture-eggs", - "--bin", - "bin-1", - "--qty", - "2", - ]) - .output() - .expect("run order new"); - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("order json"); - assert_eq!(json["state"], "draft_created"); - assert_eq!(json["buyer_account_id"], account_id); - assert_eq!(json["buyer_pubkey"], buyer_pubkey); - assert_eq!(json["listing_addr"], ORDER_LISTING_ADDR); - assert_eq!(json["seller_pubkey"], ORDER_SELLER_PUBKEY); - assert_eq!(json["ready_for_submit"], true); - assert_eq!(json["items"][0]["bin_id"], "bin-1"); - assert_eq!(json["items"][0]["bin_count"], 2); - - let file = json["file"].as_str().expect("draft file"); - assert!(file.contains("/data/apps/cli/orders/drafts/ord_")); - let contents = fs::read_to_string(file).expect("read order draft"); - assert!(contents.contains("kind = \"order_draft_v1\"")); - assert!(contents.contains("listing_lookup = \"pasture-eggs\"")); - assert!(contents.contains(&format!("listing_addr = \"{ORDER_LISTING_ADDR}\""))); - assert!(contents.contains(&format!("seller_pubkey = \"{ORDER_SELLER_PUBKEY}\""))); - assert!(contents.contains(&format!("buyer_account_id = \"{account_id}\""))); -} - -#[test] -fn order_new_listing_lookup_failures_do_not_create_drafts() { - let _guard = order_test_guard(); - - run_order_lookup_failure(|_| {}, "requires local market data"); - run_order_lookup_failure( - |workdir| { - init_local_replica(workdir); - }, - "is not available in the local replica", - ); - run_order_lookup_failure( - |workdir| { - init_local_replica(workdir); - seed_trade_product( - workdir, - "00000000-0000-0000-0000-000000000902", - "pasture-eggs", - None, - ); - }, - "is missing a canonical listing address", - ); - run_order_lookup_failure( - |workdir| { - init_local_replica(workdir); - seed_trade_product( - workdir, - "00000000-0000-0000-0000-000000000903", - "pasture-eggs", - Some(ORDER_DRAFT_LISTING_ADDR), - ); - }, - "must reference a public NIP-99 listing", - ); - run_order_lookup_failure( - |workdir| { - init_local_replica(workdir); - seed_trade_product( - workdir, - "00000000-0000-0000-0000-000000000904", - "pasture-eggs", - Some(ORDER_LISTING_ADDR), - ); - seed_trade_product( - workdir, - "00000000-0000-0000-0000-000000000905", - "pasture-eggs", - Some(ORDER_LISTING_ADDR), - ); - }, - "matched 2 local listings", - ); -} - -#[test] -fn order_get_and_ls_read_local_drafts_and_report_missing() { - let _guard = order_test_guard(); - let dir = tempdir().expect("tempdir"); - let account_output = order_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - - let first = order_command_in(dir.path()) - .args([ - "--json", - "order", - "new", - "--listing", - "pasture-eggs", - "--listing-addr", - "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", - "--bin", - "bin-1", - ]) - .output() - .expect("run first order new"); - assert!(first.status.success()); - let first_json: Value = serde_json::from_slice(first.stdout.as_slice()).expect("first json"); - let first_order_id = first_json["order_id"].as_str().expect("first order id"); - - let second = order_command_in(dir.path()) - .args([ - "--json", - "order", - "new", - "--listing", - "carrots", - "--listing-addr", - ORDER_LISTING_ADDR, - ]) - .output() - .expect("run second order new"); - assert!(second.status.success()); - let second_json: Value = serde_json::from_slice(second.stdout.as_slice()).expect("second json"); - let second_order_id = second_json["order_id"].as_str().expect("second order id"); - - let get_output = order_command_in(dir.path()) - .args(["--json", "order", "get", first_order_id]) - .output() - .expect("run order get"); - assert!(get_output.status.success()); - let get_json: Value = serde_json::from_slice(get_output.stdout.as_slice()).expect("get json"); - assert_eq!(get_json["state"], "ready"); - assert_eq!(get_json["order_id"], first_order_id); - assert_eq!(get_json["listing_lookup"], "pasture-eggs"); - - let missing_output = order_command_in(dir.path()) - .args(["--json", "order", "get", "ord_missing"]) - .output() - .expect("run missing order get"); - assert!(missing_output.status.success()); - let missing_json: Value = - serde_json::from_slice(missing_output.stdout.as_slice()).expect("missing json"); - assert_eq!(missing_json["state"], "missing"); - - let human_list = order_command_in(dir.path()) - .args(["order", "ls"]) - .output() - .expect("run human order ls"); - assert!(human_list.status.success()); - let human_text = String::from_utf8(human_list.stdout).expect("human text"); - assert!(human_text.contains("orders ยท 2 local drafts")); - assert!(human_text.contains(first_order_id)); - assert!(human_text.contains(second_order_id)); - - let ndjson_output = order_command_in(dir.path()) - .args(["--ndjson", "order", "ls"]) - .output() - .expect("run ndjson order ls"); - assert!(ndjson_output.status.success()); - let ndjson = String::from_utf8(ndjson_output.stdout).expect("ndjson text"); - let lines = ndjson.lines().collect::<Vec<_>>(); - assert_eq!(lines.len(), 2); - assert!(lines.iter().any(|line| line.contains(first_order_id))); - assert!(lines.iter().any(|line| line.contains(second_order_id))); -} - -#[test] -fn order_create_view_and_list_aliases_wrap_the_existing_draft_surfaces() { - let _guard = order_test_guard(); - let dir = tempdir().expect("tempdir"); - - let account_output = order_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - - let create_output = order_command_in(dir.path()) - .args([ - "--json", - "order", - "create", - "--listing", - "pasture-eggs", - "--listing-addr", - "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", - "--bin", - "bin-1", - "--qty", - "2", - ]) - .output() - .expect("run order create"); - assert!(create_output.status.success()); - let create_json: Value = - serde_json::from_slice(create_output.stdout.as_slice()).expect("create json"); - let order_id = create_json["order_id"].as_str().expect("order id"); - assert_eq!(create_json["state"], "draft_created"); - assert_eq!( - create_json["actions"][0], - format!("radroots order view {order_id}") - ); - - let view_output = order_command_in(dir.path()) - .args(["--json", "order", "view", order_id]) - .output() - .expect("run order view"); - assert!(view_output.status.success()); - let view_json: Value = - serde_json::from_slice(view_output.stdout.as_slice()).expect("view json"); - assert_eq!(view_json["state"], "ready"); - assert_eq!(view_json["order_id"], order_id); - - let list_output = order_command_in(dir.path()) - .args(["--json", "order", "list"]) - .output() - .expect("run order list"); - assert!(list_output.status.success()); - let list_json: Value = - serde_json::from_slice(list_output.stdout.as_slice()).expect("list json"); - assert_eq!(list_json["count"], 1); - assert_eq!(list_json["orders"][0]["id"], order_id); -} - -#[test] -fn order_list_empty_prefers_the_create_follow_up() { - let _guard = order_test_guard(); - let dir = tempdir().expect("tempdir"); - - let output = order_command_in(dir.path()) - .args(["--json", "order", "list"]) - .output() - .expect("run empty order list"); - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("list json"); - assert_eq!(json["state"], "empty"); - assert_eq!(json["actions"][0], "radroots order create"); -} - -#[test] -fn order_get_surfaces_recorded_job_metadata_from_the_local_draft_store() { - let _guard = order_test_guard(); - let dir = tempdir().expect("tempdir"); - let drafts_dir = data_root(dir.path()).join("apps/cli/orders/drafts"); - fs::create_dir_all(&drafts_dir).expect("create drafts dir"); - let draft_path = drafts_dir.join("ord_AAAAAAAAAAAAAAAAAAAAAg.toml"); - fs::write( - &draft_path, - r#"version = 1 -kind = "order_draft_v1" -listing_lookup = "fresh-eggs" -buyer_account_id = "acct_demo" - -[order] -order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg" -listing_addr = "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg" -buyer_pubkey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -seller_pubkey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" - -[[order.items]] -bin_id = "bin-1" -bin_count = 2 - -[submission] -job_id = "job_order_01" -"#, - ) - .expect("write order draft"); - - let output = order_command_in(dir.path()) - .args(["--json", "order", "get", "ord_AAAAAAAAAAAAAAAAAAAAAg"]) - .output() - .expect("run order get"); - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); - assert_eq!(json["state"], "submitted"); - assert_eq!(json["job"]["job_id"], "job_order_01"); - assert_eq!(json["job"]["state"], "recorded"); - assert_eq!(json["ready_for_submit"], false); -} - -#[test] -fn order_submit_persists_submission_metadata_and_reports_job() { - let _guard = order_test_guard(); - let dir = tempdir().expect("tempdir"); - let account_output = order_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - - let new_output = order_command_in(dir.path()) - .args([ - "--json", - "order", - "new", - "--listing", - "pasture-eggs", - "--listing-addr", - "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", - "--bin", - "bin-1", - ]) - .output() - .expect("run order new"); - assert!(new_output.status.success()); - let new_json: Value = serde_json::from_slice(new_output.stdout.as_slice()).expect("new json"); - let order_id = new_json["order_id"].as_str().expect("order id"); - let file = new_json["file"].as_str().expect("file"); - let buyer_pubkey = new_json["buyer_pubkey"] - .as_str() - .expect("buyer pubkey") - .to_owned(); - - let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); - let recorded = Arc::clone(&requests); - let server = MockRpcServer::start(move |body, auth_header| { - recorded - .lock() - .expect("recorded requests lock") - .push(MockRpcRequest { - body: body.clone(), - method: body["method"].as_str().unwrap_or_default().to_owned(), - auth_header, - }); - match body["method"].as_str().unwrap_or_default() { - "nip46.session.list" => MockRpcResponse::success(json!([sample_session( - "sess_order_01", - buyer_pubkey.as_str(), - &["sign_event"], - true - )])), - "bridge.order.request" => MockRpcResponse::success(serde_json::json!({ - "deduplicated": false, - "job": sample_bridge_job("job_order_01", "accepted", false, "sess_order_01"), - })), - "bridge.job.status" => MockRpcResponse::success(sample_bridge_job( - "job_order_01", - "accepted", - false, - "sess_order_01", - )), - other => panic!("unexpected mock rpc method {other}"), - } - }); - write_user_config( - dir.path(), - config_with_write_plane("", server.url().as_str()).as_str(), - ); - - let submit_output = order_command_in(dir.path()) - .env("RADROOTS_RPC_BEARER_TOKEN", "test-token") - .args([ - "--json", - "order", - "submit", - order_id, - "--idempotency-key", - "order-submit-1", - "--signer-session-id", - "sess_order_01", - ]) - .output() - .expect("run order submit"); - assert!(submit_output.status.success()); - let submit_json: Value = - serde_json::from_slice(submit_output.stdout.as_slice()).expect("submit json"); - assert_eq!(submit_json["state"], "accepted"); - assert_eq!(submit_json["signer_mode"], "nip46_session"); - assert_eq!(submit_json["signer_session_id"], "sess_order_01"); - assert_eq!(submit_json["job"]["job_id"], "job_order_01"); - assert_eq!(submit_json["job"]["command"], "order.submit"); - assert_eq!(submit_json["job"]["signer_mode"], "nip46_session"); - assert_eq!(submit_json["job"]["signer_session_id"], "sess_order_01"); - assert_eq!(submit_json["requested_signer_session_id"], "sess_order_01"); - assert_eq!( - submit_json["job"]["requested_signer_session_id"], - "sess_order_01" - ); - - let contents = fs::read_to_string(file).expect("read updated order draft"); - assert!(contents.contains("job_id = \"job_order_01\"")); - assert!(contents.contains("state = \"accepted\"")); - assert!(contents.contains("command = \"order.submit\"")); - - let recorded_requests = requests.lock().expect("requests lock"); - assert!( - recorded_requests - .iter() - .any(|request| request.method == "bridge.order.request") - ); - assert!( - recorded_requests - .iter() - .any(|request| request.method == "nip46.session.list") - ); - let request = recorded_requests - .iter() - .find(|request| request.method == "bridge.order.request") - .expect("bridge order request"); - assert_eq!(request.body["params"]["signer_session_id"], "sess_order_01"); - assert!( - recorded_requests - .iter() - .any(|request| { request.auth_header.as_deref() == Some("Bearer test-token") }) - ); -} - -#[test] -fn order_submit_quiet_reports_submitted_order_id() { - let _guard = order_test_guard(); - let dir = tempdir().expect("tempdir"); - let account_output = order_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - - let new_output = order_command_in(dir.path()) - .args([ - "--json", - "order", - "new", - "--listing", - "pasture-eggs", - "--listing-addr", - "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", - "--bin", - "bin-1", - ]) - .output() - .expect("run order new"); - assert!(new_output.status.success()); - let new_json: Value = serde_json::from_slice(new_output.stdout.as_slice()).expect("new json"); - let order_id = new_json["order_id"].as_str().expect("order id"); - let buyer_pubkey = new_json["buyer_pubkey"] - .as_str() - .expect("buyer pubkey") - .to_owned(); - - let server = MockRpcServer::start(move |body, _auth_header| { - match body["method"].as_str().unwrap_or_default() { - "nip46.session.list" => MockRpcResponse::success(json!([sample_session( - "sess_order_quiet_01", - buyer_pubkey.as_str(), - &["sign_event"], - true - )])), - "bridge.order.request" => MockRpcResponse::success(serde_json::json!({ - "deduplicated": false, - "job": sample_bridge_job("job_order_quiet_01", "accepted", false, "sess_order_quiet_01"), - })), - "bridge.job.status" => MockRpcResponse::success(sample_bridge_job( - "job_order_quiet_01", - "accepted", - false, - "sess_order_quiet_01", - )), - other => panic!("unexpected mock rpc method {other}"), - } - }); - write_user_config( - dir.path(), - config_with_write_plane("", server.url().as_str()).as_str(), - ); - - let submit_output = order_command_in(dir.path()) - .env("RADROOTS_RPC_BEARER_TOKEN", "quiet-token") - .args([ - "--quiet", - "order", - "submit", - order_id, - "--signer-session-id", - "sess_order_quiet_01", - ]) - .output() - .expect("run quiet order submit"); - assert!(submit_output.status.success()); - let stdout = String::from_utf8(submit_output.stdout).expect("utf8 stdout"); - assert_eq!(stdout.trim(), format!("Order submitted: {order_id}")); -} - -#[test] -fn order_submit_watch_rejects_json_output() { - let _guard = order_test_guard(); - let dir = tempdir().expect("tempdir"); - - let output = order_command_in(dir.path()) - .args(["--json", "order", "submit", "ord_demo", "--watch"]) - .output() - .expect("run order submit watch json"); - assert_eq!(output.status.code(), Some(2)); - let stderr = String::from_utf8(output.stderr).expect("stderr utf8"); - assert!(stderr.contains("`order submit --watch` only supports human output")); -} - -#[test] -fn order_submit_watch_appends_human_watch_snapshots() { - let _guard = order_test_guard(); - let dir = tempdir().expect("tempdir"); - let account_output = order_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - let account_json: Value = - serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); - let buyer_pubkey = account_json["public_identity"]["public_key_hex"] - .as_str() - .expect("buyer pubkey") - .to_owned(); - - let create_output = order_command_in(dir.path()) - .args([ - "--json", - "order", - "create", - "--listing", - "pasture-eggs", - "--listing-addr", - "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", - "--bin", - "bin-1", - ]) - .output() - .expect("run order create"); - assert!(create_output.status.success()); - let create_json: Value = - serde_json::from_slice(create_output.stdout.as_slice()).expect("create json"); - let order_id = create_json["order_id"].as_str().expect("order id"); - - let server = MockRpcServer::start(move |body, _auth_header| { - match body["method"].as_str().unwrap_or_default() { - "nip46.session.list" => MockRpcResponse::success(json!([sample_session( - "sess_order_watch_01", - buyer_pubkey.as_str(), - &["sign_event"], - true - )])), - "bridge.order.request" => MockRpcResponse::success(serde_json::json!({ - "deduplicated": false, - "job": sample_bridge_job( - "job_order_watch_01", - "accepted", - false, - "sess_order_watch_01" - ), - })), - "bridge.job.status" => MockRpcResponse::success(sample_bridge_job( - "job_order_watch_01", - "completed", - true, - "sess_order_watch_01", - )), - other => panic!("unexpected mock rpc method {other}"), - } - }); - write_user_config( - dir.path(), - config_with_write_plane("", server.url().as_str()).as_str(), - ); - - let output = order_command_in(dir.path()) - .env("RADROOTS_RPC_BEARER_TOKEN", "watch-token") - .args([ - "order", - "submit", - order_id, - "--watch", - "--signer-session-id", - "sess_order_watch_01", - ]) - .output() - .expect("run order submit watch"); - let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); - let stderr = String::from_utf8(output.stderr).expect("stderr utf8"); - assert!( - output.status.success(), - "status: {:?}\nstdout:\n{stdout}\nstderr:\n{stderr}", - output.status.code() - ); - assert!(stdout.contains("Order submitted")); - assert!(stdout.contains("Watching order")); - assert!(stdout.contains(order_id)); - assert!(stdout.contains("Completed")); - assert!(stdout.contains("submitted to 2 relays")); - assert!(!stdout.contains("order ยท")); -} - -#[test] -fn order_watch_reports_job_frames_for_submitted_order() { - let _guard = order_test_guard(); - let dir = tempdir().expect("tempdir"); - let polls = Arc::new(Mutex::new(0usize)); - let watch_polls = Arc::clone(&polls); - let server = MockRpcServer::start(move |body, _auth_header| { - match body["method"].as_str().unwrap_or_default() { - "bridge.job.status" => { - let mut count = watch_polls.lock().expect("watch polls lock"); - *count += 1; - if *count == 1 { - MockRpcResponse::success(sample_bridge_job( - "job_watch_01", - "accepted", - false, - "sess_order_01", - )) - } else { - MockRpcResponse::success(sample_bridge_job( - "job_watch_01", - "completed", - true, - "sess_order_01", - )) - } - } - other => panic!("unexpected mock rpc method {other}"), - } - }); - - let drafts_dir = data_root(dir.path()).join("apps/cli/orders/drafts"); - fs::create_dir_all(&drafts_dir).expect("create drafts dir"); - fs::write( - drafts_dir.join("ord_AAAAAAAAAAAAAAAAAAAAAg.toml"), - r#"version = 1 -kind = "order_draft_v1" -listing_lookup = "fresh-eggs" -buyer_account_id = "acct_demo" - -[order] -order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg" -listing_addr = "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg" -buyer_pubkey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -seller_pubkey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" - -[[order.items]] -bin_id = "bin-1" -bin_count = 2 - -[submission] -job_id = "job_watch_01" -"#, - ) - .expect("write watch draft"); - - let output = order_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) - .env("RADROOTS_RPC_BEARER_TOKEN", "watch-token") - .args([ - "--json", - "order", - "watch", - "ord_AAAAAAAAAAAAAAAAAAAAAg", - "--frames", - "2", - "--interval-ms", - "1", - ]) - .output() - .expect("run order watch"); - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("watch json"); - assert_eq!(json["state"], "completed"); - assert_eq!(json["frames"].as_array().map(Vec::len), Some(2)); - assert_eq!(json["frames"][0]["state"], "accepted"); - assert_eq!(json["frames"][1]["state"], "completed"); - assert_eq!(json["frames"][0]["signer_mode"], "nip46_session"); - assert_eq!(json["frames"][0]["signer_session_id"], "sess_order_01"); - assert_eq!(json["frames"][1]["signer_mode"], "nip46_session"); - assert_eq!(json["frames"][1]["signer_session_id"], "sess_order_01"); - - let human_polls = Arc::new(Mutex::new(0usize)); - let human_watch_polls = Arc::clone(&human_polls); - let human_server = MockRpcServer::start(move |body, _auth_header| { - match body["method"].as_str().unwrap_or_default() { - "bridge.job.status" => { - let mut count = human_watch_polls.lock().expect("watch polls lock"); - *count += 1; - if *count == 1 { - MockRpcResponse::success(sample_bridge_job( - "job_watch_01", - "accepted", - false, - "sess_order_01", - )) - } else { - MockRpcResponse::success(sample_bridge_job( - "job_watch_01", - "completed", - true, - "sess_order_01", - )) - } - } - other => panic!("unexpected mock rpc method {other}"), - } - }); - - let human_output = order_command_in(dir.path()) - .env("RADROOTS_RPC_URL", human_server.url()) - .env("RADROOTS_RPC_BEARER_TOKEN", "watch-token") - .args([ - "order", - "watch", - "ord_AAAAAAAAAAAAAAAAAAAAAg", - "--frames", - "2", - "--interval-ms", - "1", - ]) - .output() - .expect("run human order watch"); - assert!(human_output.status.success()); - let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Watching order ord_AAAAAAAAAAAAAAAAAAAAAg")); - assert!(stdout.contains("Accepted")); - assert!(stdout.contains("Completed")); - assert!(stdout.contains("Summary")); - assert!(!stdout.contains("order ยท")); - assert!(!stdout.contains("\u{1b}")); -} - -#[test] -fn order_submit_uses_myc_binding_before_resolving_daemon_signer_session() { - let _guard = order_test_guard(); - let dir = tempdir().expect("tempdir"); - - let account_output = order_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - let account_json: Value = - serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); - let account_id = account_json["account"]["id"] - .as_str() - .expect("account id") - .to_owned(); - let public_identity = account_json["public_identity"].clone(); - let buyer_pubkey = public_identity["public_key_hex"] - .as_str() - .expect("buyer pubkey") - .to_owned(); - - let myc = write_fake_myc( - dir.path(), - successful_status_script( - sample_myc_status_payload( - account_id.as_str(), - &public_identity, - "conn_order_binding_01", - ) - .to_string(), - ) - .as_str(), - ); - - let new_output = order_command_in(dir.path()) - .args([ - "--json", - "order", - "new", - "--listing", - "pasture-eggs", - "--listing-addr", - "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", - "--bin", - "bin-1", - "--qty", - "2", - ]) - .output() - .expect("run order new"); - assert!(new_output.status.success()); - let new_json: Value = serde_json::from_slice(new_output.stdout.as_slice()).expect("new json"); - let order_id = new_json["order_id"].as_str().expect("order id"); - - let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); - let recorded = Arc::clone(&requests); - let session_account_id = account_id.clone(); - let provider_pubkey = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - let server = MockRpcServer::start(move |body, auth_header| { - recorded - .lock() - .expect("recorded requests lock") - .push(MockRpcRequest { - body: body.clone(), - method: body["method"].as_str().unwrap_or_default().to_owned(), - auth_header: auth_header.clone(), - }); - match body["method"].as_str().unwrap_or_default() { - "nip46.session.list" => { - let mut session = sample_session_with_authority( - "sess_order_02", - provider_pubkey, - &["sign_event"], - true, - Some(session_account_id.as_str()), - Some("conn_order_binding_01"), - ); - session["user_pubkey"] = Value::Null; - MockRpcResponse::success(json!([session])) - } - "nip46.get_public_key" => { - assert_eq!(auth_header.as_deref(), Some("Bearer test-token")); - assert_eq!(body["params"]["session_id"], "sess_order_02"); - MockRpcResponse::success(json!({ - "pubkey": buyer_pubkey.as_str() - })) - } - "bridge.order.request" => MockRpcResponse::success(serde_json::json!({ - "deduplicated": false, - "job": sample_bridge_job("job_order_02", "accepted", false, "sess_order_02"), - })), - other => panic!("unexpected mock rpc method {other}"), - } - }); - write_user_config( - dir.path(), - config_with_write_plane( - format!( - r#" -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "managed_instance" -target = "default" -managed_account_ref = "{account_id}" -"# - ) - .as_str(), - server.url().as_str(), - ) - .as_str(), - ); - - let submit_output = order_command_in(dir.path()) - .env("RADROOTS_RPC_BEARER_TOKEN", "test-token") - .args([ - "--json", - "--signer", - "myc", - "--myc-executable", - myc.to_str().expect("myc path"), - "order", - "submit", - order_id, - ]) - .output() - .expect("run order submit"); - - let stdout = String::from_utf8(submit_output.stdout.clone()).expect("stdout utf8"); - let stderr = String::from_utf8(submit_output.stderr.clone()).expect("stderr utf8"); - assert!( - submit_output.status.success(), - "status: {:?}\nstdout:\n{stdout}\nstderr:\n{stderr}", - submit_output.status.code() - ); - let submit_json: Value = - serde_json::from_slice(submit_output.stdout.as_slice()).expect("submit json"); - assert_eq!(submit_json["state"], "accepted"); - assert_eq!(submit_json["signer_mode"], "nip46_session"); - assert_eq!(submit_json["signer_session_id"], "sess_order_02"); - assert_eq!(submit_json["requested_signer_session_id"], Value::Null); - - let recorded_requests = requests.lock().expect("requests lock"); - assert!( - recorded_requests - .iter() - .any(|request| request.method == "nip46.session.list") - ); - let request = recorded_requests - .iter() - .find(|request| request.method == "bridge.order.request") - .expect("bridge order request"); - assert!( - recorded_requests - .iter() - .any(|request| request.method == "nip46.get_public_key") - ); - assert_eq!(request.body["params"]["signer_session_id"], "sess_order_02"); - assert_eq!( - request.body["params"]["signer_authority"]["provider_runtime_id"], - "myc" - ); - assert_eq!( - request.body["params"]["signer_authority"]["account_identity_id"], - account_id - ); - assert_eq!( - request.body["params"]["signer_authority"]["provider_signer_session_id"], - "conn_order_binding_01" - ); -} - -#[test] -fn order_submit_rejects_myc_binding_that_resolves_the_wrong_actor() { - let _guard = order_test_guard(); - let dir = tempdir().expect("tempdir"); - - let account_output = order_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - - let new_output = order_command_in(dir.path()) - .args([ - "--json", - "order", - "new", - "--listing", - "pasture-eggs", - "--listing-addr", - "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", - "--bin", - "bin-1", - "--qty", - "2", - ]) - .output() - .expect("run order new"); - assert!(new_output.status.success()); - let new_json: Value = serde_json::from_slice(new_output.stdout.as_slice()).expect("new json"); - let order_id = new_json["order_id"].as_str().expect("order id"); - - let mismatch_account_output = order_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run mismatch account new"); - assert!(mismatch_account_output.status.success()); - let mismatch_account_json: Value = - serde_json::from_slice(mismatch_account_output.stdout.as_slice()).expect("mismatch json"); - let mismatch_account_id = mismatch_account_json["account"]["id"] - .as_str() - .expect("mismatch account id"); - let mismatch_public_identity = mismatch_account_json["public_identity"].clone(); - - let myc = write_fake_myc( - dir.path(), - successful_status_script( - sample_myc_status_payload( - mismatch_account_id, - &mismatch_public_identity, - "conn_order_binding_02", - ) - .to_string(), - ) - .as_str(), - ); - - let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); - let recorded = Arc::clone(&requests); - let server = MockRpcServer::start(move |body, auth_header| { - recorded - .lock() - .expect("recorded requests lock") - .push(MockRpcRequest { - body, - method: "unexpected".to_owned(), - auth_header, - }); - panic!("daemon write path should not be reached"); - }); - write_user_config( - dir.path(), - config_with_write_plane( - format!( - r#" -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "managed_instance" -target = "default" -managed_account_ref = "{mismatch_account_id}" -"# - ) - .as_str(), - server.url().as_str(), - ) - .as_str(), - ); - - let submit_output = order_command_in(dir.path()) - .env("RADROOTS_RPC_BEARER_TOKEN", "test-token") - .args([ - "--json", - "--signer", - "myc", - "--myc-executable", - myc.to_str().expect("myc path"), - "order", - "submit", - order_id, - ]) - .output() - .expect("run order submit"); - - assert_eq!(submit_output.status.code(), Some(3)); - let submit_json: Value = - serde_json::from_slice(submit_output.stdout.as_slice()).expect("submit json"); - assert_eq!(submit_json["state"], "unconfigured"); - assert_eq!(submit_json["signer_mode"], "myc"); - assert!(submit_json["reason"].as_str().is_some_and(|value| { - value.contains("configured myc signer binding resolves user pubkey") - })); - assert!(requests.lock().expect("requests lock").is_empty()); -} - -#[test] -fn order_submit_without_unique_matching_signer_session_exits_unconfigured() { - let _guard = order_test_guard(); - let dir = tempdir().expect("tempdir"); - - let account_output = order_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - let account_json: Value = - serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); - let buyer_pubkey = account_json["public_identity"]["public_key_hex"] - .as_str() - .expect("buyer pubkey") - .to_owned(); - - let new_output = order_command_in(dir.path()) - .args([ - "--json", - "order", - "new", - "--listing", - "pasture-eggs", - "--listing-addr", - "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", - "--bin", - "bin-1", - ]) - .output() - .expect("run order new"); - assert!(new_output.status.success()); - let new_json: Value = serde_json::from_slice(new_output.stdout.as_slice()).expect("new json"); - let order_id = new_json["order_id"].as_str().expect("order id"); - - let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); - let recorded = Arc::clone(&requests); - let server = MockRpcServer::start(move |body, _auth_header| { - recorded - .lock() - .expect("recorded requests lock") - .push(MockRpcRequest { - body: body.clone(), - method: body["method"].as_str().unwrap_or_default().to_owned(), - auth_header: None, - }); - match body["method"].as_str().unwrap_or_default() { - "nip46.session.list" => MockRpcResponse::success(json!([ - sample_session( - "sess_order_01", - buyer_pubkey.as_str(), - &["sign_event"], - true - ), - sample_session( - "sess_order_02", - buyer_pubkey.as_str(), - &["sign_event"], - true - ) - ])), - other => panic!("unexpected mock rpc method {other}"), - } - }); - write_user_config( - dir.path(), - config_with_write_plane("", server.url().as_str()).as_str(), - ); - - let submit_output = order_command_in(dir.path()) - .env("RADROOTS_RPC_BEARER_TOKEN", "test-token") - .args(["--json", "order", "submit", order_id]) - .output() - .expect("run order submit"); - assert_eq!(submit_output.status.code(), Some(3)); - let submit_json: Value = - serde_json::from_slice(submit_output.stdout.as_slice()).expect("submit json"); - assert_eq!(submit_json["state"], "unconfigured"); - assert!( - submit_json["reason"] - .as_str() - .expect("reason") - .contains("multiple authorized signer sessions matched buyer pubkey") - ); - - let recorded_requests = requests.lock().expect("requests lock"); - assert_eq!(recorded_requests.len(), 1); - assert_eq!(recorded_requests[0].method, "nip46.session.list"); -} - -#[test] -fn order_submit_rejects_requested_session_that_mismatches_buyer_pubkey() { - let _guard = order_test_guard(); - let dir = tempdir().expect("tempdir"); - - let account_output = order_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - - let new_output = order_command_in(dir.path()) - .args([ - "--json", - "order", - "new", - "--listing", - "pasture-eggs", - "--listing-addr", - "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", - "--bin", - "bin-1", - ]) - .output() - .expect("run order new"); - assert!(new_output.status.success()); - let new_json: Value = serde_json::from_slice(new_output.stdout.as_slice()).expect("new json"); - let order_id = new_json["order_id"].as_str().expect("order id"); - - let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); - let recorded = Arc::clone(&requests); - let server = MockRpcServer::start(move |body, _auth_header| { - recorded - .lock() - .expect("recorded requests lock") - .push(MockRpcRequest { - body: body.clone(), - method: body["method"].as_str().unwrap_or_default().to_owned(), - auth_header: None, - }); - match body["method"].as_str().unwrap_or_default() { - "nip46.session.list" => MockRpcResponse::success(json!([sample_session( - "sess_wrong_01", - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - &["sign_event"], - true - )])), - other => panic!("unexpected mock rpc method {other}"), - } - }); - write_user_config( - dir.path(), - config_with_write_plane("", server.url().as_str()).as_str(), - ); - - let submit_output = order_command_in(dir.path()) - .env("RADROOTS_RPC_BEARER_TOKEN", "test-token") - .args([ - "--json", - "order", - "submit", - order_id, - "--signer-session-id", - "sess_wrong_01", - ]) - .output() - .expect("run order submit"); - assert_eq!(submit_output.status.code(), Some(3)); - let submit_json: Value = - serde_json::from_slice(submit_output.stdout.as_slice()).expect("submit json"); - assert_eq!(submit_json["state"], "unconfigured"); - assert!( - submit_json["reason"] - .as_str() - .expect("reason") - .contains("does not match buyer pubkey") - ); - - let recorded_requests = requests.lock().expect("requests lock"); - assert_eq!(recorded_requests.len(), 1); - assert_eq!(recorded_requests[0].method, "nip46.session.list"); -} - -fn sample_session( - session_id: &str, - signer_pubkey: &str, - permissions: &[&str], - authorized: bool, -) -> Value { - sample_session_with_authority( - session_id, - signer_pubkey, - permissions, - authorized, - None, - None, - ) -} - -fn sample_session_with_authority( - session_id: &str, - signer_pubkey: &str, - permissions: &[&str], - authorized: bool, - account_identity_id: Option<&str>, - provider_signer_session_id: Option<&str>, -) -> Value { - json!({ - "session_id": session_id, - "role": "remote_signer", - "client_pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "signer_pubkey": signer_pubkey, - "user_pubkey": signer_pubkey, - "relays": ["wss://relay.one"], - "permissions": permissions, - "auth_required": false, - "authorized": authorized, - "expires_in_secs": Value::Null, - "signer_authority": account_identity_id.map(|account_identity_id| json!({ - "provider_runtime_id": "myc", - "account_identity_id": account_identity_id, - "provider_signer_session_id": provider_signer_session_id - })) - }) -} - -#[test] -fn order_history_lists_submitted_order_drafts() { - let _guard = order_test_guard(); - let dir = tempdir().expect("tempdir"); - let drafts_dir = data_root(dir.path()).join("apps/cli/orders/drafts"); - fs::create_dir_all(&drafts_dir).expect("create drafts dir"); - fs::write( - drafts_dir.join("ord_AAAAAAAAAAAAAAAAAAAAAg.toml"), - r#"version = 1 -kind = "order_draft_v1" -listing_lookup = "fresh-eggs" -buyer_account_id = "acct_demo" - -[order] -order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg" -listing_addr = "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg" -buyer_pubkey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -seller_pubkey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" - -[[order.items]] -bin_id = "bin-1" -bin_count = 2 - -[submission] -job_id = "job_order_01" -state = "accepted" -command = "order.submit" -submitted_at_unix = 1712720000 -"#, - ) - .expect("write history draft"); - - let json_output = order_command_in(dir.path()) - .args(["--json", "order", "history"]) - .output() - .expect("run order history json"); - assert!(json_output.status.success()); - let json: Value = serde_json::from_slice(json_output.stdout.as_slice()).expect("history json"); - assert_eq!(json["count"], 1); - assert_eq!(json["orders"][0]["id"], "ord_AAAAAAAAAAAAAAAAAAAAAg"); - assert_eq!(json["orders"][0]["state"], "accepted"); - - let ndjson_output = order_command_in(dir.path()) - .args(["--ndjson", "order", "history"]) - .output() - .expect("run order history ndjson"); - assert!(ndjson_output.status.success()); - let ndjson = String::from_utf8(ndjson_output.stdout).expect("history ndjson"); - let lines = ndjson.lines().collect::<Vec<_>>(); - assert_eq!(lines.len(), 1); - assert!(lines[0].contains("ord_AAAAAAAAAAAAAAAAAAAAAg")); -} - -#[test] -fn order_cancel_is_truthfully_narrowed_when_trade_chain_state_is_unavailable() { - let _guard = order_test_guard(); - let dir = tempdir().expect("tempdir"); - let drafts_dir = data_root(dir.path()).join("apps/cli/orders/drafts"); - fs::create_dir_all(&drafts_dir).expect("create drafts dir"); - fs::write( - drafts_dir.join("ord_AAAAAAAAAAAAAAAAAAAAAg.toml"), - r#"version = 1 -kind = "order_draft_v1" -listing_lookup = "fresh-eggs" -buyer_account_id = "acct_demo" - -[order] -order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg" -listing_addr = "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg" -buyer_pubkey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -seller_pubkey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" - -[[order.items]] -bin_id = "bin-1" -bin_count = 2 - -[submission] -job_id = "job_order_01" -state = "accepted" -command = "order.submit" -"#, - ) - .expect("write cancel draft"); - - let output = order_command_in(dir.path()) - .args(["--json", "order", "cancel", "ord_AAAAAAAAAAAAAAAAAAAAAg"]) - .output() - .expect("run order cancel"); - assert_eq!(output.status.code(), Some(3)); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("cancel json"); - assert_eq!(json["state"], "unconfigured"); - assert!( - json["reason"] - .as_str() - .expect("cancel reason") - .contains("trade-chain") - ); -} diff --git a/tests/relay_net.rs b/tests/relay_net.rs @@ -1,154 +0,0 @@ -use std::fs; -use std::path::Path; -use std::process::Command; - -use assert_cmd::prelude::*; -use serde_json::Value; -use tempfile::tempdir; - -fn cli_command_in(workdir: &Path) -> Command { - let mut command = Command::cargo_bin("radroots").expect("binary"); - command.current_dir(workdir); - command.env("HOME", workdir.join("home")); - for key in [ - "RADROOTS_ENV_FILE", - "RADROOTS_OUTPUT", - "RADROOTS_CLI_LOGGING_FILTER", - "RADROOTS_CLI_LOGGING_OUTPUT_DIR", - "RADROOTS_CLI_LOGGING_STDOUT", - "RADROOTS_CLI_PATHS_PROFILE", - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", - "RADROOTS_LOG_FILTER", - "RADROOTS_LOG_DIR", - "RADROOTS_LOG_STDOUT", - "RADROOTS_ACCOUNT", - "RADROOTS_ACCOUNT_SECRET_BACKEND", - "RADROOTS_ACCOUNT_SECRET_FALLBACK", - "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", - "RADROOTS_IDENTITY_PATH", - "RADROOTS_SIGNER", - "RADROOTS_RELAYS", - "RADROOTS_MYC_EXECUTABLE", - "RADROOTS_MYC_STATUS_TIMEOUT_MS", - "RADROOTS_RPC_URL", - "RADROOTS_RPC_BEARER_TOKEN", - ] { - command.env_remove(key); - } - command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); - command -} - -#[test] -fn relay_ls_json_reports_workspace_configured_relays() { - let dir = tempdir().expect("tempdir"); - let config_dir = dir.path().join("infra/local/runtime/radroots"); - fs::create_dir_all(&config_dir).expect("workspace config dir"); - fs::write( - config_dir.join("config.toml"), - "[relay]\nurls = [\"wss://relay.one\", \"wss://relay.two\"]\npublish_policy = \"any\"\n", - ) - .expect("write workspace config"); - - let output = cli_command_in(dir.path()) - .env("RADROOTS_CLI_PATHS_PROFILE", "repo_local") - .env("RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", &config_dir) - .args(["--json", "relay", "ls"]) - .output() - .expect("run relay ls"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("relay json"); - assert_eq!(json["state"], "configured"); - assert_eq!(json["count"], 2); - assert_eq!(json["publish_policy"], "any"); - assert_eq!(json["source"], "workspace config ยท local first"); - assert_eq!(json["relays"][0]["url"], "wss://relay.one"); - assert_eq!(json["relays"][0]["read"], true); - assert_eq!(json["relays"][0]["write"], true); - assert_eq!(json["relays"][1]["url"], "wss://relay.two"); -} - -#[test] -fn relay_ls_ndjson_emits_one_object_per_relay() { - let dir = tempdir().expect("tempdir"); - let output = cli_command_in(dir.path()) - .args([ - "--ndjson", - "--relay", - "wss://relay.one", - "--relay", - "wss://relay.two", - "relay", - "ls", - ]) - .output() - .expect("run relay ls"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let lines = stdout.lines().collect::<Vec<_>>(); - assert_eq!(lines.len(), 2); - assert!(lines[0].contains("\"url\":\"wss://relay.one\"")); - assert!(lines[1].contains("\"url\":\"wss://relay.two\"")); -} - -#[test] -fn relay_ls_without_relays_exits_unconfigured() { - let dir = tempdir().expect("tempdir"); - let output = cli_command_in(dir.path()) - .args(["relay", "ls"]) - .output() - .expect("run relay ls"); - - assert_eq!(output.status.code(), Some(3)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Not ready yet")); - assert!(stdout.contains("Missing")); - assert!(stdout.contains("Relay configuration")); - assert!(stdout.contains("no relays are configured")); -} - -#[test] -fn net_status_json_reports_effective_network_configuration() { - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(init.status.success()); - let init_json: Value = serde_json::from_slice(init.stdout.as_slice()).expect("account json"); - let account_id = init_json["account"]["id"] - .as_str() - .expect("account id") - .to_owned(); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--signer", - "local", - "--relay", - "wss://relay.one", - "--relay", - "wss://relay.two", - "net", - "status", - ]) - .output() - .expect("run net status"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("net json"); - assert_eq!(json["state"], "configured"); - assert_eq!(json["session"], "not_started"); - assert_eq!(json["relay_count"], 2); - assert_eq!(json["publish_policy"], "any"); - assert_eq!(json["signer_mode"], "local"); - assert_eq!(json["account_resolution"]["source"], "default_account"); - assert_eq!( - json["account_resolution"]["resolved_account"]["id"], - account_id - ); - assert_eq!(json["source"], "cli flags ยท local first"); -} diff --git a/tests/runtime_management.rs b/tests/runtime_management.rs @@ -1,367 +0,0 @@ -use std::fs; -use std::fs::File; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::thread; -use std::time::Duration; - -use assert_cmd::prelude::*; -use flate2::Compression; -use flate2::write::GzEncoder; -use serde_json::Value; -use tar::Builder; -use tempfile::tempdir; - -fn appdata_root(workdir: &Path) -> PathBuf { - workdir.join("roaming").join("Radroots") -} - -fn localappdata_root(workdir: &Path) -> PathBuf { - workdir.join("local").join("Radroots") -} - -fn interactive_root(workdir: &Path) -> PathBuf { - if cfg!(windows) { - localappdata_root(workdir) - } else { - workdir.join("home").join(".radroots") - } -} - -fn config_root(workdir: &Path) -> PathBuf { - if cfg!(windows) { - appdata_root(workdir).join("config") - } else { - interactive_root(workdir).join("config") - } -} - -fn cache_root(workdir: &Path) -> PathBuf { - if cfg!(windows) { - localappdata_root(workdir).join("cache") - } else { - interactive_root(workdir).join("cache") - } -} - -fn runtime_manager_registry_path(workdir: &Path) -> PathBuf { - config_root(workdir).join("shared/runtime-manager/instances.toml") -} - -fn runtime_manager_artifact_cache_dir(workdir: &Path) -> PathBuf { - cache_root(workdir) - .join("shared/runtime-manager/artifacts") - .join("radrootsd") - .join("stable") -} - -fn runtime_command_in(workdir: &Path) -> Command { - let mut command = Command::cargo_bin("radroots").expect("binary"); - command.current_dir(workdir); - command.env("HOME", workdir.join("home")); - command.env("APPDATA", workdir.join("roaming")); - command.env("LOCALAPPDATA", workdir.join("local")); - for key in [ - "RADROOTS_ENV_FILE", - "RADROOTS_OUTPUT", - "RADROOTS_CLI_LOGGING_FILTER", - "RADROOTS_CLI_LOGGING_OUTPUT_DIR", - "RADROOTS_CLI_LOGGING_STDOUT", - "RADROOTS_CLI_PATHS_PROFILE", - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", - "RADROOTS_LOG_FILTER", - "RADROOTS_LOG_DIR", - "RADROOTS_LOG_STDOUT", - "RADROOTS_ACCOUNT", - "RADROOTS_ACCOUNT_SECRET_BACKEND", - "RADROOTS_ACCOUNT_SECRET_FALLBACK", - "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", - "RADROOTS_IDENTITY_PATH", - "RADROOTS_SIGNER", - "RADROOTS_RELAYS", - "RADROOTS_MYC_EXECUTABLE", - "RADROOTS_MYC_STATUS_TIMEOUT_MS", - "RADROOTS_RPC_URL", - "RADROOTS_RPC_BEARER_TOKEN", - ] { - command.env_remove(key); - } - command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); - command -} - -#[cfg(not(windows))] -fn current_server_target_id() -> &'static str { - if cfg!(target_os = "macos") && cfg!(target_arch = "aarch64") { - "aarch64-apple-darwin" - } else if cfg!(target_os = "macos") && cfg!(target_arch = "x86_64") { - "x86_64-apple-darwin" - } else if cfg!(target_os = "linux") && cfg!(target_arch = "aarch64") { - "aarch64-unknown-linux-gnu" - } else if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") { - "x86_64-unknown-linux-gnu" - } else { - panic!("unsupported host target for runtime-management tests") - } -} - -#[cfg(not(windows))] -fn write_cached_radrootsd_artifact(workdir: &Path) -> PathBuf { - let artifact_dir = runtime_manager_artifact_cache_dir(workdir); - fs::create_dir_all(&artifact_dir).expect("artifact dir"); - let file_name = format!( - "radrootsd-0.1.0-alpha.2-{}.tar.gz", - current_server_target_id() - ); - let archive_path = artifact_dir.join(file_name); - let script = artifact_dir.join("radrootsd"); - fs::write( - &script, - "#!/bin/sh\nprintf 'managed radrootsd started\\n' >> \"${TMPDIR:-/tmp}/radrootsd-managed.log\"\nsleep 30\n", - ) - .expect("write script"); - let file = File::create(&archive_path).expect("archive file"); - let encoder = GzEncoder::new(file, Compression::default()); - let mut builder = Builder::new(encoder); - builder - .append_path_with_name(&script, "radrootsd/bin/radrootsd") - .expect("append script"); - builder.finish().expect("finish archive"); - archive_path -} - -#[test] -fn runtime_status_reports_active_managed_target_truth() { - let dir = tempdir().expect("tempdir"); - let output = runtime_command_in(dir.path()) - .args(["--json", "runtime", "status", "radrootsd"]) - .output() - .expect("runtime status"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["runtime_id"], "radrootsd"); - assert_eq!(json["runtime_group"], "active_managed_target"); - assert_eq!(json["management_posture"], "active_managed_target"); - assert_eq!(json["state"], "not_installed"); - assert_eq!(json["install_state"], "not_installed"); - assert_eq!(json["health_state"], "not_installed"); - assert_eq!(json["instance_id"], "local"); - assert_eq!(json["instance_source"], "bootstrap_default"); - assert_eq!(json["management_mode"], "interactive_user_managed"); -} - -#[test] -fn runtime_status_reports_defined_future_target_truth() { - let dir = tempdir().expect("tempdir"); - let output = runtime_command_in(dir.path()) - .args(["--json", "runtime", "status", "myc"]) - .output() - .expect("runtime status"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["runtime_id"], "myc"); - assert_eq!(json["runtime_group"], "defined_managed_target"); - assert_eq!(json["management_posture"], "defined_future_target"); - assert_eq!(json["state"], "defined_not_active"); - assert_eq!(json["lifecycle_actions"], Value::Array(vec![])); -} - -#[cfg(not(windows))] -#[test] -fn runtime_manages_radrootsd_lifecycle_end_to_end() { - let dir = tempdir().expect("tempdir"); - let artifact_path = write_cached_radrootsd_artifact(dir.path()); - - let install = runtime_command_in(dir.path()) - .args(["--json", "runtime", "install", "radrootsd"]) - .output() - .expect("runtime install"); - assert!(install.status.success()); - let install_json: Value = - serde_json::from_slice(install.stdout.as_slice()).expect("install json"); - assert_eq!(install_json["action"], "install"); - assert_eq!(install_json["state"], "configured"); - assert!( - install_json["detail"] - .as_str() - .expect("detail") - .contains(artifact_path.display().to_string().as_str()) - ); - - let registry_path = runtime_manager_registry_path(dir.path()); - let registry_raw = fs::read_to_string(&registry_path).expect("registry"); - assert!(registry_raw.contains("health_endpoint = \"http://127.0.0.1:7070\"")); - assert!(registry_raw.contains("secret_material_ref = ")); - - let start = runtime_command_in(dir.path()) - .args(["--json", "runtime", "start", "radrootsd"]) - .output() - .expect("runtime start"); - assert!(start.status.success()); - let start_json: Value = serde_json::from_slice(start.stdout.as_slice()).expect("start json"); - assert_eq!(start_json["state"], "running"); - - thread::sleep(Duration::from_millis(150)); - - let running_status = runtime_command_in(dir.path()) - .args(["--json", "runtime", "status", "radrootsd"]) - .output() - .expect("runtime status"); - assert!(running_status.status.success()); - let running_json: Value = - serde_json::from_slice(running_status.stdout.as_slice()).expect("status json"); - assert_eq!(running_json["state"], "configured"); - assert_eq!(running_json["health_state"], "running"); - - let config_set = runtime_command_in(dir.path()) - .args([ - "--json", - "runtime", - "config", - "set", - "radrootsd", - "config.rpc.addr", - "127.0.0.1:7444", - ]) - .output() - .expect("runtime config set"); - assert!(config_set.status.success()); - let config_set_json: Value = - serde_json::from_slice(config_set.stdout.as_slice()).expect("config set json"); - assert_eq!(config_set_json["state"], "configured"); - - let updated_registry_raw = fs::read_to_string(&registry_path).expect("updated registry"); - assert!(updated_registry_raw.contains("health_endpoint = \"http://127.0.0.1:7444\"")); - - let stop = runtime_command_in(dir.path()) - .args(["--json", "runtime", "stop", "radrootsd"]) - .output() - .expect("runtime stop"); - assert!(stop.status.success()); - let stop_json: Value = serde_json::from_slice(stop.stdout.as_slice()).expect("stop json"); - assert_eq!(stop_json["state"], "stopped"); - - let uninstall = runtime_command_in(dir.path()) - .args(["--json", "runtime", "uninstall", "radrootsd"]) - .output() - .expect("runtime uninstall"); - assert!(uninstall.status.success()); - let uninstall_json: Value = - serde_json::from_slice(uninstall.stdout.as_slice()).expect("uninstall json"); - assert_eq!(uninstall_json["state"], "uninstalled"); - - let final_status = runtime_command_in(dir.path()) - .args(["--json", "runtime", "status", "radrootsd"]) - .output() - .expect("runtime status after uninstall"); - assert!(final_status.status.success()); - let final_json: Value = - serde_json::from_slice(final_status.stdout.as_slice()).expect("final status json"); - assert_eq!(final_json["state"], "not_installed"); - assert_eq!(final_json["health_state"], "not_installed"); -} - -#[test] -fn runtime_logs_reports_managed_log_locations() { - let dir = tempdir().expect("tempdir"); - let output = runtime_command_in(dir.path()) - .args(["--json", "runtime", "logs", "radrootsd"]) - .output() - .expect("runtime logs"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["runtime_id"], "radrootsd"); - assert_eq!(json["state"], "ready"); - assert!( - json["stdout_log_path"] - .as_str() - .expect("stdout log path") - .ends_with("shared/runtime-manager/radrootsd/local/stdout.log") - ); - assert!( - json["stderr_log_path"] - .as_str() - .expect("stderr log path") - .ends_with("shared/runtime-manager/radrootsd/local/stderr.log") - ); -} - -#[test] -fn runtime_config_show_uses_registered_instance_config_path() { - let dir = tempdir().expect("tempdir"); - let registry_path = runtime_manager_registry_path(dir.path()); - let config_path = dir.path().join("managed").join("radrootsd-local.toml"); - let token_path = dir.path().join("managed").join("bridge-token.txt"); - fs::create_dir_all(config_path.parent().expect("config parent")).expect("create config dir"); - fs::write( - &config_path, - "[metadata]\nname = \"managed-radrootsd\"\n[config.rpc]\naddr = \"127.0.0.1:7070\"\n[config.bridge]\nenabled = true\nbearer_token = \"redacted\"\n", - ) - .expect("write config"); - fs::write(&token_path, "redacted").expect("write token"); - fs::create_dir_all(registry_path.parent().expect("registry parent")).expect("registry dir"); - fs::write( - &registry_path, - format!( - r#"schema = "radroots_runtime-instance-registry" -schema_version = 1 - -[[instances]] -runtime_id = "radrootsd" -instance_id = "local" -management_mode = "interactive_user_managed" -install_state = "configured" -binary_path = "{binary_path}" -config_path = "{config_path}" -logs_path = "{logs_path}" -run_path = "{run_path}" -installed_version = "0.1.0" -health_endpoint = "http://127.0.0.1:7070" -secret_material_ref = "{secret_material_ref}" -"#, - binary_path = dir.path().join("bin/radrootsd").display(), - config_path = config_path.display(), - logs_path = dir.path().join("managed/logs/radrootsd-local").display(), - run_path = dir.path().join("managed/run/radrootsd-local").display(), - secret_material_ref = token_path.display(), - ), - ) - .expect("write registry"); - - let output = runtime_command_in(dir.path()) - .args(["--json", "runtime", "config", "show", "radrootsd"]) - .output() - .expect("runtime config show"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["runtime_id"], "radrootsd"); - assert_eq!(json["config_format"], "toml"); - assert_eq!(json["config_path"], config_path.display().to_string()); - assert_eq!(json["config_present"], true); - assert_eq!(json["requires_bootstrap_secret"], true); - assert_eq!(json["requires_config_bootstrap"], true); -} - -#[test] -fn runtime_logs_rejects_bootstrap_only_runtime() { - let dir = tempdir().expect("tempdir"); - let output = runtime_command_in(dir.path()) - .args(["--json", "runtime", "logs", "hyf"]) - .output() - .expect("runtime logs"); - - assert_eq!(output.status.code(), Some(5)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["runtime_id"], "hyf"); - assert_eq!(json["runtime_group"], "bootstrap_only"); - assert_eq!(json["state"], "unsupported"); -} diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs @@ -1,873 +0,0 @@ -use std::fs; -use std::path::Path; -use std::process::Command; - -use assert_cmd::prelude::*; -use serde_json::Value; -use tempfile::tempdir; - -fn appdata_root(workdir: &Path) -> std::path::PathBuf { - workdir.join("roaming").join("Radroots") -} - -fn localappdata_root(workdir: &Path) -> std::path::PathBuf { - workdir.join("local").join("Radroots") -} - -fn interactive_root(workdir: &Path) -> std::path::PathBuf { - if cfg!(windows) { - localappdata_root(workdir) - } else { - workdir.join("home").join(".radroots") - } -} - -fn config_root(workdir: &Path) -> std::path::PathBuf { - if cfg!(windows) { - appdata_root(workdir).join("config") - } else { - interactive_root(workdir).join("config") - } -} - -fn runtime_manager_registry_path(workdir: &Path) -> std::path::PathBuf { - config_root(workdir).join("shared/runtime-manager/instances.toml") -} - -fn data_root(workdir: &Path) -> std::path::PathBuf { - if cfg!(windows) { - localappdata_root(workdir).join("data") - } else { - interactive_root(workdir).join("data") - } -} - -fn logs_root(workdir: &Path) -> std::path::PathBuf { - if cfg!(windows) { - localappdata_root(workdir).join("logs") - } else { - interactive_root(workdir).join("logs") - } -} - -fn secrets_root(workdir: &Path) -> std::path::PathBuf { - if cfg!(windows) { - appdata_root(workdir).join("secrets") - } else { - interactive_root(workdir).join("secrets") - } -} - -fn runtime_show_command_in(workdir: &Path) -> Command { - let mut command = Command::cargo_bin("radroots").expect("binary"); - command.current_dir(workdir); - command.env("HOME", workdir.join("home")); - command.env("APPDATA", workdir.join("roaming")); - command.env("LOCALAPPDATA", workdir.join("local")); - for key in [ - "RADROOTS_ENV_FILE", - "RADROOTS_OUTPUT", - "RADROOTS_CLI_LOGGING_FILTER", - "RADROOTS_CLI_LOGGING_OUTPUT_DIR", - "RADROOTS_CLI_LOGGING_STDOUT", - "RADROOTS_CLI_PATHS_PROFILE", - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", - "RADROOTS_LOG_FILTER", - "RADROOTS_LOG_DIR", - "RADROOTS_LOG_STDOUT", - "RADROOTS_ACCOUNT", - "RADROOTS_ACCOUNT_SECRET_BACKEND", - "RADROOTS_ACCOUNT_SECRET_FALLBACK", - "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", - "RADROOTS_IDENTITY_PATH", - "RADROOTS_SIGNER", - "RADROOTS_RELAYS", - "RADROOTS_MYC_EXECUTABLE", - "RADROOTS_MYC_STATUS_TIMEOUT_MS", - "RADROOTS_RPC_URL", - "RADROOTS_RPC_BEARER_TOKEN", - ] { - command.env_remove(key); - } - command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); - command -} - -fn binding_by_capability<'a>(json: &'a Value, capability_id: &str) -> &'a Value { - json["capability_bindings"] - .as_array() - .expect("capability bindings array") - .iter() - .find(|binding| binding["capability_id"] == capability_id) - .expect("binding present") -} - -#[test] -fn config_show_json_reports_default_bootstrap_state() { - let dir = tempdir().expect("tempdir"); - let output = runtime_show_command_in(dir.path()) - .args(["--json", "config", "show"]) - .output() - .expect("run config show"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["source"], "local runtime state"); - assert_eq!(json["output"]["format"], "json"); - assert_eq!(json["output"]["verbosity"], "normal"); - assert_eq!(json["output"]["color"], true); - assert_eq!(json["output"]["dry_run"], false); - assert_eq!(json["interaction"]["input_enabled"], true); - assert_eq!(json["interaction"]["assume_yes"], false); - assert_eq!(json["paths"]["profile"], "interactive_user"); - assert_eq!(json["paths"]["profile_source"], "default"); - assert_eq!(json["paths"]["root_source"], "host_defaults"); - assert_eq!(json["paths"]["repo_local_root"], Value::Null); - assert_eq!(json["paths"]["repo_local_root_source"], Value::Null); - assert_eq!( - json["paths"]["subordinate_path_override_source"], - "runtime_config" - ); - assert_eq!(json["paths"]["allowed_profiles"][0], "interactive_user"); - assert_eq!(json["paths"]["allowed_profiles"][1], "repo_local"); - assert_eq!(json["paths"]["app_namespace"], "apps/cli"); - assert_eq!( - json["paths"]["shared_accounts_namespace"], - "shared/accounts" - ); - assert_eq!( - json["paths"]["shared_identities_namespace"], - "shared/identities" - ); - assert_eq!( - json["paths"]["app_config_path"], - config_root(dir.path()) - .join("apps/cli/config.toml") - .display() - .to_string() - ); - assert_eq!(json["paths"]["workspace_config_enabled"], false); - assert_eq!(json["paths"]["workspace_config_path"], Value::Null); - assert_eq!( - json["paths"]["app_data_root"], - data_root(dir.path()).join("apps/cli").display().to_string() - ); - assert_eq!( - json["paths"]["app_logs_root"], - logs_root(dir.path()).join("apps/cli").display().to_string() - ); - assert_eq!( - json["paths"]["shared_accounts_data_root"], - data_root(dir.path()) - .join("shared/accounts") - .display() - .to_string() - ); - assert_eq!( - json["paths"]["shared_accounts_secrets_root"], - secrets_root(dir.path()) - .join("shared/accounts") - .display() - .to_string() - ); - assert_eq!( - json["paths"]["default_identity_path"], - secrets_root(dir.path()) - .join("shared/identities/default.json") - .display() - .to_string() - ); - assert_eq!( - json["migration"]["posture"], - "explicit_operator_import_required" - ); - assert_eq!(json["migration"]["state"], "ready"); - assert_eq!(json["migration"]["silent_startup_relocation"], false); - assert_eq!( - json["migration"]["compatibility_window"], - "detect_and_report_only" - ); - assert_eq!( - json["migration"]["detected_legacy_paths"], - Value::Array(vec![]) - ); - assert_eq!(json["migration"]["actions"], Value::Array(vec![])); - assert_eq!(json["logging"]["initialized"], true); - assert_eq!(json["logging"]["stdout"], false); - assert_eq!( - json["logging"]["directory"], - logs_root(dir.path()).join("apps/cli").display().to_string() - ); - assert_eq!(json["config_files"]["user_present"], false); - assert_eq!(json["config_files"]["workspace_present"], false); - assert_eq!(json["account"]["selector"], Value::Null); - assert_eq!( - json["account"]["store_path"], - data_root(dir.path()) - .join("shared/accounts/store.json") - .display() - .to_string() - ); - assert_eq!( - json["account"]["secrets_dir"], - secrets_root(dir.path()) - .join("shared/accounts") - .display() - .to_string() - ); - assert_eq!( - json["account"]["identity_path"], - secrets_root(dir.path()) - .join("shared/identities/default.json") - .display() - .to_string() - ); - assert_eq!( - json["account"]["secret_backend"]["contract_default_backend"], - "host_vault" - ); - assert_eq!( - json["account"]["secret_backend"]["contract_default_fallback"], - "encrypted_file" - ); - assert_eq!( - json["account"]["secret_backend"]["allowed_backends"] - .as_array() - .expect("allowed backends") - .len(), - 2 - ); - assert_eq!( - json["account"]["secret_backend"]["allowed_backends"][0], - "host_vault" - ); - assert_eq!( - json["account"]["secret_backend"]["allowed_backends"][1], - "encrypted_file" - ); - assert_eq!( - json["account"]["secret_backend"]["host_vault_policy"], - "desktop" - ); - assert_eq!( - json["account"]["secret_backend"]["uses_protected_store"], - true - ); - assert_eq!( - json["account"]["secret_backend"]["configured_primary"], - "host_vault" - ); - assert_eq!( - json["account"]["secret_backend"]["configured_fallback"], - "encrypted_file" - ); - assert_eq!(json["account"]["secret_backend"]["state"], "ready"); - assert_eq!( - json["account"]["secret_backend"]["active_backend"], - "encrypted_file" - ); - assert_eq!(json["account"]["secret_backend"]["used_fallback"], true); - assert_eq!(json["signer"]["mode"], "local"); - assert_eq!(json["relay"]["count"], 0); - assert_eq!(json["relay"]["publish_policy"], "any"); - assert_eq!(json["relay"]["source"], "defaults ยท local first"); - assert_eq!( - json["local"]["root"], - data_root(dir.path()) - .join("apps/cli/replica") - .display() - .to_string() - ); - assert_eq!( - json["local"]["replica_db_path"], - data_root(dir.path()) - .join("apps/cli/replica/replica.sqlite") - .display() - .to_string() - ); - assert_eq!(json["myc"]["executable"], "myc"); - assert_eq!(json["myc"]["status_timeout_ms"], 2000); - assert_eq!(json["write_plane"]["provider_runtime_id"], "radrootsd"); - assert_eq!( - json["write_plane"]["binding_model"], - "daemon_backed_jsonrpc" - ); - assert_eq!(json["write_plane"]["state"], "unconfigured"); - assert_eq!(json["write_plane"]["provenance"], "unavailable"); - assert_eq!( - json["write_plane"]["source"], - "no explicit capability binding or managed preferred default" - ); - assert!(json["write_plane"]["target"].is_null()); - assert_eq!(json["write_plane"]["bridge_auth_configured"], false); - assert_eq!(json["workflow"]["provider_runtime_id"], "rhi"); - assert_eq!(json["workflow"]["binding_model"], "out_of_process_worker"); - assert_eq!(json["workflow"]["state"], "not_configured"); - assert_eq!(json["workflow"]["provenance"], "unavailable"); - assert_eq!(json["workflow"]["source"], "no explicit capability binding"); - assert_eq!(json["workflow"]["hyf_helper_state"], "not_implied"); - assert_eq!(json["hyf_provider"]["provider_runtime_id"], "hyf"); - assert_eq!(json["hyf_provider"]["binding_model"], "stdio_service"); - assert_eq!(json["hyf_provider"]["state"], "disabled"); - assert_eq!(json["hyf_provider"]["provenance"], "disabled"); - assert_eq!( - json["hyf_provider"]["source"], - "hyf status control request ยท local first" - ); - assert_eq!(json["rpc"]["url"], "http://127.0.0.1:7070"); - assert_eq!(json["rpc"]["bridge_auth_configured"], false); - assert_eq!( - json["resolved_providers"] - .as_array() - .expect("resolved providers") - .len(), - 3 - ); - assert_eq!( - json["capability_bindings"] - .as_array() - .expect("capability bindings") - .len(), - 4 - ); - let signer = binding_by_capability(&json, "signer.remote_nip46"); - assert_eq!(signer["provider_runtime_id"], "myc"); - assert_eq!(signer["binding_model"], "session_authorized_remote_signer"); - assert_eq!(signer["state"], "disabled"); - assert_eq!(signer["source"], "independent local signer mode"); - let write = binding_by_capability(&json, "write_plane.trade_jsonrpc"); - assert_eq!(write["provider_runtime_id"], "radrootsd"); - assert_eq!(write["binding_model"], "daemon_backed_jsonrpc"); - assert_eq!(write["state"], "not_configured"); - let workflow = binding_by_capability(&json, "workflow.trade"); - assert_eq!(workflow["provider_runtime_id"], "rhi"); - assert_eq!(workflow["binding_model"], "out_of_process_worker"); - assert_eq!(workflow["state"], "not_configured"); - let inference = binding_by_capability(&json, "inference.hyf_stdio"); - assert_eq!(inference["provider_runtime_id"], "hyf"); - assert_eq!(inference["binding_model"], "stdio_service"); - assert_eq!(inference["state"], "disabled"); - assert_eq!(inference["source"], "hyf disabled by config"); -} - -#[test] -fn config_show_machine_output_rejects_stdout_logging() { - let dir = tempdir().expect("tempdir"); - let output = runtime_show_command_in(dir.path()) - .args(["--json", "--log-stdout", "config", "show"]) - .output() - .expect("run config show"); - - assert_eq!(output.status.code(), Some(2)); - assert!(output.stdout.is_empty()); - let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); - assert!(stderr.contains("stdout logging")); - assert!(stderr.contains("json output")); - - let env_output = runtime_show_command_in(dir.path()) - .env("RADROOTS_CLI_LOGGING_STDOUT", "true") - .args(["--json", "config", "show"]) - .output() - .expect("run config show"); - - assert_eq!(env_output.status.code(), Some(2)); - assert!(env_output.stdout.is_empty()); - let stderr = String::from_utf8(env_output.stderr).expect("utf8 stderr"); - assert!(stderr.contains("RADROOTS_CLI_LOGGING_STDOUT")); -} - -#[test] -fn config_show_json_reports_detected_legacy_cli_paths_without_moving_them() { - let dir = tempdir().expect("tempdir"); - let home = dir.path().join("home"); - let old_config = home.join(".config/radroots/config.toml"); - let old_state_root = home.join(".local/share/radroots"); - fs::create_dir_all(old_config.parent().expect("old config parent")).expect("old config dir"); - fs::create_dir_all(old_state_root.join("accounts")).expect("old state dir"); - fs::write(&old_config, "[relay]\nurls = []\n").expect("old config"); - fs::write(old_state_root.join("accounts/store.json"), "{}").expect("old store"); - - let output = runtime_show_command_in(dir.path()) - .args(["--json", "config", "show"]) - .output() - .expect("run config show"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - - assert_eq!(json["migration"]["state"], "legacy_state_detected"); - assert_eq!(json["migration"]["silent_startup_relocation"], false); - let detected = json["migration"]["detected_legacy_paths"] - .as_array() - .expect("detected legacy paths array"); - assert_eq!(detected.len(), 2); - assert_eq!(detected[0]["id"], "cli_user_config_v0"); - assert_eq!(detected[0]["path"], old_config.display().to_string()); - assert_eq!( - detected[0]["destination"], - config_root(dir.path()) - .join("apps/cli/config.toml") - .display() - .to_string() - ); - assert_eq!(detected[1]["id"], "cli_user_state_root_v0"); - assert_eq!(detected[1]["path"], old_state_root.display().to_string()); - assert!( - json["migration"]["actions"] - .as_array() - .expect("actions") - .iter() - .any(|action| action - .as_str() - .is_some_and(|value| value.contains("startup did not move legacy data"))) - ); -} - -#[test] -fn config_show_json_reports_repo_local_paths_when_requested() { - let dir = tempdir().expect("tempdir"); - let repo_local_root = dir.path().join(".local/radroots/dev"); - let output = runtime_show_command_in(dir.path()) - .env("RADROOTS_CLI_PATHS_PROFILE", "repo_local") - .env("RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", &repo_local_root) - .args(["--json", "config", "show"]) - .output() - .expect("run config show"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - - assert_eq!(json["paths"]["profile"], "repo_local"); - assert_eq!( - json["paths"]["profile_source"], - "process_env:RADROOTS_CLI_PATHS_PROFILE" - ); - assert_eq!(json["paths"]["root_source"], "repo_local_root"); - assert_eq!( - json["paths"]["repo_local_root"], - repo_local_root.display().to_string() - ); - assert_eq!( - json["paths"]["repo_local_root_source"], - "process_env:RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT" - ); - assert_eq!(json["paths"]["allowed_profiles"][0], "interactive_user"); - assert_eq!(json["paths"]["allowed_profiles"][1], "repo_local"); - assert_eq!( - json["paths"]["app_config_path"], - repo_local_root - .join("config/apps/cli/config.toml") - .display() - .to_string() - ); - assert_eq!( - json["paths"]["workspace_config_path"], - repo_local_root.join("config.toml").display().to_string() - ); - assert_eq!(json["paths"]["workspace_config_enabled"], true); - assert_eq!( - json["paths"]["app_data_root"], - repo_local_root.join("data/apps/cli").display().to_string() - ); - assert_eq!( - json["paths"]["app_logs_root"], - repo_local_root.join("logs/apps/cli").display().to_string() - ); - assert_eq!( - json["paths"]["shared_accounts_data_root"], - repo_local_root - .join("data/shared/accounts") - .display() - .to_string() - ); - assert_eq!( - json["paths"]["shared_accounts_secrets_root"], - repo_local_root - .join("secrets/shared/accounts") - .display() - .to_string() - ); - assert_eq!( - json["paths"]["default_identity_path"], - repo_local_root - .join("secrets/shared/identities/default.json") - .display() - .to_string() - ); -} - -#[test] -fn config_show_json_reads_repo_local_workspace_config_from_explicit_root() { - let dir = tempdir().expect("tempdir"); - let repo_local_root = dir.path().join("repo-runtime"); - fs::create_dir_all(&repo_local_root).expect("repo-local root"); - fs::write( - repo_local_root.join("config.toml"), - "[relay]\nurls = [\"wss://relay.repo-local\"]\npublish_policy = \"any\"\n", - ) - .expect("write repo-local workspace config"); - - let output = runtime_show_command_in(dir.path()) - .env("RADROOTS_CLI_PATHS_PROFILE", "repo_local") - .env("RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", &repo_local_root) - .args(["--json", "config", "show"]) - .output() - .expect("run config show"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - assert_eq!( - json["paths"]["workspace_config_path"], - repo_local_root.join("config.toml").display().to_string() - ); - assert_eq!(json["paths"]["workspace_config_enabled"], true); - assert_eq!(json["config_files"]["workspace_present"], true); - assert_eq!(json["relay"]["count"], 1); - assert_eq!(json["relay"]["urls"][0], "wss://relay.repo-local"); - assert_eq!(json["relay"]["source"], "workspace config ยท local first"); -} - -#[test] -fn config_show_json_reflects_environment_configuration() { - let dir = tempdir().expect("tempdir"); - let output = runtime_show_command_in(dir.path()) - .env("RADROOTS_OUTPUT", "json") - .env("RADROOTS_LOG_FILTER", "debug") - .env("RADROOTS_LOG_DIR", "logs/runtime") - .env("RADROOTS_LOG_STDOUT", "false") - .env("RADROOTS_ACCOUNT", "acct_demo") - .env("RADROOTS_IDENTITY_PATH", "state/identity.json") - .env("RADROOTS_SIGNER", "myc") - .env("RADROOTS_RELAYS", "wss://relay.one,wss://relay.two") - .env("RADROOTS_MYC_EXECUTABLE", "bin/myc") - .env("RADROOTS_MYC_STATUS_TIMEOUT_MS", "3500") - .env("RADROOTS_RPC_URL", "https://rpc.radroots.test/jsonrpc") - .env("RADROOTS_RPC_BEARER_TOKEN", "secret") - .args(["config", "show"]) - .output() - .expect("run config show"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["output"]["format"], "json"); - assert_eq!(json["logging"]["filter"], "debug"); - assert_eq!(json["logging"]["directory"], "logs/runtime"); - assert_eq!(json["account"]["selector"], "acct_demo"); - assert_eq!(json["account"]["identity_path"], "state/identity.json"); - assert_eq!( - json["account"]["secret_backend"]["active_backend"], - "encrypted_file" - ); - assert_eq!(json["signer"]["mode"], "myc"); - assert_eq!(json["relay"]["count"], 2); - assert_eq!(json["relay"]["urls"][0], "wss://relay.one"); - assert_eq!(json["relay"]["source"], "environment ยท local first"); - assert_eq!(json["myc"]["executable"], "bin/myc"); - assert_eq!(json["myc"]["status_timeout_ms"], 3500); - assert_eq!(json["rpc"]["url"], "https://rpc.radroots.test/jsonrpc"); - assert_eq!(json["rpc"]["bridge_auth_configured"], true); -} - -#[test] -fn config_show_json_reflects_global_output_flags() { - let dir = tempdir().expect("tempdir"); - let output = runtime_show_command_in(dir.path()) - .args([ - "--output", - "json", - "--trace", - "--dry-run", - "--no-color", - "--no-input", - "--yes", - "config", - "show", - ]) - .output() - .expect("run config show"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["output"]["format"], "json"); - assert_eq!(json["output"]["verbosity"], "trace"); - assert_eq!(json["output"]["color"], false); - assert_eq!(json["output"]["dry_run"], true); - assert_eq!(json["interaction"]["input_enabled"], false); - assert_eq!(json["interaction"]["assume_yes"], true); -} - -#[test] -fn config_show_json_reads_logging_from_default_env_file() { - let temp = tempdir().expect("tempdir"); - let env_path = temp.path().join(".env"); - let logs_dir = temp.path().join("logs").join("radroots-cli"); - fs::write( - &env_path, - format!( - "RADROOTS_CLI_LOGGING_FILTER=debug,radroots_cli=trace\nRADROOTS_CLI_LOGGING_OUTPUT_DIR={}\nRADROOTS_CLI_LOGGING_STDOUT=false\n", - logs_dir.display() - ), - ) - .expect("write env file"); - - let output = runtime_show_command_in(temp.path()) - .args(["--json", "config", "show"]) - .output() - .expect("run config show"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["logging"]["filter"], "debug,radroots_cli=trace"); - assert_eq!(json["logging"]["directory"], logs_dir.display().to_string()); - let current_file = json["logging"]["current_file"] - .as_str() - .expect("current log file"); - assert!(current_file.starts_with(logs_dir.display().to_string().as_str())); - assert!(std::path::Path::new(current_file).exists()); -} - -#[test] -fn config_show_json_reads_workspace_relay_config() { - let dir = tempdir().expect("tempdir"); - let config_dir = dir.path().join("infra/local/runtime/radroots"); - fs::create_dir_all(&config_dir).expect("workspace config dir"); - fs::write( - config_dir.join("config.toml"), - "[relay]\nurls = [\"wss://relay.workspace\", \"wss://relay.backup\"]\npublish_policy = \"any\"\n", - ) - .expect("write workspace config"); - - let output = runtime_show_command_in(dir.path()) - .env("RADROOTS_CLI_PATHS_PROFILE", "repo_local") - .env("RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", &config_dir) - .args(["--json", "config", "show"]) - .output() - .expect("run config show"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - assert_eq!(json["config_files"]["workspace_present"], true); - assert_eq!(json["relay"]["count"], 2); - assert_eq!(json["relay"]["urls"][0], "wss://relay.workspace"); - assert_eq!(json["relay"]["urls"][1], "wss://relay.backup"); - assert_eq!(json["relay"]["source"], "workspace config ยท local first"); -} - -#[test] -fn config_show_reads_workspace_rpc_config() { - let dir = tempdir().expect("tempdir"); - let config_dir = dir.path().join("infra/local/runtime/radroots"); - fs::create_dir_all(&config_dir).expect("workspace config dir"); - fs::write( - config_dir.join("config.toml"), - "[rpc]\nurl = \"https://rpc.workspace.test/jsonrpc\"\n", - ) - .expect("write workspace config"); - - let output = runtime_show_command_in(dir.path()) - .env("RADROOTS_CLI_PATHS_PROFILE", "repo_local") - .env("RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", &config_dir) - .args(["--json", "config", "show"]) - .output() - .expect("run config show"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - assert_eq!(json["rpc"]["url"], "https://rpc.workspace.test/jsonrpc"); - assert_eq!(json["rpc"]["bridge_auth_configured"], false); -} - -#[test] -fn config_show_reports_explicit_capability_bindings() { - let dir = tempdir().expect("tempdir"); - let workspace_config_dir = dir.path().join("infra/local/runtime/radroots"); - let user_config_dir = workspace_config_dir.join("config/apps/cli"); - fs::create_dir_all(&workspace_config_dir).expect("workspace config dir"); - fs::create_dir_all(&user_config_dir).expect("user config dir"); - fs::write( - workspace_config_dir.join("config.toml"), - r#" -[[capability_binding]] -capability = "write_plane.trade_jsonrpc" -provider = "radrootsd" -target_kind = "explicit_endpoint" -target = "https://rpc.workspace.test/jsonrpc" - -[[capability_binding]] -capability = "inference.hyf_stdio" -provider = "hyf" -target_kind = "managed_instance" -target = "workspace-hyf" -"#, - ) - .expect("write workspace config"); - fs::write( - user_config_dir.join("config.toml"), - r#" -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "managed_instance" -target = "default" -managed_account_ref = "acct_demo" -signer_session_ref = "session_demo" - -[[capability_binding]] -capability = "workflow.trade" -provider = "rhi" -target_kind = "managed_instance" -target = "workflow-default" - -[[capability_binding]] -capability = "inference.hyf_stdio" -provider = "hyf" -target_kind = "explicit_endpoint" -target = "bin/hyfd-user" -"#, - ) - .expect("write user config"); - - let output = runtime_show_command_in(dir.path()) - .env("RADROOTS_CLI_PATHS_PROFILE", "repo_local") - .env("RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", &workspace_config_dir) - .args(["--json", "config", "show"]) - .output() - .expect("run config show"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - - let signer = binding_by_capability(&json, "signer.remote_nip46"); - assert_eq!(signer["state"], "configured"); - assert_eq!(signer["source"], "user config [[capability_binding]]"); - assert_eq!(signer["target_kind"], "managed_instance"); - assert_eq!(signer["target"], "default"); - assert_eq!(signer["managed_account_ref"], "acct_demo"); - assert_eq!(signer["signer_session_ref"], "session_demo"); - - let write = binding_by_capability(&json, "write_plane.trade_jsonrpc"); - assert_eq!(write["state"], "configured"); - assert_eq!(write["source"], "workspace config [[capability_binding]]"); - assert_eq!(write["target_kind"], "explicit_endpoint"); - assert_eq!(write["target"], "https://rpc.workspace.test/jsonrpc"); - - let workflow = binding_by_capability(&json, "workflow.trade"); - assert_eq!(workflow["state"], "configured"); - assert_eq!(workflow["source"], "user config [[capability_binding]]"); - assert_eq!(workflow["target_kind"], "managed_instance"); - assert_eq!(workflow["target"], "workflow-default"); - assert_eq!(json["workflow"]["provider_runtime_id"], "rhi"); - assert_eq!(json["workflow"]["state"], "unavailable"); - assert_eq!(json["workflow"]["provenance"], "managed_default"); - assert_eq!( - json["workflow"]["source"], - "user config [[capability_binding]]" - ); - assert_eq!(json["workflow"]["target_kind"], "managed_instance"); - assert_eq!(json["workflow"]["target"], "workflow-default"); - assert_eq!(json["workflow"]["hyf_helper_state"], "not_implied"); - assert!( - json["workflow"]["hyf_helper_detail"] - .as_str() - .is_some_and(|detail| detail.contains("do not imply")) - ); - assert_eq!(json["write_plane"]["state"], "unconfigured"); - assert_eq!(json["write_plane"]["provenance"], "explicit_binding"); - assert_eq!( - json["write_plane"]["source"], - "workspace config [[capability_binding]]" - ); - assert_eq!(json["write_plane"]["target_kind"], "explicit_endpoint"); - assert_eq!( - json["write_plane"]["target"], - "https://rpc.workspace.test/jsonrpc" - ); - assert_eq!(json["write_plane"]["bridge_auth_configured"], false); - assert_eq!(json["hyf_provider"]["provider_runtime_id"], "hyf"); - assert_eq!(json["hyf_provider"]["provenance"], "explicit_binding"); - assert_eq!(json["hyf_provider"]["target_kind"], "explicit_endpoint"); - assert_eq!(json["hyf_provider"]["target"], "bin/hyfd-user"); - assert_eq!(json["hyf_provider"]["executable"], "bin/hyfd-user"); - - let inference = binding_by_capability(&json, "inference.hyf_stdio"); - assert_eq!(inference["state"], "configured"); - assert_eq!(inference["source"], "user config [[capability_binding]]"); - assert_eq!(inference["target_kind"], "explicit_endpoint"); - assert_eq!(inference["target"], "bin/hyfd-user"); -} - -#[test] -fn config_show_uses_managed_default_write_plane_when_local_instance_exists() { - let dir = tempdir().expect("tempdir"); - let registry_path = runtime_manager_registry_path(dir.path()); - fs::create_dir_all(registry_path.parent().expect("registry parent")).expect("registry dir"); - let managed_config_path = dir.path().join("managed-radrootsd.toml"); - let bridge_token_path = dir.path().join("managed-bridge-token.txt"); - fs::write( - &managed_config_path, - "[metadata]\nname = \"managed-radrootsd\"\n", - ) - .expect("write managed config"); - fs::write(&bridge_token_path, "managed-bridge-token").expect("write managed token"); - fs::write( - &registry_path, - format!( - r#"schema = "radroots_runtime-instance-registry" -schema_version = 1 - -[[instances]] -runtime_id = "radrootsd" -instance_id = "local" -management_mode = "interactive_user_managed" -install_state = "configured" -binary_path = "/tmp/radrootsd" -config_path = "{}" -logs_path = "/tmp/radrootsd/logs" -run_path = "/tmp/radrootsd/run" -installed_version = "0.1.0" -health_endpoint = "http://127.0.0.1:7444" -secret_material_ref = "{}" -"#, - managed_config_path.display(), - bridge_token_path.display() - ), - ) - .expect("write managed registry"); - - let output = runtime_show_command_in(dir.path()) - .args(["--json", "config", "show"]) - .output() - .expect("run config show"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); - assert_eq!(json["write_plane"]["state"], "configured"); - assert_eq!(json["write_plane"]["provenance"], "managed_default"); - assert_eq!( - json["write_plane"]["source"], - "managed preferred radrootsd instance" - ); - assert_eq!(json["write_plane"]["target_kind"], "managed_instance"); - assert_eq!(json["write_plane"]["target"], "local"); - assert_eq!(json["write_plane"]["bridge_auth_configured"], true); -} - -#[test] -fn config_show_rejects_ndjson_for_singular_output() { - let dir = tempdir().expect("tempdir"); - let output = runtime_show_command_in(dir.path()) - .args(["--ndjson", "config", "show"]) - .output() - .expect("run config show"); - - assert_eq!(output.status.code(), Some(2)); - assert!(output.stdout.is_empty()); - let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); - assert!(stderr.contains("`config show` does not support --ndjson")); -} diff --git a/tests/sell.rs b/tests/sell.rs @@ -1,414 +0,0 @@ -use std::fs; -use std::path::Path; -use std::process::Command; -use std::sync::{Mutex, MutexGuard, OnceLock}; - -use assert_cmd::prelude::*; -use serde_json::Value; -use tempfile::tempdir; - -fn cli_command_in(workdir: &Path) -> Command { - let mut command = Command::cargo_bin("radroots").expect("binary"); - command.current_dir(workdir); - command.env("HOME", workdir.join("home")); - command.env("APPDATA", workdir.join("roaming")); - command.env("LOCALAPPDATA", workdir.join("local")); - for key in [ - "RADROOTS_ENV_FILE", - "RADROOTS_OUTPUT", - "RADROOTS_CLI_LOGGING_FILTER", - "RADROOTS_CLI_LOGGING_OUTPUT_DIR", - "RADROOTS_CLI_LOGGING_STDOUT", - "RADROOTS_CLI_PATHS_PROFILE", - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", - "RADROOTS_LOG_FILTER", - "RADROOTS_LOG_DIR", - "RADROOTS_LOG_STDOUT", - "RADROOTS_ACCOUNT", - "RADROOTS_ACCOUNT_SECRET_BACKEND", - "RADROOTS_ACCOUNT_SECRET_FALLBACK", - "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", - "RADROOTS_HYF_ENABLED", - "RADROOTS_HYF_EXECUTABLE", - "RADROOTS_IDENTITY_PATH", - "RADROOTS_SIGNER", - "RADROOTS_RELAYS", - "RADROOTS_MYC_EXECUTABLE", - "RADROOTS_MYC_STATUS_TIMEOUT_MS", - "RADROOTS_RPC_URL", - "RADROOTS_RPC_BEARER_TOKEN", - ] { - command.env_remove(key); - } - command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); - command -} - -fn sell_test_guard() -> MutexGuard<'static, ()> { - static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) -} - -fn bootstrap_seller(workdir: &Path) { - let account_output = cli_command_in(workdir) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - - let farm_output = cli_command_in(workdir) - .args([ - "--json", - "farm", - "setup", - "--name", - "La Huerta", - "--location", - "San Francisco, CA", - "--city", - "San Francisco", - "--region", - "CA", - "--country", - "US", - "--delivery-method", - "pickup", - ]) - .output() - .expect("run farm setup"); - assert!(farm_output.status.success()); -} - -#[test] -fn sell_add_creates_named_local_draft_from_human_flags() { - let _guard = sell_test_guard(); - let dir = tempdir().expect("tempdir"); - bootstrap_seller(dir.path()); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "sell", - "add", - "tomatoes", - "--pack", - "1 kg", - "--price", - "10 USD/kg", - "--stock", - "25", - ]) - .output() - .expect("run sell add"); - assert!(output.status.success()); - - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); - assert_eq!(json["state"], "draft_saved"); - assert_eq!(json["product_key"], "tomatoes"); - assert_eq!(json["title"], "Tomatoes"); - assert_eq!(json["offer"], "1 kg"); - assert_eq!(json["price"], "10 USD/kg"); - assert_eq!(json["stock"], "25 available"); - assert_eq!(json["farm_name"], "La Huerta"); - assert_eq!(json["delivery_method"], "pickup"); - assert_eq!(json["location_primary"], "San Francisco, CA"); - assert_eq!( - json["actions"][0], - "radroots sell check listing-tomatoes.toml" - ); - assert_eq!( - json["actions"][1], - "radroots sell publish listing-tomatoes.toml" - ); - - let draft_path = dir.path().join("listing-tomatoes.toml"); - let contents = fs::read_to_string(&draft_path).expect("draft contents"); - assert!(contents.contains("key = \"tomatoes\"")); - assert!(contents.contains("title = \"Tomatoes\"")); - assert!(contents.contains("category = \"Tomatoes\"")); - assert!(contents.contains("quantity_amount = \"1\"")); - assert!(contents.contains("quantity_unit = \"kg\"")); - assert!(contents.contains("price_amount = \"10\"")); - assert!(contents.contains("price_currency = \"USD\"")); - assert!(contents.contains("price_per_amount = \"1\"")); - assert!(contents.contains("price_per_unit = \"kg\"")); - assert!(contents.contains("available = \"25\"")); - - let human_output = cli_command_in(dir.path()) - .args([ - "sell", "add", "potatoes", "--pack", "2 kg", "--price", "6 USD/kg", "--stock", "10", - ]) - .output() - .expect("run human sell add"); - assert!(human_output.status.success()); - let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Listing draft saved")); - assert!(stdout.contains("The draft is local until you publish it.")); - assert!(stdout.contains("Draft")); - assert!(stdout.contains("Defaults")); - assert!(stdout.contains("radroots sell check listing-potatoes.toml")); -} - -#[test] -fn sell_show_reads_local_draft_only() { - let _guard = sell_test_guard(); - let dir = tempdir().expect("tempdir"); - bootstrap_seller(dir.path()); - - let add = cli_command_in(dir.path()) - .args([ - "sell", - "add", - "tomatoes", - "--pack", - "1 kg", - "--price", - "10 USD/kg", - "--stock", - "25", - ]) - .output() - .expect("run sell add"); - assert!(add.status.success()); - - let output = cli_command_in(dir.path()) - .args(["--json", "sell", "show", "listing-tomatoes.toml"]) - .output() - .expect("run sell show"); - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); - assert_eq!(json["state"], "ready"); - assert_eq!(json["product_key"], "tomatoes"); - assert_eq!(json["title"], "Tomatoes"); - assert_eq!(json["category"], "Tomatoes"); - assert_eq!(json["offer"], "1 kg"); - assert_eq!(json["price"], "10 USD/kg"); - assert_eq!(json["stock"], "25 available"); - assert_eq!(json["delivery_method"], "pickup"); - assert_eq!(json["location_primary"], "San Francisco, CA"); - assert_eq!( - json["actions"][0], - "radroots sell check listing-tomatoes.toml" - ); - - let human_output = cli_command_in(dir.path()) - .args(["sell", "show", "listing-tomatoes.toml"]) - .output() - .expect("run human sell show"); - assert!(human_output.status.success()); - let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Listing draft")); - assert!(stdout.contains("tomatoes")); - assert!(stdout.contains("San Francisco, CA")); - assert!(!stdout.contains("listing ยท")); -} - -#[test] -fn sell_check_reports_ready_and_invalid_drafts() { - let _guard = sell_test_guard(); - let dir = tempdir().expect("tempdir"); - bootstrap_seller(dir.path()); - - let add = cli_command_in(dir.path()) - .args([ - "sell", - "add", - "tomatoes", - "--pack", - "1 kg", - "--price", - "10 USD/kg", - "--stock", - "25", - ]) - .output() - .expect("run sell add"); - assert!(add.status.success()); - - let ready_output = cli_command_in(dir.path()) - .args(["--json", "sell", "check", "listing-tomatoes.toml"]) - .output() - .expect("run ready sell check"); - assert!(ready_output.status.success()); - let ready_json: Value = - serde_json::from_slice(ready_output.stdout.as_slice()).expect("ready json"); - assert_eq!(ready_json["state"], "ready"); - assert_eq!(ready_json["valid"], true); - assert_eq!(ready_json["product_key"], "tomatoes"); - assert_eq!( - ready_json["actions"][0], - "radroots sell publish listing-tomatoes.toml" - ); - - let draft_path = dir.path().join("listing-tomatoes.toml"); - let broken = fs::read_to_string(&draft_path) - .expect("draft contents") - .replace("price_amount = \"10\"", "price_amount = \"\""); - fs::write(&draft_path, broken).expect("write broken draft"); - - let invalid_output = cli_command_in(dir.path()) - .args(["sell", "check", "listing-tomatoes.toml"]) - .output() - .expect("run invalid sell check"); - assert!(invalid_output.status.success()); - let stdout = String::from_utf8(invalid_output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Draft needs changes")); - assert!(stdout.contains("primary_bin.price_amount")); - assert!(stdout.contains("radroots sell show listing-tomatoes.toml")); - assert!(stdout.contains("Edit the draft file and run the command again")); -} - -#[test] -fn sell_reprice_and_restock_mutate_draft_file() { - let _guard = sell_test_guard(); - let dir = tempdir().expect("tempdir"); - bootstrap_seller(dir.path()); - - let add = cli_command_in(dir.path()) - .args([ - "sell", - "add", - "tomatoes", - "--pack", - "1 kg", - "--price", - "10 USD/kg", - "--stock", - "25", - ]) - .output() - .expect("run sell add"); - assert!(add.status.success()); - - let reprice_output = cli_command_in(dir.path()) - .args([ - "--json", - "sell", - "reprice", - "listing-tomatoes.toml", - "12 USD/kg", - ]) - .output() - .expect("run sell reprice"); - assert!(reprice_output.status.success()); - let reprice_json: Value = - serde_json::from_slice(reprice_output.stdout.as_slice()).expect("reprice json"); - assert_eq!(reprice_json["state"], "updated"); - assert_eq!(reprice_json["operation"], "reprice"); - assert_eq!(reprice_json["changed_label"], "Price"); - assert_eq!(reprice_json["changed_value"], "12 USD/kg"); - - let restock_output = cli_command_in(dir.path()) - .args(["--json", "sell", "restock", "listing-tomatoes.toml", "40"]) - .output() - .expect("run sell restock"); - assert!(restock_output.status.success()); - let restock_json: Value = - serde_json::from_slice(restock_output.stdout.as_slice()).expect("restock json"); - assert_eq!(restock_json["state"], "updated"); - assert_eq!(restock_json["operation"], "restock"); - assert_eq!(restock_json["changed_label"], "Stock"); - assert_eq!(restock_json["changed_value"], "40 available"); - - let contents = fs::read_to_string(dir.path().join("listing-tomatoes.toml")).expect("draft"); - assert!(contents.contains("price_amount = \"12\"")); - assert!(contents.contains("available = \"40\"")); -} - -#[test] -fn sell_publish_update_and_pause_wrap_listing_dry_runs() { - let _guard = sell_test_guard(); - let dir = tempdir().expect("tempdir"); - bootstrap_seller(dir.path()); - - let add = cli_command_in(dir.path()) - .args([ - "sell", - "add", - "tomatoes", - "--pack", - "1 kg", - "--price", - "10 USD/kg", - "--stock", - "25", - ]) - .output() - .expect("run sell add"); - assert!(add.status.success()); - - let publish_output = cli_command_in(dir.path()) - .args([ - "--dry-run", - "--json", - "sell", - "publish", - "listing-tomatoes.toml", - ]) - .output() - .expect("run sell publish dry run"); - assert!(publish_output.status.success()); - let publish_json: Value = - serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json"); - assert_eq!(publish_json["state"], "dry_run"); - assert_eq!(publish_json["operation"], "publish"); - assert_eq!(publish_json["product_key"], "tomatoes"); - assert_eq!( - publish_json["actions"][0], - "radroots sell publish listing-tomatoes.toml" - ); - - let update_output = cli_command_in(dir.path()) - .args([ - "--dry-run", - "--json", - "sell", - "update", - "listing-tomatoes.toml", - ]) - .output() - .expect("run sell update dry run"); - assert!(update_output.status.success()); - let update_json: Value = - serde_json::from_slice(update_output.stdout.as_slice()).expect("update json"); - assert_eq!(update_json["state"], "dry_run"); - assert_eq!(update_json["operation"], "update"); - assert_eq!(update_json["product_key"], "tomatoes"); - assert_eq!( - update_json["actions"][0], - "radroots sell update listing-tomatoes.toml" - ); - - let pause_output = cli_command_in(dir.path()) - .args([ - "--dry-run", - "--json", - "sell", - "pause", - "listing-tomatoes.toml", - ]) - .output() - .expect("run sell pause dry run"); - assert!(pause_output.status.success()); - let pause_json: Value = - serde_json::from_slice(pause_output.stdout.as_slice()).expect("pause json"); - assert_eq!(pause_json["state"], "dry_run"); - assert_eq!(pause_json["operation"], "pause"); - assert_eq!(pause_json["product_key"], "tomatoes"); - assert_eq!( - pause_json["actions"][0], - "radroots sell pause listing-tomatoes.toml" - ); - - let human_output = cli_command_in(dir.path()) - .args(["--dry-run", "sell", "publish", "listing-tomatoes.toml"]) - .output() - .expect("run human sell publish dry run"); - assert!(human_output.status.success()); - let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Dry run only")); - assert!(stdout.contains("Listing would be published.")); - assert!(stdout.contains("Nothing was written.")); -} diff --git a/tests/signer_status.rs b/tests/signer_status.rs @@ -1,201 +0,0 @@ -use std::fs; -use std::path::Path; -use std::process::Command; - -use assert_cmd::prelude::*; -use serde_json::Value; -use tempfile::tempdir; - -fn data_root(workdir: &Path) -> std::path::PathBuf { - if cfg!(windows) { - workdir.join("local").join("Radroots").join("data") - } else { - workdir.join("home").join(".radroots").join("data") - } -} - -fn cli_command_in(workdir: &Path) -> Command { - let mut command = Command::cargo_bin("radroots").expect("binary"); - command.current_dir(workdir); - command.env("HOME", workdir.join("home")); - command.env("APPDATA", workdir.join("roaming")); - command.env("LOCALAPPDATA", workdir.join("local")); - for key in [ - "RADROOTS_ENV_FILE", - "RADROOTS_OUTPUT", - "RADROOTS_CLI_LOGGING_FILTER", - "RADROOTS_CLI_LOGGING_OUTPUT_DIR", - "RADROOTS_CLI_LOGGING_STDOUT", - "RADROOTS_CLI_PATHS_PROFILE", - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", - "RADROOTS_LOG_FILTER", - "RADROOTS_LOG_DIR", - "RADROOTS_LOG_STDOUT", - "RADROOTS_ACCOUNT", - "RADROOTS_ACCOUNT_SECRET_BACKEND", - "RADROOTS_ACCOUNT_SECRET_FALLBACK", - "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", - "RADROOTS_IDENTITY_PATH", - "RADROOTS_SIGNER", - "RADROOTS_RELAYS", - "RADROOTS_MYC_EXECUTABLE", - "RADROOTS_MYC_STATUS_TIMEOUT_MS", - "RADROOTS_RPC_URL", - "RADROOTS_RPC_BEARER_TOKEN", - ] { - command.env_remove(key); - } - command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); - command -} - -#[test] -fn signer_status_reports_local_ready_when_account_exists() { - let dir = tempdir().expect("tempdir"); - - let init = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(init.status.success()); - - let output = cli_command_in(dir.path()) - .args(["--json", "--signer", "local", "signer", "status"]) - .output() - .expect("run signer status"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["mode"], "local"); - assert_eq!(json["state"], "ready"); - assert_eq!(json["source"], "shared account store ยท local first"); - assert_eq!(json["signer_account_id"], json["local"]["account_id"]); - assert_eq!(json["account_resolution"]["source"], "default_account"); - assert_eq!(json["reason"], Value::Null); - assert_eq!(json["binding"]["state"], "disabled"); - assert_eq!(json["binding"]["source"], "independent local signer mode"); - assert_eq!(json["local"]["availability"], "secret_backed"); - assert_eq!(json["local"]["secret_backed"], true); - assert_eq!(json["local"]["backend"], "encrypted_file"); - assert_eq!(json["local"]["used_fallback"], true); -} - -#[test] -fn signer_status_reports_local_unconfigured_when_no_account_is_selected() { - let dir = tempdir().expect("tempdir"); - - let output = cli_command_in(dir.path()) - .args(["--json", "--signer", "local", "signer", "status"]) - .output() - .expect("run signer status"); - - assert_eq!(output.status.code(), Some(3)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["mode"], "local"); - assert_eq!(json["state"], "unconfigured"); - assert_eq!(json["binding"]["state"], "disabled"); - assert!( - json["reason"] - .as_str() - .is_some_and(|value| value.contains("no local accounts found")) - ); - assert_eq!(json["local"], Value::Null); -} - -#[test] -fn signer_status_reports_internal_error_for_invalid_account_store_file() { - let dir = tempdir().expect("tempdir"); - let accounts_dir = data_root(dir.path()).join("shared/accounts"); - fs::create_dir_all(&accounts_dir).expect("create accounts dir"); - fs::write(accounts_dir.join("store.json"), "{ not valid json").expect("write invalid store"); - - let output = cli_command_in(dir.path()) - .args(["--json", "--signer", "local", "signer", "status"]) - .output() - .expect("run signer status"); - - assert_eq!(output.status.code(), Some(1)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); - assert_eq!(json["mode"], "local"); - assert_eq!(json["state"], "error"); - assert!(json["reason"].as_str().is_some()); - assert_eq!(json["local"], Value::Null); -} - -#[test] -fn signer_status_honors_explicit_account_selector_over_default_account() { - let dir = tempdir().expect("tempdir"); - - let first = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run first account new"); - assert!(first.status.success()); - let first_json: Value = serde_json::from_slice(first.stdout.as_slice()).expect("first json"); - let first_id = first_json["account"]["id"] - .as_str() - .expect("first account id") - .to_owned(); - - let second = cli_command_in(dir.path()) - .args(["--json", "account", "new"]) - .output() - .expect("run second account new"); - assert!(second.status.success()); - - let output = cli_command_in(dir.path()) - .args([ - "--json", - "--signer", - "local", - "--account", - first_id.as_str(), - "signer", - "status", - ]) - .output() - .expect("run signer status"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("signer json"); - assert_eq!(json["mode"], "local"); - assert_eq!(json["state"], "ready"); - assert_eq!(json["signer_account_id"], first_id); - assert_eq!(json["account_resolution"]["source"], "invocation_override"); - assert_eq!( - json["account_resolution"]["resolved_account"]["id"], - first_id - ); - assert_eq!(json["local"]["account_id"], first_id); - assert_eq!(json["local"]["backend"], "encrypted_file"); - assert_eq!(json["local"]["used_fallback"], true); -} - -#[test] -fn signer_status_reports_explicit_host_vault_fallback_selection_truthfully() { - let dir = tempdir().expect("tempdir"); - - let init = cli_command_in(dir.path()) - .env("RADROOTS_ACCOUNT_SECRET_BACKEND", "host_vault") - .env("RADROOTS_ACCOUNT_SECRET_FALLBACK", "encrypted_file") - .args(["--json", "account", "new"]) - .output() - .expect("run account new"); - assert!(init.status.success()); - - let output = cli_command_in(dir.path()) - .env("RADROOTS_ACCOUNT_SECRET_BACKEND", "host_vault") - .env("RADROOTS_ACCOUNT_SECRET_FALLBACK", "encrypted_file") - .args(["--json", "--signer", "local", "signer", "status"]) - .output() - .expect("run signer status"); - - assert!(output.status.success()); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("signer json"); - assert_eq!(json["state"], "ready"); - assert_eq!(json["local"]["backend"], "encrypted_file"); - assert_eq!(json["local"]["used_fallback"], true); -} diff --git a/tests/sync.rs b/tests/sync.rs @@ -1,200 +0,0 @@ -use std::fs; -use std::path::Path; -use std::process::Command; - -use assert_cmd::prelude::*; -use serde_json::Value; -use tempfile::tempdir; - -fn cli_command_in(workdir: &Path) -> Command { - let mut command = Command::cargo_bin("radroots").expect("binary"); - command.current_dir(workdir); - command.env("HOME", workdir.join("home")); - for key in [ - "RADROOTS_ENV_FILE", - "RADROOTS_OUTPUT", - "RADROOTS_CLI_LOGGING_FILTER", - "RADROOTS_CLI_LOGGING_OUTPUT_DIR", - "RADROOTS_CLI_LOGGING_STDOUT", - "RADROOTS_CLI_PATHS_PROFILE", - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", - "RADROOTS_LOG_FILTER", - "RADROOTS_LOG_DIR", - "RADROOTS_LOG_STDOUT", - "RADROOTS_ACCOUNT", - "RADROOTS_ACCOUNT_SECRET_BACKEND", - "RADROOTS_ACCOUNT_SECRET_FALLBACK", - "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", - "RADROOTS_IDENTITY_PATH", - "RADROOTS_SIGNER", - "RADROOTS_RELAYS", - "RADROOTS_MYC_EXECUTABLE", - "RADROOTS_MYC_STATUS_TIMEOUT_MS", - "RADROOTS_RPC_URL", - "RADROOTS_RPC_BEARER_TOKEN", - ] { - command.env_remove(key); - } - command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); - command -} - -#[test] -fn sync_status_reports_unconfigured_when_local_replica_is_missing() { - let dir = tempdir().expect("tempdir"); - let output = cli_command_in(dir.path()) - .args(["--json", "sync", "status"]) - .output() - .expect("run sync status"); - - assert_eq!(output.status.code(), Some(3)); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("sync json"); - assert_eq!(json["state"], "unconfigured"); - assert_eq!(json["replica_db"], "missing"); - assert_eq!(json["freshness"]["display"], "never synced"); - assert_eq!(json["actions"][0], "radroots local init"); -} - -#[test] -fn sync_status_reports_queue_and_relay_setup_need_after_local_init() { - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - - let output = cli_command_in(dir.path()) - .args(["--json", "sync", "status"]) - .output() - .expect("run sync status"); - - assert_eq!(output.status.code(), Some(3)); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("sync json"); - assert_eq!(json["state"], "unconfigured"); - assert_eq!(json["replica_db"], "ready"); - assert_eq!(json["queue"]["pending_count"], 0); - assert_eq!(json["freshness"]["display"], "never synced"); - assert_eq!( - json["actions"][0], - "radroots relay ls --relay wss://relay.example.com" - ); -} - -#[test] -fn sync_pull_and_push_are_honestly_narrowed_until_relay_plane_lands() { - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - let config_dir = dir.path().join("home/.radroots/config/apps/cli"); - fs::create_dir_all(&config_dir).expect("user config dir"); - fs::write( - config_dir.join("config.toml"), - "[relay]\nurls = [\"wss://relay.one\"]\npublish_policy = \"any\"\n", - ) - .expect("write user config"); - - let pull = cli_command_in(dir.path()) - .args(["--json", "sync", "pull"]) - .output() - .expect("run sync pull"); - assert_eq!(pull.status.code(), Some(4)); - let pull_json: Value = serde_json::from_slice(pull.stdout.as_slice()).expect("pull json"); - assert_eq!(pull_json["direction"], "pull"); - assert_eq!(pull_json["state"], "unavailable"); - assert_eq!(pull_json["relay_count"], 1); - assert!( - pull_json["reason"] - .as_str() - .is_some_and(|reason| reason.contains("relay ingest")) - ); - - let push = cli_command_in(dir.path()) - .args(["--json", "sync", "push"]) - .output() - .expect("run sync push"); - assert_eq!(push.status.code(), Some(4)); - let push_json: Value = serde_json::from_slice(push.stdout.as_slice()).expect("push json"); - assert_eq!(push_json["direction"], "push"); - assert_eq!(push_json["state"], "unavailable"); - assert_eq!(push_json["queue"]["pending_count"], 0); - assert!( - push_json["reason"] - .as_str() - .is_some_and(|reason| reason.contains("relay publish")) - ); -} - -#[test] -fn sync_watch_ndjson_emits_one_frame_per_poll() { - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - let config_dir = dir.path().join("home/.radroots/config/apps/cli"); - fs::create_dir_all(&config_dir).expect("user config dir"); - fs::write( - config_dir.join("config.toml"), - "[relay]\nurls = [\"wss://relay.one\", \"wss://relay.two\"]\npublish_policy = \"any\"\n", - ) - .expect("write user config"); - - let output = cli_command_in(dir.path()) - .args([ - "--ndjson", - "sync", - "watch", - "--frames", - "2", - "--interval-ms", - "1", - ]) - .output() - .expect("run sync watch"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let lines = stdout.lines().collect::<Vec<_>>(); - assert_eq!(lines.len(), 2); - assert!(lines[0].contains("\"sequence\":1")); - assert!(lines[0].contains("\"state\":\"ready\"")); - assert!(lines[1].contains("\"sequence\":2")); - assert!(lines[1].contains("\"relay_count\":2")); -} - -#[test] -fn sync_watch_human_appends_readable_snapshots_without_screen_clear() { - let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); - let config_dir = dir.path().join("home/.radroots/config/apps/cli"); - fs::create_dir_all(&config_dir).expect("user config dir"); - fs::write( - config_dir.join("config.toml"), - "[relay]\nurls = [\"wss://relay.one\", \"wss://relay.two\"]\npublish_policy = \"any\"\n", - ) - .expect("write user config"); - - let output = cli_command_in(dir.path()) - .args(["sync", "watch", "--frames", "2", "--interval-ms", "1"]) - .output() - .expect("run human sync watch"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Watching market sync")); - assert!(stdout.contains("State")); - assert!(stdout.contains("Ready")); - assert!(stdout.contains("Relays")); - assert!(stdout.contains("Queue")); - assert!(!stdout.contains("activity ยท")); - assert!(!stdout.contains("\u{1b}")); -} diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -0,0 +1,137 @@ +use std::process::Command; + +use assert_cmd::prelude::*; +use serde_json::Value; + +fn radroots() -> Command { + Command::cargo_bin("radroots").expect("binary") +} + +#[test] +fn root_help_exposes_only_mvp_namespaces() { + let output = radroots().arg("--help").output().expect("run root help"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + for namespace in [ + "workspace", + "health", + "config", + "account", + "signer", + "relay", + "store", + "sync", + "runtime", + "job", + "farm", + "listing", + "market", + "basket", + "order", + ] { + assert!( + help_lists(&stdout, namespace), + "root help should contain `{namespace}`" + ); + } + + for removed in [ + "setup", "status", "doctor", "sell", "find", "local", "net", "myc", "rpc", "product", + "message", "approval", "agent", + ] { + assert!( + !help_lists(&stdout, removed), + "root help should not contain `{removed}`" + ); + } +} + +fn help_lists(stdout: &str, command: &str) -> bool { + stdout.lines().any(|line| { + let line = line.trim_start(); + line == command || line.starts_with(&format!("{command} ")) + }) +} + +#[test] +fn removed_global_flags_are_rejected_publicly() { + for args in [ + ["--output", "json", "workspace", "get"].as_slice(), + ["--json", "workspace", "get"].as_slice(), + ["--ndjson", "workspace", "get"].as_slice(), + ["--yes", "workspace", "get"].as_slice(), + ["--non-interactive", "workspace", "get"].as_slice(), + ] { + let output = radroots().args(args).output().expect("run removed flag"); + + assert!(!output.status.success(), "`{args:?}` should be rejected"); + let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); + assert!(stderr.contains("unexpected argument") || stderr.contains("unrecognized")); + } +} + +#[test] +fn removed_command_families_are_rejected_publicly() { + for command in [ + "setup", "status", "doctor", "sell", "find", "local", "net", "myc", "rpc", "product", + "message", "approval", "agent", + ] { + let output = radroots() + .arg(command) + .output() + .expect("run removed command"); + + assert!(!output.status.success(), "`{command}` should be rejected"); + let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); + assert!(stderr.contains("unrecognized subcommand")); + } +} + +#[test] +fn target_command_outputs_standard_json_envelope() { + let output = radroots() + .args(["--format", "json", "workspace", "get"]) + .output() + .expect("run workspace get"); + + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let value: Value = serde_json::from_slice(&output.stdout).expect("json envelope"); + + assert_eq!(value["schema_version"], "radroots.cli.output.v1"); + assert_eq!(value["operation_id"], "workspace.get"); + assert_eq!(value["kind"], "workspace.get"); + assert_eq!(value["dry_run"], false); + assert_eq!(value["errors"].as_array().expect("errors").len(), 0); +} + +#[test] +fn unsupported_ndjson_returns_structured_invalid_input() { + let output = radroots() + .args(["--format", "ndjson", "workspace", "get"]) + .output() + .expect("run workspace get ndjson"); + + assert_eq!(output.status.code(), Some(2)); + let value: Value = serde_json::from_slice(&output.stdout).expect("json envelope"); + + assert_eq!(value["operation_id"], "workspace.get"); + assert_eq!(value["errors"][0]["code"], "invalid_input"); + assert_eq!(value["errors"][0]["exit_code"], 2); +} + +#[test] +fn required_approval_missing_token_returns_structured_error() { + let output = radroots() + .args(["--format", "json", "order", "submit"]) + .output() + .expect("run order submit"); + + assert_eq!(output.status.code(), Some(6)); + let value: Value = serde_json::from_slice(&output.stdout).expect("json envelope"); + + assert_eq!(value["operation_id"], "order.submit"); + assert_eq!(value["errors"][0]["code"], "approval_required"); + assert_eq!(value["errors"][0]["exit_code"], 6); +} diff --git a/tests/workflow.rs b/tests/workflow.rs @@ -1,274 +0,0 @@ -use std::fs; -use std::path::Path; -use std::process::Command; - -use assert_cmd::prelude::*; -use serde_json::Value; -use tempfile::tempdir; - -fn data_root(workdir: &Path) -> std::path::PathBuf { - if cfg!(windows) { - workdir.join("local").join("Radroots").join("data") - } else { - workdir.join("home").join(".radroots").join("data") - } -} - -fn cli_command_in(workdir: &Path) -> Command { - let mut command = Command::cargo_bin("radroots").expect("binary"); - command.current_dir(workdir); - command.env("HOME", workdir.join("home")); - command.env("APPDATA", workdir.join("roaming")); - command.env("LOCALAPPDATA", workdir.join("local")); - for key in [ - "RADROOTS_ENV_FILE", - "RADROOTS_OUTPUT", - "RADROOTS_CLI_LOGGING_FILTER", - "RADROOTS_CLI_LOGGING_OUTPUT_DIR", - "RADROOTS_CLI_LOGGING_STDOUT", - "RADROOTS_CLI_PATHS_PROFILE", - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", - "RADROOTS_LOG_FILTER", - "RADROOTS_LOG_DIR", - "RADROOTS_LOG_STDOUT", - "RADROOTS_ACCOUNT", - "RADROOTS_ACCOUNT_SECRET_BACKEND", - "RADROOTS_ACCOUNT_SECRET_FALLBACK", - "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", - "RADROOTS_IDENTITY_PATH", - "RADROOTS_SIGNER", - "RADROOTS_RELAYS", - "RADROOTS_MYC_EXECUTABLE", - "RADROOTS_MYC_STATUS_TIMEOUT_MS", - "RADROOTS_RPC_URL", - "RADROOTS_RPC_BEARER_TOKEN", - ] { - command.env_remove(key); - } - command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); - command -} - -fn write_user_config(workdir: &Path, contents: &str) { - let config_dir = workdir.join("home/.radroots/config/apps/cli"); - fs::create_dir_all(&config_dir).expect("user config dir"); - fs::write(config_dir.join("config.toml"), contents).expect("write user config"); -} - -#[test] -fn setup_seller_without_account_is_unconfigured_and_does_not_create_account() { - let dir = tempdir().expect("tempdir"); - let store_path = data_root(dir.path()).join("shared/accounts/store.json"); - - let output = cli_command_in(dir.path()) - .args(["setup", "seller"]) - .output() - .expect("run setup seller"); - - assert_eq!(output.status.code(), Some(3)); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Not ready yet")); - assert!(stdout.contains("Ready")); - assert!(stdout.contains("Resolved account")); - assert!(stdout.contains("Local market data")); - assert!(stdout.contains("Needs attention")); - assert!(stdout.contains("Relay configuration")); - assert!(stdout.contains("Account resolution")); - assert!(stdout.contains("radroots account create")); - assert!(stdout.contains("radroots setup seller")); - assert!(!store_path.exists()); - - let local_output = cli_command_in(dir.path()) - .args(["--json", "local", "status"]) - .output() - .expect("run local status"); - assert!(local_output.status.success()); - let local_json: Value = - serde_json::from_slice(local_output.stdout.as_slice()).expect("local json"); - assert_eq!(local_json["state"], "ready"); -} - -#[test] -fn setup_seller_with_default_account_reports_farm_attention() { - let dir = tempdir().expect("tempdir"); - let store_path = data_root(dir.path()).join("shared/accounts/store.json"); - - let account_output = cli_command_in(dir.path()) - .args(["account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - assert!(store_path.exists()); - - let output = cli_command_in(dir.path()) - .args(["setup", "seller"]) - .output() - .expect("run setup seller"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Setup saved")); - assert!(stdout.contains("Ready")); - assert!(stdout.contains("Resolved account")); - assert!(stdout.contains("Local market data")); - assert!(stdout.contains("Needs attention")); - assert!(stdout.contains("Relay configuration")); - assert!(stdout.contains("Farm draft")); - assert!(stdout.contains("Account resolution")); - assert!(stdout.contains("radroots farm init")); - assert!(stdout.contains("radroots status")); -} - -#[test] -fn status_is_unconfigured_before_account_setup() { - let dir = tempdir().expect("tempdir"); - - let output = cli_command_in(dir.path()) - .args(["--json", "status"]) - .output() - .expect("run status"); - - assert_eq!(output.status.code(), Some(3)); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("status json"); - assert_eq!(json["state"], "unconfigured"); - assert_eq!(json["ready"], Value::Array(Vec::new())); - assert_eq!( - json["needs_attention"], - serde_json::json!([ - "Resolved account", - "Local market data", - "Relay configuration" - ]) - ); - assert_eq!(json["next"], serde_json::json!(["radroots account create"])); -} - -#[test] -fn status_points_to_account_selection_when_accounts_exist_without_default() { - let dir = tempdir().expect("tempdir"); - let store_path = data_root(dir.path()).join("shared/accounts/store.json"); - - let account_output = cli_command_in(dir.path()) - .args(["account", "new"]) - .output() - .expect("run account new"); - assert!(account_output.status.success()); - let mut store_json: Value = - serde_json::from_slice(fs::read(&store_path).expect("read store").as_slice()) - .expect("parse store"); - store_json["default_account_id"] = Value::Null; - fs::write( - &store_path, - serde_json::to_vec_pretty(&store_json).expect("serialize store"), - ) - .expect("write store"); - - let output = cli_command_in(dir.path()) - .args(["--json", "status"]) - .output() - .expect("run status"); - - assert_eq!(output.status.code(), Some(3)); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("status json"); - assert_eq!(json["state"], "unconfigured"); - assert_eq!(json["account_resolution"]["source"], "none"); - assert_eq!( - json["next"], - serde_json::json!([ - "radroots account list", - "radroots account select <selector>" - ]) - ); -} - -#[test] -fn status_calls_out_missing_relay_after_buyer_setup() { - let dir = tempdir().expect("tempdir"); - let account = cli_command_in(dir.path()) - .args(["account", "new"]) - .output() - .expect("run account new"); - assert!(account.status.success()); - let setup = cli_command_in(dir.path()) - .args(["setup", "buyer"]) - .output() - .expect("run setup buyer"); - assert!(setup.status.success()); - - let output = cli_command_in(dir.path()) - .args(["--json", "status"]) - .output() - .expect("run status"); - - assert_eq!(output.status.code(), Some(3)); - let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("status json"); - assert_eq!(json["state"], "unconfigured"); - assert_eq!( - json["ready"], - serde_json::json!(["Resolved account", "Local market data"]) - ); - assert_eq!( - json["needs_attention"], - serde_json::json!(["Relay configuration"]) - ); - assert_eq!( - json["next"], - serde_json::json!([ - "radroots relay list --relay wss://relay.example.com", - "radroots status" - ]) - ); -} - -#[test] -fn status_reports_farm_publish_need_when_core_state_is_ready() { - let dir = tempdir().expect("tempdir"); - - let account = cli_command_in(dir.path()) - .args(["account", "new"]) - .output() - .expect("run account new"); - assert!(account.status.success()); - - let local = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(local.status.success()); - - write_user_config( - dir.path(), - "[relay]\nurls = [\"wss://relay.one\"]\npublish_policy = \"any\"\n", - ); - - let farm = cli_command_in(dir.path()) - .args([ - "farm", - "setup", - "--name", - "La Huerta", - "--location", - "San Francisco, CA", - ]) - .output() - .expect("run farm setup"); - assert!(farm.status.success()); - - let output = cli_command_in(dir.path()) - .args(["status"]) - .output() - .expect("run status"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("Status")); - assert!(stdout.contains("Ready")); - assert!(stdout.contains("Resolved account")); - assert!(stdout.contains("Account resolution")); - assert!(stdout.contains("Local market data")); - assert!(stdout.contains("Relay configuration")); - assert!(stdout.contains("Needs attention")); - assert!(stdout.contains("Farm not yet published")); - assert!(stdout.contains("radroots farm publish")); - assert!(stdout.contains("radroots sell add tomatoes")); -}