//! The vote, grouped, and the vote on the Ğ1 chain (ADR 0008): sealed by the //! server, confirmed by reading an indexer — here a small simulated indexer that //! answers the GraphQL query in the shape of the real one //! (`tests/fixtures/indexer-merci.json`, relevé on indexer.duniter.org). use axum::body::Body; use axum::extract::State; use axum::http::{Request, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::post; use axum::{Json, Router}; use http_body_util::BodyExt; use ld_engine::address::{self, G1_PREFIX}; use ld_engine::ballot::{blake2_256, to_hex}; use ld_server::config::VoteChain; use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, ballots, open_db, seed}; use serde_json::{Value, json}; use std::sync::{Arc, Mutex}; use tower::ServiceExt; // Substrate's well-known development keys, as Ğ1 addresses: no real account is touched. const VOTE_ACCOUNT: &str = "g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM"; // Dave const ALICE: &str = "g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu"; const ALICE_GENERIC: &str = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; const BOB: &str = "g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g"; const CHARLIE: &str = "g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi"; const OTHER: &str = "g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj"; // Eve // --------------------------------------------------------------------------- // A simulated indexer // --------------------------------------------------------------------------- #[derive(Default)] struct Chain { comments: Vec, silent: bool, asked: usize, /// The last final block, served as `squidStatus.height`; `None`: every block is final. finalized: Option, /// An indexer that ignores the filter it is given (the hash only holds). lax: bool, /// The filters the server asked with, in order. filters: Vec, } #[derive(Clone)] struct Indexer { chain: Arc>, url: String, } /// Who signed, and the identity of that account, as the indexer serves it. struct Signer<'a> { account: &'a str, identity: Option<(u32, &'a str, bool)>, } /// What the real indexer's `filter` keeps, for the two filters the server may ask. fn kept_by(node: &Value, filter: &Value) -> bool { if let Some(author) = filter["authorId"]["equalTo"].as_str() && node["author"]["id"] != author { return false; } let some = &filter["transfersByCommentId"]["some"]; if some.is_null() { return true; } let min: u64 = some["amount"]["greaterThanOrEqualTo"].as_str().and_then(|a| a.parse().ok()).unwrap_or(0); node["transfersByCommentId"]["nodes"].as_array().into_iter().flatten().any(|t| { some["toId"]["equalTo"].as_str().is_none_or(|to| t["toId"] == to) && t["amount"].as_str().and_then(|a| a.parse::().ok()).is_some_and(|a| a >= min) }) } /// The size of a page: `first: N` in the query, as the server writes it. fn page_size(query: &str) -> usize { query .split("first:") .nth(1) .and_then(|rest| rest.trim_start().split(|c: char| !c.is_ascii_digit()).next()) .and_then(|n| n.parse().ok()) .unwrap_or(100) } async fn graphql(State(chain): State>>, Json(body): Json) -> Response { let mut c = chain.lock().expect("lock"); c.asked += 1; if c.silent { return StatusCode::BAD_GATEWAY.into_response(); } let vars = &body["variables"]; let hash = vars["hash"].as_str().unwrap_or_default().to_owned(); let filter = vars["filter"].clone(); c.filters.push(filter.clone()); let mut nodes: Vec = c .comments .iter() .filter(|n| format!("0x{}", to_hex(&blake2_256(n["remark"].as_str().unwrap_or_default().as_bytes()))) == hash) .filter(|n| c.lax || kept_by(n, &filter)) .cloned() .collect(); nodes.sort_by_key(|n| (n["blockNumber"].as_u64(), n["id"].as_str().map(str::to_owned))); // Pages, as PostGraphile serves them: `first`, then `after` the cursor of the last one. let start: usize = vars["after"].as_str().and_then(|a| a.strip_prefix("at:")).and_then(|n| n.parse().ok()).unwrap_or(0); let page: Vec = nodes.iter().skip(start).take(page_size(body["query"].as_str().unwrap_or_default())).cloned().collect(); let end = start + page.len(); Json(json!({ "data": { "squidStatus": { "height": c.finalized.unwrap_or(1_000_000_000) }, "txComments": { "pageInfo": { "hasNextPage": end < nodes.len(), "endCursor": format!("at:{end}") }, "nodes": page, }, } })) .into_response() } impl Indexer { async fn start() -> Self { let chain = Arc::new(Mutex::new(Chain::default())); let router = Router::new().route("/v1/graphql", post(graphql)).with_state(chain.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("addr"); tokio::spawn(async move { axum::serve(listener, router).await.expect("serve") }); Self { chain, url: format!("http://{addr}/v1/graphql") } } /// A transaction on the chain: the remark, signed by `signer`, with its transfers. fn add(&self, block: u32, event: u32, remark: &str, signer: Signer<'_>, transfers: &[(&str, u64)]) { let identity = signer.identity.map(|(index, name, member)| json!({ "index": index, "name": name, "isMember": member })); let transfers: Vec = transfers .iter() .map(|(to, amount)| json!({ "fromId": signer.account, "toId": to, "amount": amount.to_string() })) .collect(); self.chain.lock().expect("lock").comments.push(json!({ "id": format!("{block:010}-abcde-{event:06}"), "blockNumber": block, "remark": remark, "author": { "id": signer.account, "identity": identity }, "transfersByCommentId": { "nodes": transfers }, })); } /// The usual vote: 0.1 Ğ1 to the vote account. fn pay(&self, block: u32, remark: &str, signer: Signer<'_>) { self.add(block, 1, remark, signer, &[(VOTE_ACCOUNT, 10)]); } fn silent(&self, on: bool) { self.chain.lock().expect("lock").silent = on; } fn asked(&self) -> usize { self.chain.lock().expect("lock").asked } /// The chain is final up to this block. fn finalized(&self, block: Option) { self.chain.lock().expect("lock").finalized = block; } fn lax(&self, on: bool) { self.chain.lock().expect("lock").lax = on; } fn filters(&self) -> Vec { self.chain.lock().expect("lock").filters.clone() } } fn alice() -> Signer<'static> { Signer { account: ALICE, identity: Some((9001, "Alice", true)) } } fn chain_config(indexer: &Indexer) -> Config { let mut cfg = Config::for_tests(); cfg.indexers = vec![indexer.url.clone()]; cfg.vote = VoteChain::new(VOTE_ACCOUNT, 10, vec!["wss://node.invalid".into()], None); cfg } // --------------------------------------------------------------------------- // A client of the API // --------------------------------------------------------------------------- struct Client { router: axum::Router, cookie: Option, } impl Client { async fn new(cfg: Config) -> (Self, AppState) { let db = open_db("sqlite::memory:").await.expect("db"); let state = AppState::new(cfg, db).await.expect("state"); seed::ensure(&state).await.expect("seed"); (Self { router: app(state.clone()), cookie: None }, state) } fn other(&self) -> Self { Self { router: self.router.clone(), cookie: None } } async fn call(&mut self, method: &str, uri: &str, body: Option) -> (StatusCode, Value) { let (status, _, value) = self.call_with_headers(method, uri, body).await; (status, value) } async fn call_with_headers( &mut self, method: &str, uri: &str, body: Option, ) -> (StatusCode, axum::http::HeaderMap, 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 headers = res.headers().clone(); let bytes = res.into_body().collect().await.expect("body").to_bytes(); (status, headers, serde_json::from_slice(&bytes).unwrap_or(Value::Null)) } async fn ok(&mut self, method: &str, uri: &str, body: Option) -> Value { let (s, v) = self.call(method, uri, body).await; assert_eq!(s, StatusCode::OK, "{method} {uri}: {v}"); v } async fn login(&mut self, name: &str) { self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).await; } async fn login_admin(&mut self, name: &str) { self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": true }))).await; } async fn doc(&mut self, group: &str) -> Value { let g = self.ok("GET", &format!("/api/groups/{group}"), None).await; let id = g["docs"][0]["id"].as_str().expect("doc").to_owned(); self.ok("GET", &format!("/api/docs/{id}"), None).await } /// A pending ballot for `lines` (version id, choice). async fn ballot(&mut self, doc: &Value, lines: &[(&str, Option<&str>)], author: Option<&str>) -> Value { let id = doc["doc"]["id"].as_str().expect("id"); let lines: Vec = lines.iter().map(|(p, c)| json!({ "proposalId": p, "choice": c })).collect(); self.ok("POST", &format!("/api/docs/{id}/ballots"), Some(json!({ "lines": lines, "author": author }))).await["ballot"] .clone() } async fn status(&mut self, ballot: &Value) -> Value { let id = ballot["id"].as_str().expect("id"); self.ok("GET", &format!("/api/ballots/{id}"), None).await["ballot"].clone() } } fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value { doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit") } /// The id of a version: `inForce`, or the n-th open proposal. fn version(doc: &Value, code: &str, which: &str) -> String { let u = unit(doc, code); let v = if which == "inForce" { &u["inForce"] } else { &u["proposals"][which.parse::().expect("n")] }; v["id"].as_str().expect("id").to_owned() } /// The version as the document shows it now, by id. fn find<'a>(doc: &'a Value, id: &str) -> &'a Value { doc["units"] .as_array() .expect("units") .iter() .flat_map(|u| std::iter::once(&u["inForce"]).chain(u["proposals"].as_array().expect("p").iter())) .find(|p| p["id"] == id) .expect("version") } fn tally(doc: &Value, id: &str) -> (u64, u64) { let t = &find(doc, id)["tally"]; (t["for"].as_u64().expect("for"), t["against"].as_u64().expect("against")) } async fn rewind_check(state: &AppState) { // The page asks often, the indexer is read at most every few seconds: tests rewind that clock. sqlx::query("UPDATE ballots SET checked_at = NULL").execute(&state.db).await.expect("rewind"); } // --------------------------------------------------------------------------- // The vote on the chain // --------------------------------------------------------------------------- #[tokio::test] async fn a_grouped_ballot_is_sealed_then_confirmed_by_the_indexer() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; // Monnaie libre votes on the chain; this session may ask for a ballot. assert_eq!(doc["vote"]["way"], "chain"); assert_eq!(doc["vote"]["open"], true); assert_eq!(doc["canVote"], true); assert_eq!(doc["vote"]["chain"]["account"], VOTE_ACCOUNT); assert_eq!(doc["vote"]["chain"]["amount"], 10); assert_eq!(doc["vote"]["chain"]["test"], false); let i1 = version(&doc, "I1", "inForce"); let e3 = version(&doc, "E3", "0"); let b = c.ballot(&doc, &[(&i1, Some("for")), (&e3, Some("against"))], Some(ALICE_GENERIC)).await; let comment = b["comment"].as_str().expect("comment"); assert!(comment.starts_with("ld1:") && comment.len() == 68 && comment[4..].bytes().all(|x| x.is_ascii_hexdigit())); assert_eq!(b["status"], "pending"); assert_eq!(b["account"], VOTE_ACCOUNT); assert_eq!(b["author"], ALICE, "the bound account, written as a Ğ1 address"); assert_eq!(b["lines"], 2); // What the server keeps: the lines, the salt, and the seal they make. let (digest, salt): (String, String) = sqlx::query_as("SELECT digest, salt FROM ballots").fetch_one(&state.db).await.expect("ballot"); assert_eq!(format!("ld1:{digest}"), comment); assert_eq!(salt.len(), 32); let doc_id = doc["doc"]["id"].as_str().expect("id"); let lines = [ ld_engine::ballot::Line { proposal_id: i1.clone(), revision: 1, choice: ld_engine::ballot::Choice::For }, ld_engine::ballot::Line { proposal_id: e3.clone(), revision: 1, choice: ld_engine::ballot::Choice::Against }, ]; assert_eq!(ld_engine::ballot::seal(doc_id, Some(ALICE), &lines, &salt).expect("seal").comment, comment); // Nothing on the chain yet: pending, nothing counted. assert_eq!(c.status(&b).await["status"], "pending"); let doc = c.doc("monnaie-libre").await; assert_eq!(tally(&doc, &i1), (0, 0)); assert_eq!(find(&doc, &i1)["myBallot"]["status"], "pending"); assert_eq!(find(&doc, &i1)["myBallot"]["choice"], "for"); // The transaction lands: one transaction for both lines. indexer.pay(3_400_000, comment, alice()); rewind_check(&state).await; let s = c.status(&b).await; assert_eq!(s["status"], "confirmed", "{s}"); assert_eq!(s["voter"], "Alice"); assert_eq!(s["block"], 3_400_000); let doc = c.doc("monnaie-libre").await; assert_eq!(tally(&doc, &i1), (1, 0)); assert_eq!(tally(&doc, &e3), (0, 1)); // The session that asked sees « ton vote », confirmed on the chain. assert_eq!(find(&doc, &i1)["myVote"], "for"); assert_eq!(find(&doc, &i1)["myVoteChain"], true); assert!(find(&doc, &i1)["myBallot"].is_null()); assert_eq!(find(&doc, &e3)["myVote"], "against"); // The vote counts for the Ğ1 identity of the signer, created as a member. let row: (String, String, bool, Option) = sqlx::query_as("SELECT kind, display_name, g1_member, address FROM identities WHERE id = 'idty:9001'") .fetch_one(&state.db) .await .expect("identity"); assert_eq!(row, ("g1".into(), "Alice".into(), true, Some(ALICE.into()))); let voters: Vec<(String, String)> = sqlx::query_as("SELECT identity_id, source FROM votes ORDER BY proposal_id") .fetch_all(&state.db) .await .expect("votes"); assert!(voters.iter().all(|(who, source)| who == "idty:9001" && source == "chain"), "{voters:?}"); // One voice never makes a text pass (C = 50). assert_eq!(find(&doc, &i1)["evaluation"]["required"], 50); // Someone else sees the totals, not the vote. let mut yvv = c.other(); yvv.login("Yvv").await; let seen = yvv.doc("monnaie-libre").await; assert_eq!(tally(&seen, &i1), (1, 0)); assert!(find(&seen, &i1)["myVote"].is_null()); // Asked again, a confirmed ballot stays as it is. rewind_check(&state).await; assert_eq!(c.status(&b).await["status"], "confirmed"); assert_eq!( yvv.call("GET", &format!("/api/ballots/{}", b["id"].as_str().expect("id")), None).await.0, StatusCode::NOT_FOUND ); } #[tokio::test] async fn one_voice_on_the_chain_never_adopts_in_monnaie_libre() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Yvv").await; let doc = c.doc("monnaie-libre").await; let p = version(&doc, "E3", "0"); let b = c.ballot(&doc, &[(&p, Some("for"))], None).await; indexer.pay(100, b["comment"].as_str().expect("c"), alice()); ballots::sweep(&state).await.expect("sweep"); let s = c.status(&b).await; assert_eq!(s["status"], "confirmed"); assert_eq!(s["entered"], json!([])); let d = c.ok("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"); assert_eq!(d["proposal"]["myVoteChain"], true); } #[tokio::test] async fn a_silent_indexer_keeps_the_ballot_pending() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let i1 = version(&doc, "I1", "inForce"); let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await; indexer.pay(100, b["comment"].as_str().expect("comment"), alice()); indexer.silent(true); assert_eq!(c.status(&b).await["status"], "pending"); assert!(indexer.asked() >= 1); // Asked again at once: the indexer is not read again before a few seconds. let asked = indexer.asked(); assert_eq!(c.status(&b).await["status"], "pending"); assert_eq!(indexer.asked(), asked); // The background task reads it again; silent still, nothing changes. assert_eq!(ballots::sweep(&state).await.expect("sweep"), 1); assert_eq!(c.status(&b).await["status"], "pending"); // The indexer is back. indexer.silent(false); ballots::sweep(&state).await.expect("sweep"); assert_eq!(c.status(&b).await["status"], "confirmed"); assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0, "nothing left to read"); } #[tokio::test] async fn a_signer_who_is_not_a_member_is_refused_and_told() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let i1 = version(&doc, "I1", "inForce"); let former = c.ballot(&doc, &[(&i1, Some("for"))], None).await; indexer.pay( 100, former["comment"].as_str().expect("c"), Signer { account: BOB, identity: Some((42, "Bob", false)) }, ); let nobody = c.ballot(&doc, &[(&i1, Some("for"))], None).await; indexer.pay(101, nobody["comment"].as_str().expect("c"), Signer { account: BOB, identity: None }); ballots::sweep(&state).await.expect("sweep"); let s = c.status(&former).await; assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("not_member"))); let s = c.status(&nobody).await; assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("no_identity"))); let doc = c.doc("monnaie-libre").await; assert_eq!(tally(&doc, &i1), (0, 0)); // The page says so on the version. assert_eq!(find(&doc, &i1)["myBallot"]["status"], "refused"); assert_eq!(find(&doc, &i1)["myBallot"]["reason"], "no_identity"); let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE id = 'idty:42'") .fetch_one(&state.db) .await .expect("n"); assert_eq!(n, 0, "no identity is created for a refused ballot"); } #[tokio::test] async fn a_wrong_amount_or_recipient_is_refused() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let i1 = version(&doc, "I1", "inForce"); // Bound to the wallet that signs: its own transaction without the transfer is refused, and told. let short = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; indexer.add(100, 1, short["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]); let elsewhere = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; indexer.add(101, 1, elsewhere["comment"].as_str().expect("c"), alice(), &[(OTHER, 10)]); let remark_only = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; indexer.add(102, 1, remark_only["comment"].as_str().expect("c"), alice(), &[]); // Unbound (Ğecko): an unpaid comment settles nothing — anyone could send it for free. let gecko = c.ballot(&doc, &[(&i1, Some("for"))], None).await; indexer.add(103, 1, gecko["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]); ballots::sweep(&state).await.expect("sweep"); for b in [&short, &elsewhere, &remark_only] { let s = c.status(b).await; assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("transfer")), "{s}"); } assert_eq!(c.status(&gecko).await["status"], "pending"); assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (0, 0)); } #[tokio::test] async fn confirming_twice_changes_nothing() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let i1 = version(&doc, "I1", "inForce"); let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await; indexer.pay(100, b["comment"].as_str().expect("c"), alice()); // The page and the background task at once. let row = sqlx::query_as::<_, ballots::BallotRow>( "SELECT id, doc_id, requested_by, author, digest, remark_hash, account, amount, status, reason, voter_id, block, entered, created_at, expires_at, checked_at FROM ballots", ) .fetch_one(&state.db) .await .expect("row"); let (a, b2) = tokio::join!(ballots::check(&state, &row), ballots::check(&state, &row)); a.expect("check"); b2.expect("check"); ballots::check(&state, &row).await.expect("again"); assert_eq!(c.status(&b).await["status"], "confirmed"); let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes").fetch_one(&state.db).await.expect("n"); assert_eq!(n, 1); assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (1, 0)); } #[tokio::test] async fn the_last_one_stands_in_the_order_of_the_chain() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let i1 = version(&doc, "I1", "inForce"); let e1 = version(&doc, "E1", "inForce"); // Three ballots of the same person, signed in this order on the chain: for (I1, E1), against (I1), none (E1). let first = c.ballot(&doc, &[(&i1, Some("for")), (&e1, Some("for"))], None).await; let second = c.ballot(&doc, &[(&i1, Some("against"))], None).await; let third = c.ballot(&doc, &[(&e1, None)], None).await; // The most recent is confirmed first; the oldest last, as when an indexer lags behind. indexer.pay(300, third["comment"].as_str().expect("c"), alice()); ballots::sweep(&state).await.expect("sweep"); indexer.add(200, 4, second["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 10)]); ballots::sweep(&state).await.expect("sweep"); let doc = c.doc("monnaie-libre").await; assert_eq!((tally(&doc, &i1), tally(&doc, &e1)), ((0, 1), (0, 0))); indexer.add(200, 2, first["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 10)]); ballots::sweep(&state).await.expect("sweep"); for b in [&first, &second, &third] { assert_eq!(c.status(b).await["status"], "confirmed"); } // Block 200 event 2 comes before block 200 event 4, before block 300: the first ballot // decides nothing any more. I1 stays « against »; E1 stays withdrawn, never brought back. let doc = c.doc("monnaie-libre").await; assert_eq!(tally(&doc, &i1), (0, 1)); assert_eq!(tally(&doc, &e1), (0, 0)); assert_eq!(find(&doc, &i1)["myVote"], "against"); assert!(find(&doc, &e1)["myVote"].is_null()); // A new ballot, later on the chain, decides again. let fourth = c.ballot(&doc, &[(&e1, Some("against"))], None).await; indexer.pay(301, fourth["comment"].as_str().expect("c"), alice()); ballots::sweep(&state).await.expect("sweep"); assert_eq!(tally(&c.doc("monnaie-libre").await, &e1), (0, 1)); } #[tokio::test] async fn a_revision_changed_meanwhile_leaves_the_vote_to_reconfirm() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); let created = c .ok( "POST", &format!("/api/docs/{doc_id}/proposals"), Some(json!({ "title": "Joignabilité", "body": "Je m'engage à rester joignable par mes certifieurs.", "submit": true })), ) .await; let p = created["id"].as_str().expect("id").to_owned(); let doc = c.doc("monnaie-libre").await; let b = c.ballot(&doc, &[(&p, Some("for"))], None).await; // The author changes its meaning before the transaction is confirmed. let edit = c .ok( "PATCH", &format!("/api/proposals/{p}"), Some( json!({ "title": "Joignabilité", "body": "Je m'engage à répondre à mes certifieurs sous huit jours." }), ), ) .await; assert_eq!(edit["edit"], "substantive"); indexer.pay(100, b["comment"].as_str().expect("c"), alice()); ballots::sweep(&state).await.expect("sweep"); assert_eq!(c.status(&b).await["status"], "confirmed"); // The vote was cast on revision 1: it waits to be reconfirmed, as any vote does. let doc = c.doc("monnaie-libre").await; assert_eq!(tally(&doc, &p), (0, 0)); assert_eq!(find(&doc, &p)["myVote"], "for"); assert_eq!(find(&doc, &p)["myVoteStale"], true); let rev: i64 = sqlx::query_scalar("SELECT revision FROM votes WHERE proposal_id = ?") .bind(&p) .fetch_one(&state.db) .await .expect("vote"); assert_eq!(rev, 1); } #[tokio::test] async fn a_bound_ballot_ignores_a_copy_of_its_remark_by_another_account() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let i1 = version(&doc, "I1", "inForce"); let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; let comment = b["comment"].as_str().expect("c"); // Charlie copies the remark seen in the pool and gets it in first. indexer.pay(99, comment, Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) }); ballots::sweep(&state).await.expect("sweep"); assert_eq!(c.status(&b).await["status"], "pending", "a copy settles nothing"); indexer.pay(100, comment, alice()); ballots::sweep(&state).await.expect("sweep"); let s = c.status(&b).await; assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Alice"))); let voters: Vec = sqlx::query_scalar("SELECT identity_id FROM votes").fetch_all(&state.db).await.expect("v"); assert_eq!(voters, ["idty:9001"]); } #[tokio::test] async fn a_ballot_not_seen_in_time_is_let_go() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let i1 = version(&doc, "I1", "inForce"); let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await; let expires = b["expiresAt"].as_str().expect("expires"); assert!( expires > ld_server::util::in_seconds(23 * 3600).as_str() && expires <= ld_server::util::in_seconds(24 * 3600).as_str() ); sqlx::query("UPDATE ballots SET expires_at = ?") .bind(ld_server::util::in_seconds(-1)) .execute(&state.db) .await .expect("age"); // Read one last time at its end: nothing on the chain, it is let go. assert_eq!(ballots::sweep(&state).await.expect("sweep"), 1); assert_eq!(c.status(&b).await["status"], "expired"); assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0, "nothing left to read"); // Seen on the chain afterwards: too late, it no longer counts. indexer.pay(100, b["comment"].as_str().expect("c"), alice()); ballots::sweep(&state).await.expect("sweep"); assert_eq!(c.status(&b).await["status"], "expired"); assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["status"], "expired"); } #[tokio::test] async fn a_ballot_the_wallet_refused_to_sign_is_let_go_at_once() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let i1 = version(&doc, "I1", "inForce"); let first = c.ballot(&doc, &[(&i1, Some("against"))], Some(ALICE)).await; let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["id"], b["id"]); // Another session may not let it go. let mut other = c.other(); other.login("Yvv").await; let (s, _) = other.call("DELETE", &format!("/api/ballots/{}", b["id"].as_str().expect("id")), None).await; assert_eq!(s, StatusCode::NOT_FOUND); // Its own session does: no longer waiting, and it says nothing under the article. let dropped = c.ok("DELETE", &format!("/api/ballots/{}", b["id"].as_str().expect("id")), None).await; assert_eq!( (dropped["ballot"]["status"].as_str(), dropped["ballot"]["reason"].as_str()), (Some("expired"), Some("dropped")) ); assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["id"], first["id"]); // A confirmed ballot is never let go. indexer.pay(100, first["comment"].as_str().expect("c"), alice()); ballots::sweep(&state).await.expect("sweep"); let kept = c.ok("DELETE", &format!("/api/ballots/{}", first["id"].as_str().expect("id")), None).await; assert_eq!(kept["ballot"]["status"], "confirmed"); let now = c.doc("monnaie-libre").await; assert_eq!((find(&now, &i1)["myVote"].as_str(), find(&now, &i1)["myBallot"].is_null()), (Some("against"), true)); } #[tokio::test] async fn a_session_sees_its_own_ballots_only() { let indexer = Indexer::start().await; let (mut chiara, state) = Client::new(chain_config(&indexer)).await; chiara.login("Chiara").await; let mut other = chiara.other(); other.login("Yvv").await; let doc = chiara.doc("monnaie-libre").await; let (i1, e1) = (version(&doc, "I1", "inForce"), version(&doc, "E1", "inForce")); // Both sign with the same wallet (one person, two sessions): two ballots, two versions. let a = chiara.ballot(&doc, &[(&i1, Some("for"))], None).await; let b = other.ballot(&doc, &[(&e1, Some("against"))], None).await; indexer.pay(100, a["comment"].as_str().expect("c"), alice()); indexer.pay(101, b["comment"].as_str().expect("c"), alice()); ballots::sweep(&state).await.expect("sweep"); let mine = chiara.doc("monnaie-libre").await; assert_eq!((find(&mine, &i1)["myVote"].as_str(), find(&mine, &e1)["myVote"].as_str()), (Some("for"), None)); let theirs = other.doc("monnaie-libre").await; assert_eq!((find(&theirs, &i1)["myVote"].as_str(), find(&theirs, &e1)["myVote"].as_str()), (None, Some("against"))); // « Décisions » counts the same way (33 articles, the title, 7 orders with something to decide). let d = chiara.ok("GET", "/api/groups/monnaie-libre/decisions", None).await; assert_eq!(d["canVote"], true); assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(41), Some(40))); } #[tokio::test] async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let e4 = version(&doc, "E4", "0"); let e5 = version(&doc, "E5", "0"); let reason = "Ces deux textes disent le contraire l'un de l'autre."; let e1 = version(&doc, "E1", "inForce"); let keys: Vec = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect(); let mut last = Value::Null; for (n, account) in keys.iter().enumerate() { let b = c.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("for"))], None).await; let index = 10_000 + u32::try_from(n).expect("n"); indexer.pay( 1_000 + index, b["comment"].as_str().expect("c"), Signer { account, identity: Some((index, "Membre", true)) }, ); if n == 49 { // The last ballot is signed; before the chain confirms it, an objection holds // E5 back against the text in force E1 (ADR 0010): no new ballot may hold it. let o = c .ok( "POST", &format!("/api/proposals/{e5}/objections"), Some(json!({ "otherId": e1, "kind": "contredit", "reason": reason })), ) .await; assert_eq!(o["heldId"], e5.as_str()); let id = doc["doc"]["id"].as_str().expect("id"); let (s, v) = c .call( "POST", &format!("/api/docs/{id}/ballots"), Some(json!({ "lines": [{ "proposalId": e5, "choice": "for" }] })), ) .await; assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("objection"))); } last = b; if n % 10 == 9 { // A session holds at most 20 pending ballots: the background task confirms on the way. ballots::sweep(&state).await.expect("sweep"); } if n == 48 { ballots::sweep(&state).await.expect("sweep"); let d = c.doc("monnaie-libre").await; assert_eq!(tally(&d, &e4), (49, 0)); assert!(find(&d, &e4)["enteredAt"].is_null(), "49 voices: below C = 50"); } } ballots::sweep(&state).await.expect("sweep"); let s = c.status(&last).await; assert_eq!(s["status"], "confirmed"); assert_eq!(s["entered"][0]["code"], "E4", "{s}"); let d = c.doc("monnaie-libre").await; assert_eq!(unit(&d, "E4")["inForce"]["id"], e4.as_str()); // A ballot sealed before the objection counts, as a vote already cast: kept, but // the version held back cannot enter into force. assert_eq!(tally(&d, &e5), (50, 0)); assert_eq!(find(&d, &e5)["votable"], false); assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back"); } // --------------------------------------------------------------------------- // After the independent review of 74f96be (lot B2): its probes, kept as tests // --------------------------------------------------------------------------- async fn db_status(state: &AppState, id: &str) -> (String, Option) { sqlx::query_as("SELECT status, reason FROM ballots WHERE id = ?").bind(id).fetch_one(&state.db).await.expect("row") } fn id_of(b: &Value) -> &str { b["id"].as_str().expect("id") } fn comment_of(b: &Value) -> &str { b["comment"].as_str().expect("comment") } async fn age(state: &AppState, set: &str, seconds: i64, id: &str) { sqlx::query(&format!("UPDATE ballots SET {set} = ? WHERE id = ?")) .bind(ld_server::util::in_seconds(seconds)) .bind(id) .execute(&state.db) .await .expect("age"); } /// P5: the `author` field was decoded in a time that grows as the square of its /// length, on a thread of the server (250 000 characters: 32 s). #[tokio::test] async fn a_long_author_is_refused_at_once() { let indexer = Indexer::start().await; let (mut c, _) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); let i1 = version(&doc, "I1", "inForce"); // The body may hold 256 KiB: an author of 250 000 characters passes it. for n in [80_000usize, 250_000] { let body = json!({ "lines": [{ "proposalId": i1, "choice": "for" }], "author": "z".repeat(n) }); let t = std::time::Instant::now(); let (s, v) = c.call("POST", &format!("/api/docs/{doc_id}/ballots"), Some(body)).await; assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("author"))); assert!(t.elapsed() < std::time::Duration::from_secs(1), "{n} characters: {:?}", t.elapsed()); } } /// P1: the background task read the same 50 oldest pending ballots at every pass; /// a vote paid behind them was never confirmed, then expired. #[tokio::test] async fn the_background_task_reaches_every_pending_ballot() { let indexer = Indexer::start().await; let (mut a, state) = Client::new(chain_config(&indexer)).await; a.login("Anna").await; let doc = a.doc("monnaie-libre").await; let i1 = version(&doc, "I1", "inForce"); let mut b = a.other(); b.login("Bruno").await; let mut c = a.other(); c.login("Carla").await; for _ in 0..20 { a.ballot(&doc, &[(&i1, Some("for"))], None).await; b.ballot(&doc, &[(&i1, Some("for"))], None).await; } for _ in 0..10 { c.ballot(&doc, &[(&i1, Some("for"))], None).await; } let mut dora = a.other(); dora.login("Dora").await; let mine = dora.ballot(&doc, &[(&i1, Some("against"))], None).await; indexer.pay(100, comment_of(&mine), alice()); // 51 pending, at most 50 read per pass: the least recently read come first. ballots::sweep(&state).await.expect("sweep"); ballots::sweep(&state).await.expect("sweep"); assert_eq!(db_status(&state, id_of(&mine)).await.0, "confirmed"); let unread: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ballots WHERE checked_at IS NULL") .fetch_one(&state.db) .await .expect("n"); assert_eq!(unread, 0, "every pending ballot was read"); } /// P2: `DELETE` freed the place at once — one session made 100 ballots and 3 500 lines. #[tokio::test] async fn letting_ballots_go_does_not_lift_the_cap() { let indexer = Indexer::start().await; let (mut a, state) = Client::new(chain_config(&indexer)).await; a.login("Anna").await; let doc = a.doc("monnaie-libre").await; let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); let i1 = version(&doc, "I1", "inForce"); for _ in 0..20 { let b = a.ballot(&doc, &[(&i1, Some("for"))], None).await; a.ok("DELETE", &format!("/api/ballots/{}", id_of(&b)), None).await; } let one = json!({ "lines": [{ "proposalId": i1, "choice": "for" }] }); let url = format!("/api/docs/{doc_id}/ballots"); let (s, v) = a.call("POST", &url, Some(one.clone())).await; assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("too_many_ballots"))); // The cap holds for the identity, whatever the session. let mut again = a.other(); again.login("Anna").await; assert_eq!(again.call("POST", &url, Some(one.clone())).await.0, StatusCode::CONFLICT); // An hour later, there is room again. sqlx::query("UPDATE ballots SET settled_at = ? WHERE reason = 'dropped'") .bind(ld_server::util::in_seconds(-3601)) .execute(&state.db) .await .expect("age"); let kept = a.ballot(&doc, &[(&i1, Some("for"))], None).await; // Let go more than a day ago: purged, lines and all; the pending one stays. sqlx::query("UPDATE ballots SET settled_at = ? WHERE reason = 'dropped'") .bind(ld_server::util::in_seconds(-(24 * 3600 + 1))) .execute(&state.db) .await .expect("age"); ballots::sweep(&state).await.expect("sweep"); let count = || async { sqlx::query_as::<_, (i64, i64)>("SELECT (SELECT COUNT(*) FROM ballots), (SELECT COUNT(*) FROM ballot_lines)") .fetch_one(&state.db) .await .expect("n") }; assert_eq!(count().await, (1, 1)); // Not seen on the chain in time: it says so under the article for a week, then it is purged. age(&state, "expires_at", -3601 - 5, id_of(&kept)).await; ballots::sweep(&state).await.expect("sweep"); assert_eq!(db_status(&state, id_of(&kept)).await.0, "expired"); age(&state, "settled_at", -6 * 24 * 3600, id_of(&kept)).await; ballots::sweep(&state).await.expect("sweep"); assert_eq!(count().await, (1, 1)); age(&state, "settled_at", -8 * 24 * 3600, id_of(&kept)).await; ballots::sweep(&state).await.expect("sweep"); assert_eq!(count().await, (0, 0)); } /// P3: a bare copy of the remark — free to send — refused a Ğecko ballot for good. #[tokio::test] async fn an_unbound_ballot_is_not_settled_by_an_unpaid_copy() { let indexer = Indexer::start().await; // An indexer that does not filter: the rule of the engine holds alone. indexer.lax(true); let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let i1 = version(&doc, "I1", "inForce"); let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await; indexer.add(99, 1, comment_of(&b), Signer { account: OTHER, identity: None }, &[]); indexer.add( 99, 2, comment_of(&b), Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) }, &[(VOTE_ACCOUNT, 9)], ); ballots::sweep(&state).await.expect("sweep"); assert_eq!(c.status(&b).await["status"], "pending", "an unpaid copy settles nothing"); indexer.pay(100, comment_of(&b), alice()); ballots::sweep(&state).await.expect("sweep"); let s = c.status(&b).await; assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Alice")), "{s}"); assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (1, 0)); } /// P4, the limit that stays (ADR 0008): a paid copy, earlier on the chain, takes /// an unbound ballot — its lines count for the one who paid. #[tokio::test] async fn an_unbound_ballot_can_be_taken_by_a_paid_copy() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let i1 = version(&doc, "I1", "inForce"); let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await; indexer.pay(99, comment_of(&b), Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) }); indexer.pay(100, comment_of(&b), alice()); ballots::sweep(&state).await.expect("sweep"); let s = c.status(&b).await; assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Charlie"))); let voters: Vec = sqlx::query_scalar("SELECT identity_id FROM votes").fetch_all(&state.db).await.expect("v"); assert_eq!(voters, ["idty:7"]); } /// Point 5 of the review: a pending ballot older than a confirmed vote on the same /// line no longer shows under the article. #[tokio::test] async fn an_older_pending_ballot_never_hides_a_newer_confirmed_vote() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let i1 = version(&doc, "I1", "inForce"); // A Ğecko code shown, never paid; then a vote from the wallet, confirmed. c.ballot(&doc, &[(&i1, Some("against"))], None).await; let newer = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; indexer.pay(100, comment_of(&newer), alice()); ballots::sweep(&state).await.expect("sweep"); let d = c.doc("monnaie-libre").await; assert_eq!(find(&d, &i1)["myVote"], "for"); assert!(find(&d, &i1)["myBallot"].is_null(), "{}", find(&d, &i1)["myBallot"]); let p = c.ok("GET", &format!("/api/proposals/{i1}"), None).await; assert!(p["proposal"]["myBallot"].is_null()); // A ballot asked after the vote shows, waiting. let after = c.ballot(&doc, &[(&i1, Some("against"))], Some(ALICE)).await; assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["id"], after["id"]); } /// Point 6: only a comment in a final block counts, or refuses. #[tokio::test] async fn a_comment_counts_once_its_block_is_final() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let i1 = version(&doc, "I1", "inForce"); let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; let r = c.ballot(&doc, &[(&i1, Some("for"))], Some(BOB)).await; indexer.finalized(Some(99)); indexer.pay(100, comment_of(&b), alice()); indexer.add( 100, 2, comment_of(&r), Signer { account: BOB, identity: Some((42, "Bob", false)) }, &[(VOTE_ACCOUNT, 10)], ); ballots::sweep(&state).await.expect("sweep"); assert_eq!(db_status(&state, id_of(&b)).await.0, "pending"); assert_eq!(db_status(&state, id_of(&r)).await.0, "pending"); assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (0, 0)); indexer.finalized(Some(100)); ballots::sweep(&state).await.expect("sweep"); assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed"); assert_eq!(db_status(&state, id_of(&r)).await, ("refused".into(), Some("not_member".into()))); } /// Point 6: the search by hash went no further than 20 comments; copies could hide the real one. #[tokio::test] async fn copies_never_hide_the_real_comment() { let indexer = Indexer::start().await; // An indexer that would not filter: pages, one after the other. indexer.lax(true); let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let i1 = version(&doc, "I1", "inForce"); let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; for n in 0..120 { indexer.pay(100 + n, comment_of(&b), Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) }); } indexer.pay(300, comment_of(&b), alice()); ballots::sweep(&state).await.expect("sweep"); let s = c.status(&b).await; assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Alice")), "{s}"); // The indexer is asked for what may settle: the comments of the bound account; // for a Ğecko code, those paid to the vote account. indexer.lax(false); c.ballot(&doc, &[(&i1, Some("against"))], None).await; ballots::sweep(&state).await.expect("sweep"); let filters = indexer.filters(); assert!(filters.iter().any(|f| f["authorId"]["equalTo"] == ALICE), "{filters:?}"); assert!( filters.iter().any(|f| { let some = &f["transfersByCommentId"]["some"]; some["toId"]["equalTo"] == VOTE_ACCOUNT && some["amount"]["greaterThanOrEqualTo"] == "10" }), "{filters:?}" ); } /// Point 5: a Ğecko code keeps its ballot; the background task settles it, the /// page closed — even paid at the last moment, read once more at its end. #[tokio::test] async fn a_gecko_ballot_is_settled_by_the_background_task_even_at_its_end() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let i1 = version(&doc, "I1", "inForce"); let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await; age(&state, "expires_at", -5, id_of(&b)).await; indexer.pay(100, comment_of(&b), alice()); ballots::sweep(&state).await.expect("sweep"); assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed"); // Its end come while no indexer answers: kept and read again, for an hour at most. let late = c.ballot(&doc, &[(&i1, Some("against"))], None).await; age(&state, "expires_at", -5, id_of(&late)).await; indexer.silent(true); ballots::sweep(&state).await.expect("sweep"); assert_eq!(db_status(&state, id_of(&late)).await.0, "pending"); age(&state, "expires_at", -3601, id_of(&late)).await; ballots::sweep(&state).await.expect("sweep"); assert_eq!(db_status(&state, id_of(&late)).await.0, "expired"); } /// Point 7: the research note promises everyone can check their own vote with its /// salt. The receipt goes to the one who asked, never to anyone else. #[tokio::test] async fn the_receipt_is_for_the_one_who_asked_and_proves_the_vote() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let (i1, e3) = (version(&doc, "I1", "inForce"), version(&doc, "E3", "0")); let b = c.ballot(&doc, &[(&i1, Some("for")), (&e3, Some("against"))], Some(ALICE_GENERIC)).await; indexer.pay(100, comment_of(&b), alice()); ballots::sweep(&state).await.expect("sweep"); let url = format!("/api/ballots/{}/receipt", id_of(&b)); let (s, headers, r) = c.call_with_headers("GET", &url, None).await; assert_eq!(s, StatusCode::OK, "{r}"); let disposition = headers.get(header::CONTENT_DISPOSITION).and_then(|h| h.to_str().ok()).unwrap_or_default(); assert!(disposition.starts_with("attachment;") && disposition.contains(".json"), "{disposition}"); assert_eq!( (r["format"].as_str(), r["status"].as_str(), r["block"].as_u64(), r["voter"].as_str()), (Some("ld1"), Some("confirmed"), Some(100), Some("Alice")) ); assert_eq!((r["comment"].as_str(), r["author"].as_str()), (Some(comment_of(&b)), Some(ALICE))); // It holds: its lines and its salt seal the remark the chain carries. let lines: Vec = r["lines"] .as_array() .expect("lines") .iter() .map(|l| ld_engine::ballot::Line { proposal_id: l["proposalId"].as_str().expect("p").into(), revision: u32::try_from(l["revision"].as_u64().expect("r")).expect("r"), choice: ld_engine::ballot::Choice::parse(l["choice"].as_str().expect("c")).expect("c"), }) .collect(); assert_eq!(lines.len(), 2); let doc_id = r["doc"].as_str().expect("doc"); let salt = r["salt"].as_str().expect("salt"); let sealed = ld_engine::ballot::seal(doc_id, r["author"].as_str(), &lines, salt).expect("seal"); assert_eq!( (sealed.comment.as_str(), sealed.remark_hash.as_str()), (comment_of(&b), r["remarkHash"].as_str().expect("h")) ); assert_eq!( r["canonical"].as_str(), Some(ld_engine::ballot::canonical(doc_id, r["author"].as_str(), &lines, salt).expect("c").as_str()) ); // Each line says which article it is. assert!(r["lines"].as_array().expect("l").iter().any(|l| l["article"] == "I1")); // In her history, never in anyone else's. let mine = c.ok("GET", "/api/ballots", None).await; assert_eq!((mine["ballots"][0]["id"].as_str(), mine["ballots"][0]["lines"].as_u64()), (Some(id_of(&b)), Some(2))); assert_eq!(mine["ballots"][0]["status"], "confirmed"); let mut yvv = c.other(); yvv.login("Yvv").await; assert_eq!(yvv.call("GET", &url, None).await.0, StatusCode::NOT_FOUND); assert_eq!(yvv.ok("GET", "/api/ballots", None).await["ballots"], json!([])); let mut nobody = c.other(); assert_eq!(nobody.call("GET", &url, None).await.0, StatusCode::UNAUTHORIZED); } /// Point 8: an identity known only from a vote on the chain does not hold its name /// against a person who joins by a team link; a Ğ1 identity that signed in does. #[tokio::test] async fn a_name_known_only_from_a_chain_vote_stays_free() { let indexer = Indexer::start().await; let (mut admin, state) = Client::new(chain_config(&indexer)).await; admin.login_admin("Yvv").await; let doc = admin.doc("monnaie-libre").await; let i1 = version(&doc, "I1", "inForce"); for (block, signer) in [(100, alice()), (101, Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) })] { let b = admin.ballot(&doc, &[(&i1, Some("for"))], None).await; indexer.pay(block, comment_of(&b), signer); } ballots::sweep(&state).await.expect("sweep"); // Charlie has since signed in with his Ğ1 identity. sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = 'idty:9003'") .bind(ld_server::util::now()) .execute(&state.db) .await .expect("seen"); let link = json!({ "label": "Équipe", "groups": ["essai"], "days": 30, "maxUses": 5 }); let v = admin.ok("POST", "/api/access/team-links", Some(link)).await; let token = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned(); let mut charlie = admin.other(); let (s, v) = charlie.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "charlie" }))).await; assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken"))); let mut alice = admin.other(); let (s, v) = alice.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Alice" }))).await; assert_eq!(s, StatusCode::OK, "{v}"); } #[tokio::test] async fn fifty_voices_on_the_chain_bring_an_order_and_a_title_into_force() { // The modules (ADR 0009) are voted like articles: in a basket, in one transaction. let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let order = doc["modules"]["orders"] .as_array() .expect("orders") .iter() .find(|o| o["section"] == "conclusion") .expect("conclusion") .clone(); let (k1, k2) = (unit(&doc, "K1")["id"].as_str().expect("K1"), unit(&doc, "K2")["id"].as_str().expect("K2")); assert_eq!(order["members"], json!([k1, k2])); let o = c .ok( "POST", &format!("/api/units/{}/order", order["id"].as_str().expect("id")), Some(json!({ "order": [k2, k1] })), ) .await["id"] .as_str() .expect("order") .to_owned(); let title_unit = doc["modules"]["title"]["id"].as_str().expect("title").to_owned(); let t = c .ok( "POST", &format!("/api/units/{title_unit}/title"), Some(json!({ "title": "Acte d'engagement de certification" })), ) .await["id"] .as_str() .expect("title") .to_owned(); let doc = c.doc("monnaie-libre").await; let keys: Vec = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect(); let mut last = Value::Null; for (n, account) in keys.iter().enumerate() { let b = c.ballot(&doc, &[(&o, Some("for")), (&t, Some("for"))], None).await; let index = 20_000 + u32::try_from(n).expect("n"); indexer.pay( 2_000 + index, b["comment"].as_str().expect("c"), Signer { account, identity: Some((index, "Membre", true)) }, ); last = b; if n % 10 == 9 { ballots::sweep(&state).await.expect("sweep"); } } ballots::sweep(&state).await.expect("sweep"); let s = c.status(&last).await; assert_eq!(s["status"], "confirmed"); let mut entered: Vec<(&str, &str)> = s["entered"] .as_array() .expect("entered") .iter() .map(|e| (e["code"].as_str().unwrap_or_default(), e["kind"].as_str().unwrap_or_default())) .collect(); entered.sort_unstable(); assert_eq!(entered, [("ordre.conclusion", "order"), ("titre", "title")], "{s}"); let d = c.doc("monnaie-libre").await; let (p1, p2) = (unit(&d, "K1")["position"].as_f64().expect("p"), unit(&d, "K2")["position"].as_f64().expect("p")); assert!(p2 < p1, "K2 now reads before K1"); assert_eq!(d["doc"]["title"]["fr"], "Acte d'engagement de certification"); assert_eq!(d["modules"]["title"]["inForce"]["translations"]["en"]["stale"], true, "the English title, to review"); // The adoptions keep what entered, by kind. let kinds: Vec = sqlx::query_scalar("SELECT kind FROM adoptions ORDER BY kind").fetch_all(&state.db).await.expect("k"); assert_eq!(kinds, ["order", "title"]); } // --------------------------------------------------------------------------- // Without the chain, and in managed groups // --------------------------------------------------------------------------- #[tokio::test] async fn without_a_vote_account_the_chain_vote_is_off_and_the_test_group_still_votes() { let (mut c, _) = Client::new(Config::for_tests()).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; assert_eq!(doc["vote"]["way"], "chain"); assert_eq!(doc["vote"]["open"], false); assert_eq!(doc["vote"]["reason"], "chain_off"); assert!(doc["vote"]["chain"].is_null()); assert_eq!(doc["canVote"], false); let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); let i1 = version(&doc, "I1", "inForce"); let (s, v) = c .call( "POST", &format!("/api/docs/{doc_id}/ballots"), Some(json!({ "lines": [{ "proposalId": i1, "choice": "for" }] })), ) .await; assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_vote_off"))); let essai = c.doc("essai").await; assert_eq!((essai["vote"]["way"].as_str(), essai["vote"]["open"].as_bool()), (Some("server"), Some(true))); let p = version(&essai, "I1", "inForce"); c.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; } #[tokio::test] async fn in_monnaie_libre_nobody_votes_on_the_server() { let indexer = Indexer::start().await; let (mut c, _) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); let p = version(&doc, "E3", "0"); for choice in [json!("for"), Value::Null] { let (s, v) = c.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": choice }))).await; assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_only"))); } let (s, v) = c .call( "POST", &format!("/api/docs/{doc_id}/votes"), Some(json!({ "lines": [{ "proposalId": p, "choice": "for" }] })), ) .await; assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_only"))); // And no ballot in a group that votes on the server. let essai = c.doc("essai").await; let id = essai["doc"]["id"].as_str().expect("id").to_owned(); let q = version(&essai, "E3", "0"); let (s, v) = c .call( "POST", &format!("/api/docs/{id}/ballots"), Some(json!({ "lines": [{ "proposalId": q, "choice": "for" }] })), ) .await; assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("server_vote"))); } #[tokio::test] async fn what_a_ballot_may_not_hold() { let indexer = Indexer::start().await; let (mut c, _) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); let i1 = version(&doc, "I1", "inForce"); let essai = c.doc("essai").await; let elsewhere = version(&essai, "I1", "inForce"); for (lines, author, code) in [ (json!([]), Value::Null, "lines"), ( json!([{ "proposalId": i1, "choice": "for" }, { "proposalId": i1, "choice": "against" }]), Value::Null, "lines", ), (json!([{ "proposalId": elsewhere, "choice": "for" }]), Value::Null, "lines"), (json!([{ "proposalId": i1, "choice": "maybe" }]), Value::Null, "choice"), (json!([{ "proposalId": i1, "choice": "for" }]), json!("g1NotAnAddress"), "author"), ] { let (s, v) = c .call("POST", &format!("/api/docs/{doc_id}/ballots"), Some(json!({ "lines": lines, "author": author }))) .await; assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)), "{lines}"); } // A session holds a bounded number of pending ballots. for _ in 0..20 { c.ballot(&doc, &[(&i1, Some("for"))], None).await; } let (s, v) = c .call( "POST", &format!("/api/docs/{doc_id}/ballots"), Some(json!({ "lines": [{ "proposalId": i1, "choice": "for" }] })), ) .await; assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("too_many_ballots"))); } #[tokio::test] async fn a_grouped_vote_in_a_managed_group_is_one_write() { let (mut a, state) = Client::new(Config::for_tests()).await; a.login("Yvv").await; let mut b = a.other(); b.login("Chiara").await; let doc = a.doc("essai").await; let id = doc["doc"]["id"].as_str().expect("id").to_owned(); let (e4, e5, e6) = (version(&doc, "E4", "0"), version(&doc, "E5", "0"), version(&doc, "E6", "0")); let lines = json!([ { "proposalId": e4, "choice": "for" }, { "proposalId": e5, "choice": "against" }, { "proposalId": e6, "choice": "for" }, ]); let v = a.ok("POST", &format!("/api/docs/{id}/votes"), Some(json!({ "lines": lines }))).await; assert_eq!(v["count"], 3); assert!(v["entered"].as_array().expect("e").is_empty(), "one voice never makes a text pass"); // All or nothing: one line that does not hold, and no line is written. let (s, _) = b .call( "POST", &format!("/api/docs/{id}/votes"), Some( json!({ "lines": [{ "proposalId": e4, "choice": "for" }, { "proposalId": "nope", "choice": "for" }] }), ), ) .await; assert_eq!(s, StatusCode::BAD_REQUEST); let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes WHERE identity_id = 'dev:chiara'") .fetch_one(&state.db) .await .expect("n"); assert_eq!(n, 0); // The second voice of a group of two: E4 and E6 enter at once; withdrawing in the same gesture. let lines = json!([ { "proposalId": e4, "choice": "for" }, { "proposalId": e6, "choice": "for" }, { "proposalId": e5, "choice": null }, ]); let v = b.ok("POST", &format!("/api/docs/{id}/votes"), Some(json!({ "lines": lines }))).await; let mut codes: Vec<&str> = v["entered"].as_array().expect("e").iter().filter_map(|e| e["code"].as_str()).collect(); codes.sort_unstable(); assert_eq!(codes, ["E4", "E6"]); let doc = a.doc("essai").await; assert_eq!(tally(&doc, &e5), (0, 1)); assert_eq!(find(&doc, &e5)["myVote"], "against"); assert_eq!(find(&doc, &e5)["myVoteChain"], false); }