Remove the signup code; applications always queue for MA review, invite per node
This commit is contained in:
+139
-134
@@ -9,15 +9,11 @@ use frxd::registry::{self};
|
||||
use serde_json::Value;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
async fn spawn_registry_server(dir: &Path, signup_code: Option<&str>) -> String {
|
||||
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,
|
||||
signup_code.map(str::to_string),
|
||||
Some(format!("{base}/registry.json")),
|
||||
);
|
||||
let router = commands::registry_router(dir);
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
});
|
||||
@@ -30,67 +26,98 @@ fn setup_ma(root: &Path) -> std::path::PathBuf {
|
||||
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 signup_issues_invite_and_enroll_binds_key() {
|
||||
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, Some("sesame")).await;
|
||||
let base = spawn_registry_server(&dir).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", "org": "Alice Dev Org", "representative": "Alice", "email": "alice@example.org", "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 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!(
|
||||
registry::authorized_keys(&signed, now_ts()).is_empty(),
|
||||
"signup must not authorize a key before enrollment"
|
||||
);
|
||||
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 response = http
|
||||
let enrolled = http
|
||||
.post(format!("{base}/v1/enroll"))
|
||||
.json(&serde_json::json!({
|
||||
"id": id,
|
||||
"token": token,
|
||||
"token": invite_token(&dir, id),
|
||||
"pubkey": key.public_hex(),
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(response.status().is_success());
|
||||
|
||||
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": token,
|
||||
"token": invite_token(&dir, id),
|
||||
"pubkey": Keypair::generate().public_hex(),
|
||||
}))
|
||||
.send()
|
||||
@@ -103,15 +130,18 @@ async fn signup_issues_invite_and_enroll_binds_key() {
|
||||
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 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", "code": "sesame"}))
|
||||
.json(&serde_json::json!({
|
||||
"label": "acme", "org": "Acme", "representative": "A", "email": "a@acme.example"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -121,7 +151,6 @@ async fn signup_stores_private_application_and_class() {
|
||||
.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",
|
||||
@@ -136,26 +165,13 @@ async fn signup_stores_private_application_and_class() {
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(response.status().is_success());
|
||||
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")
|
||||
);
|
||||
|
||||
// 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);
|
||||
@@ -167,101 +183,90 @@ async fn signup_stores_private_application_and_class() {
|
||||
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 signup_without_code_queues_application_for_approval() {
|
||||
async fn invite_reissues_token_per_node() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let dir = setup_ma(root.path());
|
||||
let base = spawn_registry_server(&dir, Some("sesame")).await;
|
||||
let base = spawn_registry_server(&dir).await;
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
// no code: application queues as pending; no credentials, no member entry
|
||||
let response = http
|
||||
.post(format!("{base}/v1/signup"))
|
||||
.json(&serde_json::json!({
|
||||
"label": "Pending Co",
|
||||
"org": "Pending Co Ltd",
|
||||
"representative": "P. Pending",
|
||||
"email": "ops@pending.example",
|
||||
"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("status").and_then(Value::as_str), Some("pending"));
|
||||
assert!(body.get("credentials").is_none());
|
||||
let id = "pending-co.frx.invalid";
|
||||
|
||||
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
||||
assert!(
|
||||
signed.doc.members.iter().all(|member| member.id != id),
|
||||
"a pending application must not create a member entry"
|
||||
);
|
||||
let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
|
||||
assert_eq!(apps.len(), 1);
|
||||
assert_eq!(apps[0].id, id);
|
||||
assert_eq!(apps[0].status, "pending");
|
||||
|
||||
// a wrong code is still rejected, not queued
|
||||
let rejected = http
|
||||
.post(format!("{base}/v1/signup"))
|
||||
.json(&serde_json::json!({
|
||||
"label": "other", "code": "wrong", "org": "O", "representative": "R",
|
||||
"email": "r@o.example", "attestation": true, "privacy_ack": true
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rejected.status(), reqwest::StatusCode::FORBIDDEN);
|
||||
|
||||
// MA approves: member stub + invite; enrollment binds the key
|
||||
submit_application(&http, &base, "Multi Node").await;
|
||||
let id = "multi-node.frx.invalid";
|
||||
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 invites = registry::load_invites(&dir.join("invites.json")).unwrap();
|
||||
let invite = invites.iter().find(|invite| invite.id == id).unwrap();
|
||||
let key = Keypair::generate();
|
||||
let enrolled = http
|
||||
.post(format!("{base}/v1/enroll"))
|
||||
.json(&serde_json::json!({
|
||||
"id": id,
|
||||
"token": invite.token,
|
||||
"pubkey": key.public_hex(),
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(enrolled.status().is_success());
|
||||
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();
|
||||
assert!(registry::authorized_keys(&signed, now_ts()).contains_key(&key.public_hex()));
|
||||
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, Some("sesame")).await;
|
||||
let base = spawn_registry_server(&dir).await;
|
||||
let http = reqwest::Client::new();
|
||||
let body: Value = http
|
||||
.post(format!("{base}/v1/signup"))
|
||||
.json(&serde_json::json!({"label": "Wizard Test", "code": "sesame", "org": "Wizard Test Org", "representative": "Wiz", "email": "wiz@example.org", "attestation": true, "privacy_ack": true}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let credentials = body.get("credentials").and_then(Value::as_str).unwrap();
|
||||
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());
|
||||
@@ -275,7 +280,7 @@ async fn wizard_enrolls_and_writes_config() {
|
||||
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.id.as_deref(), Some(id));
|
||||
assert_eq!(
|
||||
config.node.registry.as_deref(),
|
||||
Some(format!("{base}/registry.json").as_str())
|
||||
|
||||
Reference in New Issue
Block a user