- Mots collés ou coupés : deux au plus, jamais sur une négation (« Si non » → « Sinon », « ne certifier » → « necertifier » sont du fond). - Titre et corps jugés chacun pour soi, puis ensemble : le titre n'absorbe plus le début du corps. - Caractères invisibles refusés aussi au découpage d'un brouillon et à la soumission. - Tabulation et retour chariot ne sont plus des espaces libres ; les lettres latines rares (ǃ, Ʒ…) font partie de la forme. - Le diff renvoyé après une coquille couvre le titre. Boucle : 62 tests Rust, svelte-check, 9 vitest, e2e 12/12. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
570 lines
26 KiB
Rust
570 lines
26 KiB
Rust
//! 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<String>,
|
|
}
|
|
|
|
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<String>) -> Self {
|
|
Self { router: self.router.clone(), cookie }
|
|
}
|
|
|
|
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (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");
|
|
// Flipping a relation in a seed text is not a typo, even with the same letters.
|
|
let k1 = unit(&doc, "K1")["proposals"][0].clone();
|
|
let flipped = k1["body"].as_str().expect("body").replace(">= 5 certifications", "<= 5 certifications");
|
|
let (s, v) = b
|
|
.call(
|
|
"PATCH",
|
|
&format!("/api/proposals/{}", k1["id"].as_str().expect("id")),
|
|
Some(json!({ "title": k1["title"], "body": flipped })),
|
|
)
|
|
.await;
|
|
assert_eq!(s, StatusCode::CONFLICT, "{v}");
|
|
assert_eq!(v["error"], "substantive_not_author");
|
|
// One word turned around in a text in force, and an invisible bidi override: refused.
|
|
let e2 = unit(&doc, "E2")["inForce"].clone();
|
|
let e2_id = e2["id"].as_str().expect("id").to_owned();
|
|
let body = e2["body"].as_str().expect("body").to_owned();
|
|
assert!(body.contains("ces deux présents"));
|
|
let (s, v) = b
|
|
.call(
|
|
"PATCH",
|
|
&format!("/api/proposals/{e2_id}"),
|
|
Some(json!({ "title": e2["title"], "body": body.replacen("deux", "dix", 1) })),
|
|
)
|
|
.await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")));
|
|
let (s, v) = b
|
|
.call(
|
|
"PATCH",
|
|
&format!("/api/proposals/{e2_id}"),
|
|
Some(json!({ "title": e2["title"], "body": format!("\u{202e}{body}") })),
|
|
)
|
|
.await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters")));
|
|
// The title never absorbs the start of the body.
|
|
let title = e2["title"].as_str().expect("title");
|
|
let (head, rest) = body.split_once(' ').expect("two words");
|
|
let (s, v) = b
|
|
.call(
|
|
"PATCH",
|
|
&format!("/api/proposals/{e2_id}"),
|
|
Some(json!({ "title": format!("{title} {head}"), "body": rest })),
|
|
)
|
|
.await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")), "{v}");
|
|
// Typo fixes are measured against the voted text: slips never add up.
|
|
let patch = |body: String| json!({ "title": e2["title"], "body": body });
|
|
let (_, v) = b
|
|
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "repectent", 1))))
|
|
.await;
|
|
assert_eq!(v["edit"], "typo", "one slip is a typo");
|
|
let (s, v) = b
|
|
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "reectent", 1))))
|
|
.await;
|
|
assert_eq!(
|
|
(s, v["error"].as_str()),
|
|
(StatusCode::CONFLICT, Some("substantive_in_force")),
|
|
"a second slip on top is not"
|
|
);
|
|
let (_, v) = b.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.clone()))).await;
|
|
assert_eq!(v["edit"], "typo", "back to the voted text");
|
|
// An address is compared exactly.
|
|
let x11 = unit(&doc, "X1.1")["proposals"][0].clone();
|
|
let x11_body = x11["body"].as_str().expect("body");
|
|
assert!(x11_body.contains("git.duniter.org"));
|
|
let (s, v) = b
|
|
.call(
|
|
"PATCH",
|
|
&format!("/api/proposals/{}", x11["id"].as_str().expect("id")),
|
|
Some(json!({ "title": x11["title"], "body": x11_body.replacen("git.duniter.org", "git.dunniter.org", 1) })),
|
|
)
|
|
.await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_not_author")));
|
|
|
|
// 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.", "Au \u{202e}01\u{202c}."] })),
|
|
)
|
|
.await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters")));
|
|
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);
|
|
}
|