mod.rs (21803B)
1 #![allow(dead_code)] 2 3 use std::fs; 4 use std::path::{Path, PathBuf}; 5 use std::process::{Command, Output}; 6 use std::sync::Mutex; 7 8 use assert_cmd::prelude::*; 9 use radroots_events::RadrootsNostrEvent; 10 use radroots_events::ids::RadrootsListingAddress; 11 use radroots_events::kinds::{KIND_FARM, KIND_LISTING}; 12 use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; 13 use radroots_local_events::{ 14 LocalEventRecord, LocalEventRecordInput, LocalEventsStore, LocalRecordFamily, 15 LocalRecordStatus, PublishOutboxStatus, RelayDeliveryEvidence, SourceRuntime, 16 canonical_relay_set_fingerprint, 17 }; 18 use radroots_protected_store::RadrootsProtectedFileSecretVault; 19 use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event}; 20 use radroots_secret_vault::RadrootsSecretVault; 21 use radroots_sql_core::{SqlExecutor, SqliteExecutor}; 22 use serde_json::{Value, json}; 23 use tempfile::TempDir; 24 25 #[cfg(unix)] 26 use std::os::unix::fs::PermissionsExt; 27 28 static COMMAND_LOCK: Mutex<()> = Mutex::new(()); 29 pub const ORDERABLE_LISTING_RELAY: &str = "ws://127.0.0.1:9"; 30 31 pub fn radroots() -> Command { 32 Command::cargo_bin("radroots").expect("binary") 33 } 34 35 pub fn json_from_stdout(output: &Output) -> Value { 36 serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { 37 panic!( 38 "stdout was not json: {error}; stderr `{}`; stdout `{}`", 39 String::from_utf8_lossy(&output.stderr), 40 String::from_utf8_lossy(&output.stdout) 41 ) 42 }) 43 } 44 45 pub fn ndjson_from_stdout(output: &Output) -> Vec<Value> { 46 let stdout = String::from_utf8_lossy(&output.stdout); 47 let frames = stdout 48 .lines() 49 .filter(|line| !line.trim().is_empty()) 50 .map(|line| { 51 serde_json::from_str::<Value>(line).unwrap_or_else(|error| { 52 panic!( 53 "stdout line was not json: {error}; stderr `{}`; line `{line}`; stdout `{stdout}`", 54 String::from_utf8_lossy(&output.stderr) 55 ) 56 }) 57 }) 58 .collect::<Vec<_>>(); 59 assert!(!frames.is_empty(), "stdout should contain ndjson frames"); 60 frames 61 } 62 63 pub struct RadrootsCliSandbox { 64 root: TempDir, 65 } 66 67 impl RadrootsCliSandbox { 68 pub fn new() -> Self { 69 Self { 70 root: TempDir::new().expect("tempdir"), 71 } 72 } 73 74 pub fn root(&self) -> &Path { 75 self.root.path() 76 } 77 78 pub fn command(&self) -> Command { 79 let mut command = radroots(); 80 self.apply_base_env(&mut command); 81 command 82 } 83 84 pub fn json_success(&self, args: &[&str]) -> Value { 85 let _guard = COMMAND_LOCK.lock().expect("cli command lock"); 86 let output = self.command().args(args).output().expect("run command"); 87 assert!( 88 output.status.success(), 89 "`{args:?}` failed with stderr `{}` and stdout `{}`", 90 String::from_utf8_lossy(&output.stderr), 91 String::from_utf8_lossy(&output.stdout) 92 ); 93 json_from_stdout(&output) 94 } 95 96 pub fn json_output(&self, args: &[&str]) -> (Output, Value) { 97 let _guard = COMMAND_LOCK.lock().expect("cli command lock"); 98 let output = self.command().args(args).output().expect("run command"); 99 let value = json_from_stdout(&output); 100 (output, value) 101 } 102 103 pub fn write_workspace_config(&self, raw: &str) -> PathBuf { 104 let path = self.root.path().join("config.toml"); 105 fs::write(&path, raw).expect("write workspace config"); 106 path 107 } 108 109 pub fn write_app_config(&self, raw: &str) -> PathBuf { 110 let path = self.root.path().join("config/apps/cli/config.toml"); 111 fs::create_dir_all(path.parent().expect("app config parent")).expect("app config dir"); 112 fs::write(&path, raw).expect("write app config"); 113 path 114 } 115 116 pub fn replica_db_path(&self) -> PathBuf { 117 self.root 118 .path() 119 .join("data/apps/cli/replica/replica.sqlite") 120 } 121 122 pub fn local_events_db_path(&self) -> PathBuf { 123 self.root 124 .path() 125 .join("data/shared/local_events/local_events.sqlite") 126 } 127 128 pub fn local_event_records(&self) -> Vec<LocalEventRecord> { 129 let path = self.local_events_db_path(); 130 if !path.exists() { 131 return Vec::new(); 132 } 133 let executor = SqliteExecutor::open(path).expect("open local events db"); 134 let store = LocalEventsStore::new(executor); 135 store.migrate_up().expect("migrate local events db"); 136 store 137 .list_records_after_seq(0, 200) 138 .expect("list local event records") 139 } 140 141 #[cfg(unix)] 142 pub fn write_fake_myc(&self, name: &str, body: &str) -> PathBuf { 143 let path = self.root.path().join("bin").join(name); 144 fs::create_dir_all(path.parent().expect("fake myc parent")).expect("fake myc dir"); 145 fs::write(&path, format!("#!/bin/sh\nset -eu\n{body}\n")).expect("write fake myc"); 146 let mut permissions = fs::metadata(&path) 147 .expect("fake myc metadata") 148 .permissions(); 149 permissions.set_mode(0o755); 150 fs::set_permissions(&path, permissions).expect("fake myc executable"); 151 path 152 } 153 154 fn apply_base_env(&self, command: &mut Command) { 155 command.env("RADROOTS_CLI_PATHS_PROFILE", "repo_local"); 156 command.env("RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", self.root.path()); 157 command.env("RADROOTS_CLI_ACCOUNT_SECRET_BACKEND", "encrypted_file"); 158 command.env("RADROOTS_CLI_ACCOUNT_SECRET_FALLBACK", "none"); 159 } 160 } 161 162 pub fn assert_no_removed_command_reference(value: &Value, args: &[&str]) { 163 let raw = serde_json::to_string(value).expect("json value"); 164 for removed in [ 165 "radroots setup", 166 "radroots status", 167 "radroots doctor", 168 "radroots sell", 169 "radroots find", 170 "radroots local", 171 "radroots net", 172 "radroots myc", 173 "radroots rpc", 174 "radroots account new", 175 "radroots config show", 176 "radroots runtime status get", 177 "radroots runtime start", 178 "radroots runtime stop", 179 "radroots runtime restart", 180 "radroots runtime log watch", 181 "radroots runtime config get", 182 "radroots runtime config show", 183 "radroots runtime install", 184 "radroots runtime uninstall", 185 "radroots runtime config set", 186 "radroots signer session", 187 "myc status", 188 "radroots job get", 189 "radroots job list", 190 "radroots job watch", 191 "radroots job cancel", 192 "radroots job retry", 193 "radroots market search", 194 "radroots market view", 195 "radroots market update", 196 "radroots order ls", 197 "radroots order history", 198 "radroots order watch", 199 "radroots order new", 200 "radroots order create", 201 "radroots farm init", 202 "radroots farm check", 203 "radroots relay ls", 204 "radroots product", 205 "radroots message", 206 "radroots approval", 207 "radroots agent", 208 ] { 209 assert!( 210 !raw.contains(removed), 211 "`{args:?}` output should not contain removed command reference `{removed}`: {raw}" 212 ); 213 } 214 } 215 216 pub fn assert_no_daemon_runtime_reference(value: &Value, args: &[&str]) { 217 let raw = serde_json::to_string(value).expect("json value"); 218 for removed in ["radrootsd", "daemon", "bridge", "radroots job"] { 219 assert!( 220 !raw.contains(removed), 221 "`{args:?}` output should not contain daemon runtime reference `{removed}`: {raw}" 222 ); 223 } 224 } 225 226 pub fn assert_contains(value: &Value, needle: &str) { 227 let value = value.as_str().expect("string value"); 228 assert!( 229 value.contains(needle), 230 "expected `{value}` to contain `{needle}`" 231 ); 232 } 233 234 pub fn assert_hex_len(value: &Value, expected_len: usize) { 235 let value = value.as_str().expect("hex string"); 236 assert_eq!(value.len(), expected_len); 237 assert!(value.chars().all(|ch| ch.is_ascii_hexdigit())); 238 } 239 240 pub fn seed_orderable_listing(sandbox: &RadrootsCliSandbox, listing_addr: &str) -> String { 241 let store = sandbox.json_success(&["--format", "json", "store", "init"]); 242 let db_path = store["result"]["path"] 243 .as_str() 244 .expect("replica db path from store init"); 245 let (seller_pubkey, listing_id) = listing_addr_parts(listing_addr); 246 let event_id = "2".repeat(64); 247 let event = RadrootsNostrEvent { 248 id: event_id.clone(), 249 author: seller_pubkey.clone(), 250 created_at: 1, 251 kind: KIND_LISTING, 252 tags: vec![ 253 vec!["d".to_owned(), listing_id], 254 vec![ 255 "a".to_owned(), 256 format!( 257 "{}:{}:{}", 258 KIND_FARM, seller_pubkey, "AAAAAAAAAAAAAAAAAAAAAA" 259 ), 260 ], 261 vec!["p".to_owned(), seller_pubkey], 262 vec!["key".to_owned(), "pasture-eggs".to_owned()], 263 vec!["title".to_owned(), "Market Eggs".to_owned()], 264 vec!["category".to_owned(), "eggs".to_owned()], 265 vec!["summary".to_owned(), "Pasture-raised eggs".to_owned()], 266 vec!["process".to_owned(), "washed".to_owned()], 267 vec!["lot".to_owned(), "lot-a".to_owned()], 268 vec!["profile".to_owned(), "dozen".to_owned()], 269 vec!["year".to_owned(), "2026".to_owned()], 270 vec!["radroots:primary_bin".to_owned(), "bin-1".to_owned()], 271 vec![ 272 "radroots:bin".to_owned(), 273 "bin-1".to_owned(), 274 "12".to_owned(), 275 "each".to_owned(), 276 "12".to_owned(), 277 "each".to_owned(), 278 "dozen".to_owned(), 279 ], 280 vec![ 281 "radroots:price".to_owned(), 282 "bin-1".to_owned(), 283 "6".to_owned(), 284 "USD".to_owned(), 285 "1".to_owned(), 286 "each".to_owned(), 287 "6".to_owned(), 288 "each".to_owned(), 289 ], 290 vec!["inventory".to_owned(), "5".to_owned()], 291 vec!["status".to_owned(), "active".to_owned()], 292 ], 293 content: "# Market Eggs".to_owned(), 294 sig: "f".repeat(128), 295 }; 296 let executor = SqliteExecutor::open(Path::new(db_path)).expect("open replica db"); 297 assert_eq!( 298 radroots_replica_ingest_event(&executor, &event).expect("ingest listing"), 299 RadrootsReplicaIngestOutcome::Applied 300 ); 301 seed_orderable_listing_signed_event(sandbox, &event, listing_addr); 302 event_id 303 } 304 305 fn seed_orderable_listing_signed_event( 306 sandbox: &RadrootsCliSandbox, 307 event: &RadrootsNostrEvent, 308 listing_addr: &str, 309 ) { 310 let database_path = sandbox.local_events_db_path(); 311 fs::create_dir_all(database_path.parent().expect("local events parent")) 312 .expect("local events parent"); 313 let executor = SqliteExecutor::open(database_path).expect("open local events"); 314 let store = LocalEventsStore::new(executor); 315 store.migrate_up().expect("migrate local events"); 316 let delivery = RelayDeliveryEvidence::acknowledged( 317 [ORDERABLE_LISTING_RELAY], 318 [ORDERABLE_LISTING_RELAY], 319 [ORDERABLE_LISTING_RELAY], 320 Vec::new(), 321 ) 322 .expect("listing relay delivery evidence"); 323 store 324 .append_record(&LocalEventRecordInput { 325 record_id: format!("test:signed_listing:{}", event.id), 326 family: LocalRecordFamily::SignedEvent, 327 status: LocalRecordStatus::Published, 328 source_runtime: SourceRuntime::Cli, 329 created_at_ms: 1_779_000_001_000, 330 inserted_at_ms: 1_779_000_001_000, 331 owner_account_id: None, 332 owner_pubkey: Some(event.author.clone()), 333 farm_id: None, 334 listing_addr: Some(listing_addr.to_owned()), 335 local_work_json: None, 336 event_id: Some(event.id.clone()), 337 event_kind: Some(i64::from(event.kind)), 338 event_pubkey: Some(event.author.clone()), 339 event_created_at: Some(i64::try_from(event.created_at).expect("event created_at")), 340 event_tags_json: Some(json!(event.tags)), 341 event_content: Some(event.content.clone()), 342 event_sig: Some(event.sig.clone()), 343 raw_event_json: Some(json!(event)), 344 outbox_status: PublishOutboxStatus::Acknowledged, 345 relay_set_fingerprint: canonical_relay_set_fingerprint([ORDERABLE_LISTING_RELAY]), 346 relay_delivery_json: Some(delivery.to_json_value().expect("delivery json")), 347 }) 348 .expect("append listing signed event record"); 349 } 350 351 pub fn remove_orderable_listing(sandbox: &RadrootsCliSandbox, listing_addr: &str) { 352 let executor = SqliteExecutor::open(sandbox.replica_db_path()).expect("open replica db"); 353 let params = serde_json::to_string(&vec![listing_addr]).expect("delete listing params"); 354 executor 355 .exec( 356 "DELETE FROM trade_product WHERE listing_addr = ?;", 357 params.as_str(), 358 ) 359 .expect("delete listing row"); 360 } 361 362 pub fn update_orderable_listing_available_amount( 363 sandbox: &RadrootsCliSandbox, 364 listing_addr: &str, 365 available_amount: i64, 366 ) { 367 let executor = SqliteExecutor::open(sandbox.replica_db_path()).expect("open replica db"); 368 let params = serde_json::to_string(&serde_json::json!([available_amount, listing_addr])) 369 .expect("update listing params"); 370 executor 371 .exec( 372 "UPDATE trade_product SET qty_avail = ? WHERE listing_addr = ?;", 373 params.as_str(), 374 ) 375 .expect("update listing available amount"); 376 } 377 378 pub fn update_orderable_listing_primary_bin_id( 379 sandbox: &RadrootsCliSandbox, 380 listing_addr: &str, 381 primary_bin_id: Option<&str>, 382 ) { 383 let executor = SqliteExecutor::open(sandbox.replica_db_path()).expect("open replica db"); 384 let params = serde_json::to_string(&serde_json::json!([primary_bin_id, listing_addr])) 385 .expect("update listing primary bin params"); 386 executor 387 .exec( 388 "UPDATE trade_product SET primary_bin_id = ? WHERE listing_addr = ?;", 389 params.as_str(), 390 ) 391 .expect("update listing primary bin"); 392 } 393 394 pub fn duplicate_orderable_listing_row(sandbox: &RadrootsCliSandbox, listing_addr: &str) { 395 let executor = SqliteExecutor::open(sandbox.replica_db_path()).expect("open replica db"); 396 let params = serde_json::to_string(&json!([ 397 "33333333-3333-3333-3333-333333333333", 398 listing_addr 399 ])) 400 .expect("duplicate listing params"); 401 executor 402 .exec( 403 "INSERT INTO trade_product (id, created_at, updated_at, key, 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, listing_addr, primary_bin_id, qty_amt_exact, price_amt_exact, price_qty_amt_exact, verified_primary_bin_id) SELECT ?, created_at, updated_at, key, 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, listing_addr, primary_bin_id, qty_amt_exact, price_amt_exact, price_qty_amt_exact, verified_primary_bin_id FROM trade_product WHERE listing_addr = ?;", 404 params.as_str(), 405 ) 406 .expect("duplicate listing row"); 407 } 408 409 pub fn replace_latest_listing_event_id( 410 sandbox: &RadrootsCliSandbox, 411 listing_addr: &str, 412 event_id: &str, 413 ) { 414 let (seller_pubkey, listing_id) = listing_addr_parts(listing_addr); 415 let key = format!("{KIND_LISTING}:{seller_pubkey}:{listing_id}"); 416 let executor = SqliteExecutor::open(sandbox.replica_db_path()).expect("open replica db"); 417 let params = serde_json::to_string(&vec![event_id, key.as_str()]).expect("update params"); 418 executor 419 .exec( 420 "UPDATE nostr_event_head SET last_event_id = ? WHERE key = ?;", 421 params.as_str(), 422 ) 423 .expect("update latest listing event id"); 424 } 425 426 fn listing_addr_parts(listing_addr: &str) -> (String, String) { 427 let parsed = RadrootsListingAddress::parse(listing_addr).expect("listing addr"); 428 let (_, rest) = parsed.as_str().split_once(':').expect("listing addr kind"); 429 let (seller_pubkey, listing_id) = rest.split_once(':').expect("listing addr parts"); 430 (seller_pubkey.to_owned(), listing_id.to_owned()) 431 } 432 433 pub fn create_listing_draft(sandbox: &RadrootsCliSandbox, key: &str) -> PathBuf { 434 let accounts = sandbox.json_success(&["--format", "json", "account", "list"]); 435 if accounts["result"]["count"].as_u64().unwrap_or_default() == 0 { 436 sandbox.json_success(&["--format", "json", "account", "create"]); 437 } 438 let listing_file = sandbox.root().join(format!("{key}.toml")); 439 let listing_file_arg = listing_file.to_string_lossy(); 440 let value = sandbox.json_success(&[ 441 "--format", 442 "json", 443 "listing", 444 "create", 445 "--output", 446 listing_file_arg.as_ref(), 447 "--key", 448 key, 449 "--title", 450 "Eggs", 451 "--category", 452 "eggs", 453 "--summary", 454 "Fresh eggs", 455 "--bin-id", 456 "bin-1", 457 "--quantity-amount", 458 "1", 459 "--quantity-unit", 460 "each", 461 "--price-amount", 462 "6", 463 "--price-currency", 464 "USD", 465 "--price-per-amount", 466 "1", 467 "--price-per-unit", 468 "each", 469 "--available", 470 "10", 471 ]); 472 assert_eq!(value["operation_id"], "listing.create"); 473 listing_file 474 } 475 476 pub fn identity_public(seed: u8) -> RadrootsIdentityPublic { 477 identity_secret(seed).to_public() 478 } 479 480 pub fn identity_secret(seed: u8) -> RadrootsIdentity { 481 let secret = [seed; 32]; 482 RadrootsIdentity::from_secret_key_bytes(&secret).expect("fixture identity") 483 } 484 485 pub fn store_test_session_secret(sandbox: &RadrootsCliSandbox, slot: &str, secret: &str) { 486 let vault = 487 RadrootsProtectedFileSecretVault::new(sandbox.root().join("secrets/shared/accounts")); 488 vault 489 .store_secret(slot, secret) 490 .expect("store test session secret"); 491 } 492 493 pub fn make_listing_publishable(path: &Path, farm_d_tag: &str) { 494 let raw = fs::read_to_string(path).expect("listing draft"); 495 let mut seller_pubkey_present = false; 496 let mut in_seller_actor = false; 497 let patched = raw 498 .lines() 499 .map(|line| { 500 let trimmed = line.trim_start(); 501 if trimmed.starts_with('[') { 502 in_seller_actor = trimmed == "[seller_actor]"; 503 } 504 if in_seller_actor && trimmed.starts_with("pubkey =") { 505 seller_pubkey_present = !trimmed.ends_with("\"\""); 506 line.to_owned() 507 } else if trimmed.starts_with("farm_d_tag =") { 508 format!("{}farm_d_tag = \"{}\"", line_indent(line), farm_d_tag) 509 } else if trimmed.starts_with("method =") { 510 format!("{}method = \"pickup\"", line_indent(line)) 511 } else if trimmed.starts_with("primary =") { 512 format!("{}primary = \"farmstand\"", line_indent(line)) 513 } else { 514 line.to_owned() 515 } 516 }) 517 .collect::<Vec<_>>() 518 .join("\n"); 519 assert!(seller_pubkey_present, "listing draft seller pubkey"); 520 fs::write(path, format!("{patched}\n")).expect("write listing draft"); 521 } 522 523 pub fn make_listing_publishable_with_seller(path: &Path, farm_d_tag: &str, seller_pubkey: &str) { 524 let raw = fs::read_to_string(path).expect("listing draft"); 525 let mut seller_pubkey_field_present = false; 526 let mut in_seller_actor = false; 527 let patched = raw 528 .lines() 529 .map(|line| { 530 let trimmed = line.trim_start(); 531 if trimmed.starts_with('[') { 532 in_seller_actor = trimmed == "[seller_actor]"; 533 } 534 if in_seller_actor && trimmed.starts_with("pubkey =") { 535 seller_pubkey_field_present = true; 536 format!("{}pubkey = \"{}\"", line_indent(line), seller_pubkey) 537 } else if trimmed.starts_with("farm_d_tag =") { 538 format!("{}farm_d_tag = \"{}\"", line_indent(line), farm_d_tag) 539 } else if trimmed.starts_with("method =") { 540 format!("{}method = \"pickup\"", line_indent(line)) 541 } else if trimmed.starts_with("primary =") { 542 format!("{}primary = \"farmstand\"", line_indent(line)) 543 } else { 544 line.to_owned() 545 } 546 }) 547 .collect::<Vec<_>>() 548 .join("\n"); 549 assert!( 550 seller_pubkey_field_present, 551 "listing draft seller pubkey field" 552 ); 553 fs::write(path, format!("{patched}\n")).expect("write listing draft"); 554 } 555 556 pub fn shell_single_quoted(value: &str) -> String { 557 value.replace('\'', "'\"'\"'") 558 } 559 560 pub fn toml_string(value: &str) -> String { 561 value.replace('\\', "\\\\").replace('"', "\\\"") 562 } 563 564 pub fn write_public_identity_profile( 565 sandbox: &RadrootsCliSandbox, 566 name: &str, 567 identity: &RadrootsIdentityPublic, 568 ) -> PathBuf { 569 let path = sandbox.root().join(format!("{name}.json")); 570 fs::write( 571 &path, 572 serde_json::to_string_pretty(identity).expect("public identity json"), 573 ) 574 .expect("write public identity"); 575 path 576 } 577 578 pub fn write_secret_identity_profile( 579 sandbox: &RadrootsCliSandbox, 580 name: &str, 581 identity: &RadrootsIdentity, 582 ) -> PathBuf { 583 let path = sandbox.root().join(format!("{name}.json")); 584 identity.save_json(&path).expect("write secret identity"); 585 path 586 } 587 588 fn line_indent(line: &str) -> &str { 589 let trimmed = line.trim_start(); 590 &line[..line.len() - trimmed.len()] 591 }