//! End-to-end API scenarios on an in-memory database seeded like production. use axum::body::Body; use axum::http::{Request, StatusCode, header}; use http_body_util::BodyExt; use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed}; use serde_json::{Value, json}; use tower::ServiceExt; struct Client { router: axum::Router, cookie: Option, } impl Client { async fn new() -> (Self, AppState) { let db = open_db("sqlite::memory:").await.expect("db"); let state = AppState::new(Config::for_tests(), db).await.expect("state"); seed::ensure(&state).await.expect("seed"); (Self { router: app(state.clone()), cookie: None }, state) } fn with(&self, cookie: Option) -> Self { Self { router: self.router.clone(), cookie } } async fn call(&mut self, method: &str, uri: &str, body: Option) -> (StatusCode, Value) { let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE); if let Some(c) = &self.cookie { req = req.header(header::COOKIE, c); } let req = match body { Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())), None => req.body(Body::empty()), } .expect("request"); let res = self.router.clone().oneshot(req).await.expect("response"); let status = res.status(); if let Some(set) = res.headers().get(header::SET_COOKIE) { let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned(); if raw.starts_with("ld_session=") { self.cookie = Some(raw); } } let bytes = res.into_body().collect().await.expect("body").to_bytes(); (status, serde_json::from_slice(&bytes).unwrap_or(Value::Null)) } async fn login(&mut self, name: &str, admin: bool) { let (s, v) = self.call("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await; assert_eq!(s, StatusCode::OK, "{v}"); } } async fn doc_id(c: &mut Client, group: &str) -> String { let (s, v) = c.call("GET", &format!("/api/groups/{group}"), None).await; assert_eq!(s, StatusCode::OK, "{v}"); v["docs"][0]["id"].as_str().expect("doc").to_owned() } fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value { doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit") } #[tokio::test] async fn anonymous_sees_nothing_and_csrf_is_enforced() { let (mut c, _) = Client::new().await; let (s, _) = c.call("GET", "/api/groups", None).await; assert_eq!(s, StatusCode::UNAUTHORIZED); let (s, v) = c.call("GET", "/api/me", None).await; assert_eq!(s, StatusCode::OK); assert!(v["me"].is_null()); // A mutating call without the header is refused. let req = Request::builder() .method("POST") .uri("/api/auth/dev") .header(header::CONTENT_TYPE, "application/json") .body(Body::from(r#"{"name":"x"}"#)) .expect("request"); let res = c.router.clone().oneshot(req).await.expect("response"); assert_eq!(res.status(), StatusCode::FORBIDDEN); // No neighbour may frame the product. assert_eq!(res.headers().get(header::X_FRAME_OPTIONS).map(|v| v.as_bytes()), Some(&b"DENY"[..])); assert!(res.headers().contains_key(header::X_CONTENT_TYPE_OPTIONS)); } #[tokio::test] async fn the_seed_a_minima_and_the_open_proposals() { let (mut c, _) = Client::new().await; c.login("Chiara", false).await; let (_, groups) = c.call("GET", "/api/groups", None).await; let slugs: Vec<&str> = groups["groups"].as_array().expect("groups").iter().filter_map(|g| g["slug"].as_str()).collect(); assert_eq!(slugs, ["monnaie-libre", "axiom", "librodrome", "vallee-drome-crestois", "essai"]); let id = doc_id(&mut c, "monnaie-libre").await; let (s, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await; assert_eq!(s, StatusCode::OK); let in_force: Vec<&str> = doc["units"] .as_array() .expect("units") .iter() .filter(|u| !u["inForce"].is_null()) .filter_map(|u| u["code"].as_str()) .collect(); assert_eq!(in_force, ["I1", "E1", "E2"]); let e1 = &unit(&doc, "E1")["inForce"]; assert_eq!(e1["seedMinimal"], true); assert_eq!(e1["tally"]["for"], 0); assert_eq!(doc["doc"]["voteFloor"], 50.0); assert_eq!(doc["doc"]["electorate"], 5745); // Every other article is an open proposal at zero votes, standard inertia. let e3 = unit(&doc, "E3"); assert!(e3["inForce"].is_null()); assert_eq!(e3["proposals"].as_array().expect("p").len(), 2, "E3 and its 0.4.0 variant"); assert_eq!(e3["inertia"], "standard"); // Translations seeded. assert!(e1["translations"]["en"]["body"].as_str().expect("en").contains("commit")); } #[tokio::test] async fn one_vote_never_adopts_in_monnaie_libre() { let (mut c, _) = Client::new().await; c.login("Yvv", false).await; let id = doc_id(&mut c, "monnaie-libre").await; let (_, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await; let p = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("p").to_owned(); let (s, v) = c.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; assert_eq!(s, StatusCode::OK, "{v}"); assert_eq!(v["entered"].as_array().expect("e").len(), 0); let (_, d) = c.call("GET", &format!("/api/proposals/{p}"), None).await; assert_eq!(d["proposal"]["evaluation"]["required"], 50); assert_eq!(d["proposal"]["evaluation"]["wellRated"], true); assert_eq!(d["proposal"]["myVote"], "for"); } #[tokio::test] async fn adoption_in_the_test_group_and_publication_queued_only_where_published() { let (mut a, state) = Client::new().await; a.login("Yvv", false).await; let mut b = a.with(None); b.login("Chiara", false).await; // Dev identities are members of every group: W(essai) = 2, C = 1. let id = doc_id(&mut a, "essai").await; let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; let e3 = unit(&doc, "E3"); let p = e3["proposals"][0]["id"].as_str().expect("p").to_owned(); let (_, v) = a.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; assert!(v["entered"].as_array().expect("e").is_empty(), "one of two is not enough"); let (_, v) = b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; assert_eq!(v["entered"][0]["code"], "E3"); let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; let e3 = unit(&doc, "E3"); assert_eq!(e3["inForce"]["id"], p.as_str()); assert!(e3["inForce"]["enteredAt"].is_string()); // The test group publishes nothing. let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE doc_id = ?") .bind(&id) .fetch_one(&state.db) .await .expect("count"); assert_eq!(n, 0); } #[tokio::test] async fn a_rival_replaces_the_seed_and_the_seed_returns_among_proposals() { let (mut a, _) = Client::new().await; a.login("Yvv", false).await; let mut b = a.with(None); b.login("Chiara", false).await; let id = doc_id(&mut a, "essai").await; let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; let e1 = unit(&doc, "E1"); let (uid, seed_id) = (e1["id"].as_str().expect("u").to_owned(), e1["inForce"]["id"].as_str().expect("v").to_owned()); let (s, v) = a .call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({ "unitId": uid, "title": "Unicité du compte", "body": "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie Ğ1.", "submit": true, }))) .await; assert_eq!(s, StatusCode::OK, "{v}"); let rival = v["id"].as_str().expect("id").to_owned(); a.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await; let (_, v) = b.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await; assert_eq!(v["entered"][0]["kind"], "text"); let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; let e1 = unit(&doc, "E1"); assert_eq!(e1["inForce"]["id"], rival.as_str()); assert!( e1["proposals"].as_array().expect("p").iter().any(|p| p["id"] == seed_id.as_str() && p["status"] == "open") ); } #[tokio::test] async fn editing_is_not_voting() { let (mut a, _) = Client::new().await; a.login("Yvv", false).await; let mut b = a.with(None); b.login("Chiara", false).await; let id = doc_id(&mut a, "monnaie-libre").await; let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; let i1 = unit(&doc, "I1")["inForce"].clone(); let pid = i1["id"].as_str().expect("id").to_owned(); // Anyone may fix the glued word of the preamble in force, without a vote. let fixed = i1["body"].as_str().expect("body").replace("latransmission", "la transmission"); let (s, v) = b.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": i1["title"], "body": fixed }))).await; assert_eq!(s, StatusCode::OK, "{v}"); assert_eq!(v["edit"], "typo"); // A change of meaning in force is refused: it goes through a proposal. let (s, v) = b .call( "PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": i1["title"], "body": "Tout autre chose." })), ) .await; assert_eq!(s, StatusCode::CONFLICT); assert_eq!(v["error"], "substantive_in_force"); // A member proposal: typo keeps votes, substance asks to reconfirm. let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned(); let (_, v) = a .call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({ "unitId": uid, "title": "Réciprocité", "body": "Je m'engage à ne certifier que des persones qui respectent ces engagements.", "submit": true, }))) .await; let p = v["id"].as_str().expect("id").to_owned(); b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; // A misspelt word fixed: the vote stays. let (_, v) = a .call("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": "Réciprocité", "body": "Je m'engage à ne certifier que des personnes qui respectent ces engagements.", }))) .await; assert_eq!(v["edit"], "typo"); let (_, d) = b.call("GET", &format!("/api/proposals/{p}"), None).await; assert_eq!(d["proposal"]["tally"]["for"], 1, "a typo keeps the votes"); // Words added: a new revision, the vote waits for reconfirmation. let (_, v) = a .call("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": "Réciprocité", "body": "Je m'engage à ne certifier que des personnes qui respectent ces deux engagements fondamentaux.", }))) .await; assert_eq!(v["edit"], "substantive"); let (_, d) = b.call("GET", &format!("/api/proposals/{p}"), None).await; assert_eq!(d["proposal"]["myVoteStale"], true); assert_eq!(d["proposal"]["tally"]["for"], 0); b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; let (_, d) = b.call("GET", &format!("/api/proposals/{p}"), None).await; assert_eq!(d["proposal"]["myVoteStale"], false); assert_eq!(d["proposal"]["tally"]["for"], 1); assert_eq!(d["history"].as_array().expect("h").len(), 3); // Someone else cannot change the substance. let (s, _) = b .call( "PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": "Réciprocité", "body": "Autre chose entièrement." })), ) .await; assert_eq!(s, StatusCode::CONFLICT); } #[tokio::test] async fn a_contradiction_signal_blocks_entering_into_force() { let (mut a, _) = Client::new().await; a.login("Yvv", false).await; let mut b = a.with(None); b.login("Chiara", false).await; let id = doc_id(&mut a, "essai").await; let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned(); let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned(); let (s, v) = b .call( "POST", &format!("/api/proposals/{k1}/signals"), Some(json!({ "targetId": e2, "reason": "Ces règles contredisent la réciprocité en vigueur." })), ) .await; assert_eq!(s, StatusCode::OK, "{v}"); let sid = v["id"].as_str().expect("sid").to_owned(); a.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await; let (_, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await; assert!(v["entered"].as_array().expect("e").is_empty(), "blocked while the signal stands"); // Only its author lifts it; then the pass lets the text in. let (s, _) = a.call("POST", &format!("/api/signals/{sid}/lift"), None).await; assert_eq!(s, StatusCode::FORBIDDEN); let (_, v) = b.call("POST", &format!("/api/signals/{sid}/lift"), None).await; assert_eq!(v["entered"][0]["code"], "K1"); } #[tokio::test] async fn inertia_change_is_voted_one_level_up() { let (mut a, _) = Client::new().await; a.login("Yvv", false).await; let id = doc_id(&mut a, "monnaie-libre").await; let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; let uid = unit(&doc, "E1")["id"].as_str().expect("u").to_owned(); let (s, v) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "high" }))).await; assert_eq!(s, StatusCode::OK, "{v}"); let p = v["id"].as_str().expect("id").to_owned(); let (_, again) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "high" }))).await; assert_eq!(again["id"], p.as_str()); let (_, d) = a.call("GET", &format!("/api/proposals/{p}"), None).await; assert_eq!(d["proposal"]["votedAtInertia"], "high"); } #[tokio::test] async fn previews_and_assistant() { let (mut a, _) = Client::new().await; a.login("Yvv", false).await; let id = doc_id(&mut a, "monnaie-libre").await; let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await; assert_eq!(v["units"].as_array().expect("u").len(), 33); let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=wellrated"), None).await; assert_eq!(v["units"].as_array().expect("u").len(), 3, "nothing is well rated at zero votes"); let (_, v) = a .call("POST", &format!("/api/docs/{id}/assist"), Some(json!({ "text": "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie G1.\n\nJe m'engage à rester joignable par mes certifieurs.", }))) .await; let kinds: Vec<&str> = v["hints"].as_array().expect("h").iter().filter_map(|h| h["kind"].as_str()).collect(); assert!(kinds.contains(&"split"), "{v}"); } #[tokio::test] async fn split_a_draft() { let (mut a, _) = Client::new().await; a.login("Yvv", false).await; let id = doc_id(&mut a, "essai").await; let (_, v) = a .call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({ "title": "Deux choses", "body": "a\n\nb" }))) .await; let p = v["id"].as_str().expect("id").to_owned(); let (s, v) = a.call("POST", &format!("/api/proposals/{p}/split"), Some(json!({ "parts": ["Première.", "Seconde."] }))).await; assert_eq!(s, StatusCode::OK, "{v}"); assert_eq!(v["drafts"].as_array().expect("d").len(), 2); let (s, _) = a.call("POST", &format!("/api/proposals/{p}/submit"), None).await; assert_eq!(s, StatusCode::OK); } #[tokio::test] async fn access_management_is_for_administrators_only() { let (mut user, _) = Client::new().await; user.login("Chiara", false).await; let (s, _) = user.call("GET", "/api/access", None).await; assert_eq!(s, StatusCode::FORBIDDEN); let mut admin = user.with(None); admin.login("Yvv", true).await; let (s, v) = admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Chiara", "groups": ["essai"] }))).await; assert_eq!(s, StatusCode::OK, "{v}"); let link = v["link"].as_str().expect("link").to_owned(); let token = link.split('#').nth(1).expect("token").to_owned(); let mut invited = user.with(None); let (s, v) = invited.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await; assert_eq!(s, StatusCode::OK, "{v}"); assert_eq!(v["me"]["kind"], "invite"); let (_, g) = invited.call("GET", "/api/groups", None).await; let open: Vec<&str> = g["groups"] .as_array() .expect("g") .iter() .filter(|g| g["open"] == true) .filter_map(|g| g["slug"].as_str()) .collect(); assert_eq!(open, ["essai"]); // Single use. let mut again = user.with(None); let (s, _) = again.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await; assert_eq!(s, StatusCode::BAD_REQUEST); // Monnaie libre is seen but closed to an invited identity. let (s, _) = invited.call("GET", "/api/groups/axiom", None).await; assert_eq!(s, StatusCode::OK); let (_, ov) = admin.call("GET", "/api/access", None).await; assert!(ov["identities"].as_array().expect("i").iter().any(|i| i["displayName"] == "Chiara")); } #[tokio::test] async fn in_monnaie_libre_only_web_of_trust_members_vote() { let (mut admin, _) = Client::new().await; admin.login("Yvv", true).await; let (_, v) = admin .call("POST", "/api/access/invitations", Some(json!({ "label": "Invitée", "groups": ["monnaie-libre"] }))) .await; let token = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned(); let mut invited = admin.with(None); let (s, _) = invited.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await; assert_eq!(s, StatusCode::OK); // She reads the text and may propose, but her vote would not be a web-of-trust vote. let id = doc_id(&mut invited, "monnaie-libre").await; let (s, doc) = invited.call("GET", &format!("/api/docs/{id}"), None).await; assert_eq!(s, StatusCode::OK); assert_eq!(doc["canVote"], false); let p = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("p").to_owned(); let (s, v) = invited.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; assert_eq!(s, StatusCode::CONFLICT, "{v}"); assert_eq!(v["error"], "wot_members_only"); // In the test group, every member votes. let essai = doc_id(&mut admin, "essai").await; let (_, d) = admin.call("GET", &format!("/api/docs/{essai}"), None).await; assert_eq!(d["canVote"], true); // A new link may only target an invitation identity, never another kind. let (_, me) = admin.call("GET", "/api/me", None).await; let dev_id = me["me"]["id"].as_str().expect("id").to_owned(); let (s, _) = admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Yvv", "identityId": dev_id }))).await; assert_eq!(s, StatusCode::BAD_REQUEST); } #[tokio::test] async fn feedback_reaches_administrators() { let (mut user, _) = Client::new().await; user.login("Chiara", false).await; let (s, _) = user .call("POST", "/api/feedback", Some(json!({ "route": "/g/essai", "body": "Le bouton est trop discret." }))) .await; assert_eq!(s, StatusCode::OK); let mut admin = user.with(None); admin.login("Yvv", true).await; let (_, v) = admin.call("GET", "/api/access/feedback", None).await; assert_eq!(v["feedback"][0]["body"], "Le bouton est trop discret."); } #[tokio::test] async fn publication_writes_the_texts_in_force_to_the_repository() { use std::process::Command; let dir = std::env::temp_dir().join(format!("ld-pub-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("dir"); let remote = dir.join("textes.git"); assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&remote).status().expect("git").success()); let mut cfg = Config::for_tests(); cfg.data_dir = dir.join("data"); cfg.publish = Some(ld_server::config::Publish { remote: remote.display().to_string(), branch: "main".into(), key: dir.join("data/keys/publish_ed25519"), author_name: "libreDecision".into(), author_email: "decision@example.org".into(), }); let db = open_db("sqlite::memory:").await.expect("db"); let state = AppState::new(cfg, db).await.expect("state"); seed::ensure(&state).await.expect("seed"); ld_server::publish::pass(&state).await.expect("pass"); let show = |path: &str| { let out = Command::new("git") .arg("-C") .arg(&remote) .args(["show", &format!("main:{path}")]) .output() .expect("git show"); String::from_utf8(out.stdout).expect("utf8") }; let fr = show("monnaie-libre/acte-engagement-certification/fr.md"); assert!(fr.contains("# Acte d'engagement Certification"), "{fr}"); assert!(fr.contains("## Préambule") && fr.contains("## Unicité du compte") && fr.contains("## Réciprocité")); assert!(!fr.contains("Connaissance suffisante"), "only the texts in force are published"); assert!(show("monnaie-libre/acte-engagement-certification/en.md").contains("traduction: editoriale")); let json: Value = serde_json::from_str(&show("monnaie-libre/acte-engagement-certification/document.json")).expect("json"); assert_eq!(json["units"].as_array().expect("units").len(), 3); assert_eq!(json["units"][0]["status"], "seed-minimal"); assert!(show("README.md").contains("libreDecision")); let done: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL") .fetch_one(&state.db) .await .expect("count"); assert_eq!(done, 0); // A restart queues the document again; nothing changed, so nothing is committed. let head = || { let out = Command::new("git").arg("-C").arg(&remote).args(["rev-parse", "main"]).output().expect("rev-parse"); String::from_utf8(out.stdout).expect("utf8") }; let before = head(); ld_server::publish::resync(&state).await.expect("resync"); ld_server::publish::pass(&state).await.expect("pass"); assert_eq!(head(), before, "an unchanged document produces no commit"); let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL") .fetch_one(&state.db) .await .expect("count"); assert_eq!(pending, 0); let _ = std::fs::remove_dir_all(&dir); }