Files
decision/crates/ld-server/tests/api.rs
T
YvvandClaude Opus 5.5 753b52202b v3 : neuvième relecture — noms uniques, places justes, quantités
- Noms des liens d'équipe : comparés sans casse, accents ni ponctuation
  (ld_engine::names), lettres latines seulement (pas de sosie cyrillique,
  grec ou pleine chasse), une ligne ; index unique (migration additive
  0003) contre deux ouvertures simultanées sous le même nom.
- Une personne déjà connectée ne prend une place que si le lien lui
  ajoute un groupe ; « Ce n'est pas moi » la déconnecte sur un navigateur
  partagé ; un lien collé dans le même onglet est relu.
- Coller ou couper un mot de quantité n'est pas une coquille
  (« sur tout » → « surtout », « par tout » → « partout »).
- Libellés sans caractère de contrôle ; la soumission revérifie le titre.

Boucle : 68 tests Rust, svelte-check, 9 vitest, e2e 13/13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:40:04 +02:00

666 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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")));
// A title is one line.
let (s, v) = b
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(json!({ "title": "Récipro\ncité", "body": body })))
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("title")));
// 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 a_team_link_makes_one_identity_per_opening() {
let (mut admin, _) = Client::new().await;
admin.login("Yvv", true).await;
let mut user = admin.with(None);
user.login("Chiara", false).await;
let team = |groups: Value, days: i64, max: i64| json!({ "label": "Équipe dev", "groups": groups, "days": days, "maxUses": max });
let (s, _) = user.call("POST", "/api/access/team-links", Some(team(json!(["essai"]), 30, 2))).await;
assert_eq!(s, StatusCode::FORBIDDEN, "administrators only");
for (body, code) in [
(team(json!([]), 30, 2), "groups"),
(team(json!(["nope"]), 30, 2), "groups"),
(team(json!(["essai"]), 0, 2), "team_limits"),
(team(json!(["essai"]), 30, 501), "team_limits"),
] {
let (s, v) = admin.call("POST", "/api/access/team-links", Some(body)).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)));
}
let (s, v) = admin.call("POST", "/api/access/team-links", Some(team(json!(["essai"]), 30, 2))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let link = v["link"].as_str().expect("link").to_owned();
assert!(link.contains("/equipe#"), "{link}");
let token = link.split('#').nth(1).expect("token").to_owned();
let mut first = admin.with(None);
let (s, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["label"], "Équipe dev");
assert_eq!(v["groups"].as_array().expect("g").len(), 1);
assert_eq!(v["signedInAs"], Value::Null);
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Lu\u{202e}na" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("name")));
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": " Luna " }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!((v["me"]["kind"].as_str(), v["me"]["admin"].as_bool()), (Some("invite"), Some(false)));
assert_eq!(v["me"]["displayName"], "Luna");
let luna = v["me"]["id"].as_str().expect("id").to_owned();
let (_, g) = first.call("GET", "/api/groups", None).await;
assert!(g["groups"].as_array().expect("g").iter().any(|g| g["slug"] == "essai" && g["open"] == true));
// Nobody joins under a name already there; the refusal does not count as an opening.
let mut second = admin.with(None);
for taken in ["luna", "LÚNA", "Lu-na", "Yvv"] {
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": taken }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")), "{taken}");
}
// Look-alike letters from other alphabets are not a name.
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Lunа" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("name")));
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Paul" }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_ne!(v["me"]["id"].as_str(), Some(luna.as_str()), "one identity per opening");
// Two openings allowed: the third is refused.
let mut third = admin.with(None);
let (s, v) = third.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Zoé" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("invitation_invalid")));
// Someone signed in keeps their identity and receives the groups.
let (_, v) = admin.call("POST", "/api/access/team-links", Some(team(json!(["monnaie-libre"]), 7, 5))).await;
let token2 = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
let (_, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token2 }))).await;
assert_eq!(v["signedInAs"], "Luna");
for _ in 0..3 {
// Back again with the session open: no place taken once the groups are there.
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token2 }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["me"]["id"].as_str(), Some(luna.as_str()));
}
let (s, _) = first.call("GET", "/api/groups/monnaie-libre", None).await;
assert_eq!(s, StatusCode::OK);
// Administrators see the links still open and where each identity came from; a link revokes.
let (_, ov) = admin.call("GET", "/api/access", None).await;
let links = ov["teamLinks"].as_array().expect("links");
assert_eq!(links.len(), 2, "{ov}");
assert!(
ov["identities"]
.as_array()
.expect("i")
.iter()
.any(|i| i["displayName"] == "Paul" && i["teamLink"] == "Équipe dev")
);
let open = links.iter().find(|l| l["uses"] == 1).expect("second link");
let (s, _) =
admin.call("DELETE", &format!("/api/access/team-links/{}", open["id"].as_str().expect("id")), None).await;
assert_eq!(s, StatusCode::OK);
let mut late = admin.with(None);
let (s, _) = late.call("POST", "/api/auth/team", Some(json!({ "token": token2, "name": "Tardif" }))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
#[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);
}