Files
frxd/tests/onboarding.rs
T

311 lines
11 KiB
Rust

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; a member token then authorizes key enrollment
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 enroll = |token: &str, pubkey: String| {
let http = http.clone();
let base = base.clone();
let id = id.to_string();
let token = token.to_string();
async move {
http.post(format!("{base}/v1/enroll"))
.json(&serde_json::json!({
"id": id, "token": token, "pubkey": pubkey,
}))
.send()
.await
.unwrap()
.status()
}
};
// the account credential is reusable: two nodes, two keys, one token
let token = commands::registry_token(&dir, id, None).unwrap();
let key1 = Keypair::generate();
let key2 = Keypair::generate();
assert!(enroll(&token, key1.public_hex()).await.is_success());
assert!(enroll(&token, 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()));
// after revocation the token no longer enrolls; a fresh one does
commands::registry_revoke_token(&dir, id).unwrap();
let key3 = Keypair::generate();
assert_eq!(
enroll(&token, key3.public_hex()).await,
reqwest::StatusCode::FORBIDDEN
);
let fresh = commands::registry_token(&dir, id, None).unwrap();
assert!(enroll(&fresh, key3.public_hex()).await.is_success());
}
#[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: a single-use handoff invite
commands::registry_invite(&dir, id, None).unwrap();
let key1 = Keypair::generate();
let token1 = invite_token(&dir, id);
assert!(enroll(token1.clone(), key1.public_hex()).await.is_success());
// the invite is single-use: replay is rejected
assert_eq!(
enroll(token1, Keypair::generate().public_hex()).await,
reqwest::StatusCode::FORBIDDEN
);
// 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!(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 token = commands::registry_token(&dir, id, None).unwrap();
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
let credentials = format!(
"id={id} token={token} registry={base}/registry.json ma_key={}",
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"
);
}