use std::path::Path; use std::time::Duration; use frxd::commands; use frxd::config::Config; use frxd::crypto::{Keypair, now_ts}; use frxd::onboard; use frxd::registry::{self}; use serde_json::Value; use tokio::net::TcpListener; async fn spawn_registry_server(dir: &Path, signup_code: Option<&str>) -> String { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let base = format!("http://{addr}"); let router = commands::registry_router( dir, signup_code.map(str::to_string), Some(format!("{base}/registry.json")), ); tokio::spawn(async move { let _ = axum::serve(listener, router).await; }); base } fn setup_ma(root: &Path) -> std::path::PathBuf { let dir = root.join("ma"); commands::registry_init(&dir, "frx.invalid").unwrap(); dir } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn signup_issues_invite_and_enroll_binds_key() { let root = tempfile::tempdir().unwrap(); let dir = setup_ma(root.path()); let base = spawn_registry_server(&dir, Some("sesame")).await; let http = reqwest::Client::builder() .timeout(Duration::from_secs(5)) .build() .unwrap(); let rejected = http .post(format!("{base}/v1/signup")) .json(&serde_json::json!({"label": "alice", "code": "wrong"})) .send() .await .unwrap(); assert_eq!(rejected.status(), reqwest::StatusCode::FORBIDDEN); let response = http .post(format!("{base}/v1/signup")) .json(&serde_json::json!({"label": "Alice Dev", "code": "sesame", "attestation": true, "privacy_ack": true})) .send() .await .unwrap(); assert!(response.status().is_success()); let body: Value = response.json().await.unwrap(); let id = body.get("id").and_then(Value::as_str).unwrap().to_string(); assert_eq!(id, "alice-dev.frx.invalid"); let token = body .get("token") .and_then(Value::as_str) .unwrap() .to_string(); let signed = registry::load_registry(&dir.join("registry.json")).unwrap(); assert!( registry::authorized_keys(&signed, now_ts()).is_empty(), "signup must not authorize a key before enrollment" ); let key = Keypair::generate(); let response = http .post(format!("{base}/v1/enroll")) .json(&serde_json::json!({ "id": id, "token": token, "pubkey": key.public_hex(), })) .send() .await .unwrap(); assert!(response.status().is_success()); let signed = registry::load_registry(&dir.join("registry.json")).unwrap(); assert!(registry::authorized_keys(&signed, now_ts()).contains_key(&key.public_hex())); let replayed = http .post(format!("{base}/v1/enroll")) .json(&serde_json::json!({ "id": id, "token": token, "pubkey": Keypair::generate().public_hex(), })) .send() .await .unwrap(); assert_eq!(replayed.status(), reqwest::StatusCode::FORBIDDEN); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn signup_stores_private_application_and_class() { let root = tempfile::tempdir().unwrap(); let dir = setup_ma(root.path()); let base = spawn_registry_server(&dir, Some("sesame")).await; let http = reqwest::Client::builder() .timeout(Duration::from_secs(5)) .build() .unwrap(); let missing = http .post(format!("{base}/v1/signup")) .json(&serde_json::json!({"label": "acme", "code": "sesame"})) .send() .await .unwrap(); assert_eq!(missing.status(), reqwest::StatusCode::BAD_REQUEST); let response = http .post(format!("{base}/v1/signup")) .json(&serde_json::json!({ "label": "Keswick Research", "code": "sesame", "org": "Keswick Research LLC", "representative": "J. Keswick", "email": "ops@keswick.example", "address": "1 Fell Road, Keswick", "domain": "keswick.example", "class": "enrichment", "payment": "IBAN XX00 0000", "privacy_link": "https://keswick.example/privacy", "attestation": true, "privacy_ack": true })) .send() .await .unwrap(); assert!(response.status().is_success()); let body: Value = response.json().await.unwrap(); assert_eq!( body.get("id").and_then(Value::as_str), Some("keswick-research.frx.invalid") ); // public registry stays minimal: identifier + class only, no org data let signed = registry::load_registry(&dir.join("registry.json")).unwrap(); let member = signed .doc .members .iter() .find(|m| m.id == "keswick-research.frx.invalid") .unwrap(); assert_eq!(member.class, frxd::config::CLASS_ENRICHMENT); let raw = std::fs::read_to_string(dir.join("registry.json")).unwrap(); assert!(!raw.contains("Keswick Research LLC")); assert!(!raw.contains("ops@keswick.example")); // private application record holds the contract details let apps = registry::load_applications(&dir.join("applications.json")).unwrap(); assert_eq!(apps.len(), 1); let app = &apps[0]; assert_eq!(app.id, "keswick-research.frx.invalid"); assert_eq!(app.org, "Keswick Research LLC"); assert_eq!(app.representative, "J. Keswick"); assert_eq!(app.email, "ops@keswick.example"); assert_eq!(app.payment, "IBAN XX00 0000"); assert_eq!(app.privacy_link, "https://keswick.example/privacy"); assert_eq!(app.class, "enrichment"); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn wizard_enrolls_and_writes_config() { let root = tempfile::tempdir().unwrap(); let dir = setup_ma(root.path()); let base = spawn_registry_server(&dir, Some("sesame")).await; let http = reqwest::Client::new(); let body: Value = http .post(format!("{base}/v1/signup")) .json(&serde_json::json!({"label": "Wizard Test", "code": "sesame", "attestation": true, "privacy_ack": true})) .send() .await .unwrap() .json() .await .unwrap(); let credentials = body.get("credentials").and_then(Value::as_str).unwrap(); let config_path = root.path().join("wizard.toml"); let input = format!("{}\n{credentials}\n\n\n\nn\n", config_path.display()); let mut reader = input.as_bytes(); let mut output = Vec::new(); onboard::run(&mut reader, &mut output, &config_path, None) .await .unwrap(); let text = String::from_utf8(output).unwrap(); assert!(text.contains("enrolled"), "{text}"); let config = Config::load(&config_path).unwrap(); assert_eq!(config.node.id.as_deref(), Some("wizard-test.frx.invalid")); assert_eq!( config.node.registry.as_deref(), Some(format!("{base}/registry.json").as_str()) ); let key = config.load_key().unwrap(); let signed = registry::load_registry(&dir.join("registry.json")).unwrap(); assert!( registry::authorized_keys(&signed, now_ts()).contains_key(&key.public_hex()), "wizard did not bind the key" ); }