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) -> 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); 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 } fn full_application(label: &str) -> Value { serde_json::json!({ "label": label, "org": format!("{label} Org"), "representative": "R. Ep", "email": "ops@example.org", "attestation": true, "privacy_ack": true }) } async fn submit_application(http: &reqwest::Client, base: &str, label: &str) -> Value { let response = http .post(format!("{base}/v1/signup")) .json(&full_application(label)) .send() .await .unwrap(); assert_eq!(response.status(), reqwest::StatusCode::ACCEPTED); response.json().await.unwrap() } fn invite_token(dir: &Path, id: &str) -> String { registry::load_invites(&dir.join("invites.json")) .unwrap() .into_iter() .rev() .find(|invite| invite.id == id) .unwrap() .token } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn application_pending_then_approve_then_enroll_binds_key() { let root = tempfile::tempdir().unwrap(); let dir = setup_ma(root.path()); let base = spawn_registry_server(&dir).await; let http = reqwest::Client::builder() .timeout(Duration::from_secs(5)) .build() .unwrap(); let body = submit_application(&http, &base, "Alice Dev").await; let id = "alice-dev.frx.invalid"; assert_eq!(body.get("status").and_then(Value::as_str), Some("pending")); assert_eq!(body.get("id").and_then(Value::as_str), Some(id)); assert!(body.get("credentials").is_none()); // pending: no member entry yet, application on file let signed = registry::load_registry(&dir.join("registry.json")).unwrap(); assert!(signed.doc.members.iter().all(|member| member.id != id)); let apps = registry::load_applications(&dir.join("applications.json")).unwrap(); assert_eq!(apps.len(), 1); assert_eq!(apps[0].status, "pending"); // a duplicate application for the same identifier is rejected let dup = http .post(format!("{base}/v1/signup")) .json(&full_application("Alice Dev")) .send() .await .unwrap(); assert_eq!(dup.status(), reqwest::StatusCode::CONFLICT); // MA approves: member stub + invite; enrollment binds the key commands::registry_approve(&dir, id, None).unwrap(); let signed = registry::load_registry(&dir.join("registry.json")).unwrap(); assert!(signed.doc.members.iter().any(|member| member.id == id)); let apps = registry::load_applications(&dir.join("applications.json")).unwrap(); assert_eq!(apps[0].status, "approved"); let key = Keypair::generate(); let enrolled = http .post(format!("{base}/v1/enroll")) .json(&serde_json::json!({ "id": id, "token": invite_token(&dir, id), "pubkey": key.public_hex(), })) .send() .await .unwrap(); assert!(enrolled.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())); // the token is single-use let replayed = http .post(format!("{base}/v1/enroll")) .json(&serde_json::json!({ "id": id, "token": invite_token(&dir, id), "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).await; let http = reqwest::Client::builder() .timeout(Duration::from_secs(5)) .build() .unwrap(); // missing acknowledgements are rejected let missing = http .post(format!("{base}/v1/signup")) .json(&serde_json::json!({ "label": "acme", "org": "Acme", "representative": "A", "email": "a@acme.example" })) .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", "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_eq!(response.status(), reqwest::StatusCode::ACCEPTED); let body: Value = response.json().await.unwrap(); assert_eq!( body.get("id").and_then(Value::as_str), Some("keswick-research.frx.invalid") ); // 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"); assert_eq!(app.status, "pending"); // approval creates the member entry with the declared class commands::registry_approve(&dir, "keswick-research.frx.invalid", None).unwrap(); 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); // public registry stays minimal: no org data in the signed snapshot let raw = std::fs::read_to_string(dir.join("registry.json")).unwrap(); assert!(!raw.contains("Keswick Research LLC")); assert!(!raw.contains("ops@keswick.example")); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn invite_reissues_token_per_node() { let root = tempfile::tempdir().unwrap(); let dir = setup_ma(root.path()); let base = spawn_registry_server(&dir).await; let http = reqwest::Client::builder() .timeout(Duration::from_secs(5)) .build() .unwrap(); submit_application(&http, &base, "Multi Node").await; let id = "multi-node.frx.invalid"; commands::registry_approve(&dir, id, None).unwrap(); let enroll = |token: String, pubkey: String| { let http = http.clone(); let base = base.clone(); let id = id.to_string(); async move { http.post(format!("{base}/v1/enroll")) .json(&serde_json::json!({ "id": id, "token": token, "pubkey": pubkey, })) .send() .await .unwrap() .status() } }; // node 1: the invite from approval let key1 = Keypair::generate(); let token1 = invite_token(&dir, id); assert!(enroll(token1.clone(), key1.public_hex()).await.is_success()); // node 2: a fresh token from `registry invite` commands::registry_invite(&dir, id, None).unwrap(); let key2 = Keypair::generate(); let token2 = invite_token(&dir, id); assert_ne!(token1, token2); assert!(enroll(token2, key2.public_hex()).await.is_success()); let signed = registry::load_registry(&dir.join("registry.json")).unwrap(); let authorized = registry::authorized_keys(&signed, now_ts()); assert!(authorized.contains_key(&key1.public_hex())); assert!(authorized.contains_key(&key2.public_hex())); // an invite for an unknown member fails assert!(commands::registry_invite(&dir, "ghost.frx.invalid", None).is_err()); } #[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).await; let http = reqwest::Client::new(); submit_application(&http, &base, "Wizard Test").await; let id = "wizard-test.frx.invalid"; commands::registry_approve(&dir, id, None).unwrap(); let signed = registry::load_registry(&dir.join("registry.json")).unwrap(); let credentials = format!( "id={id} token={} registry={base}/registry.json ma_key={}", invite_token(&dir, id), signed.doc.ma_key ); 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(id)); 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" ); }