v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
//! 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);
|
||||
}
|
||||
|
||||
#[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").fetch_one(&state.db).await.expect("count");
|
||||
assert_eq!(n, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_rival_replaces_the_seed_and_the_seed_returns_among_proposals() {
|
||||
let (mut a, _) = Client::new().await;
|
||||
a.login("Yvv", false).await;
|
||||
let mut b = a.with(None);
|
||||
b.login("Chiara", false).await;
|
||||
let id = doc_id(&mut a, "essai").await;
|
||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let e1 = unit(&doc, "E1");
|
||||
let (uid, seed_id) =
|
||||
(e1["id"].as_str().expect("u").to_owned(), e1["inForce"]["id"].as_str().expect("v").to_owned());
|
||||
let (s, v) = a
|
||||
.call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({
|
||||
"unitId": uid, "title": "Unicité du compte",
|
||||
"body": "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie Ğ1.", "submit": true,
|
||||
})))
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let rival = v["id"].as_str().expect("id").to_owned();
|
||||
a.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
let (_, v) = b.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert_eq!(v["entered"][0]["kind"], "text");
|
||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let e1 = unit(&doc, "E1");
|
||||
assert_eq!(e1["inForce"]["id"], rival.as_str());
|
||||
assert!(
|
||||
e1["proposals"].as_array().expect("p").iter().any(|p| p["id"] == seed_id.as_str() && p["status"] == "open")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editing_is_not_voting() {
|
||||
let (mut a, _) = Client::new().await;
|
||||
a.login("Yvv", false).await;
|
||||
let mut b = a.with(None);
|
||||
b.login("Chiara", false).await;
|
||||
let id = doc_id(&mut a, "monnaie-libre").await;
|
||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let i1 = unit(&doc, "I1")["inForce"].clone();
|
||||
let pid = i1["id"].as_str().expect("id").to_owned();
|
||||
// Anyone may fix the glued word of the preamble in force, without a vote.
|
||||
let fixed = i1["body"].as_str().expect("body").replace("latransmission", "la transmission");
|
||||
let (s, v) =
|
||||
b.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": i1["title"], "body": fixed }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["edit"], "typo");
|
||||
// A change of meaning in force is refused: it goes through a proposal.
|
||||
let (s, v) = b
|
||||
.call(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{pid}"),
|
||||
Some(json!({ "title": i1["title"], "body": "Tout autre chose." })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::CONFLICT);
|
||||
assert_eq!(v["error"], "substantive_in_force");
|
||||
|
||||
// A member proposal: typo keeps votes, substance asks to reconfirm.
|
||||
let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
|
||||
let (_, v) = a
|
||||
.call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({
|
||||
"unitId": uid, "title": "Réciprocité", "body": "Je m'engage à ne certifier que des persones qui respectent ces engagements.", "submit": true,
|
||||
})))
|
||||
.await;
|
||||
let p = v["id"].as_str().expect("id").to_owned();
|
||||
b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
// A misspelt word fixed: the vote stays.
|
||||
let (_, v) = a
|
||||
.call("PATCH", &format!("/api/proposals/{p}"), Some(json!({
|
||||
"title": "Réciprocité", "body": "Je m'engage à ne certifier que des personnes qui respectent ces engagements.",
|
||||
})))
|
||||
.await;
|
||||
assert_eq!(v["edit"], "typo");
|
||||
let (_, d) = b.call("GET", &format!("/api/proposals/{p}"), None).await;
|
||||
assert_eq!(d["proposal"]["tally"]["for"], 1, "a typo keeps the votes");
|
||||
// Words added: a new revision, the vote waits for reconfirmation.
|
||||
let (_, v) = a
|
||||
.call("PATCH", &format!("/api/proposals/{p}"), Some(json!({
|
||||
"title": "Réciprocité", "body": "Je m'engage à ne certifier que des personnes qui respectent ces deux engagements fondamentaux.",
|
||||
})))
|
||||
.await;
|
||||
assert_eq!(v["edit"], "substantive");
|
||||
let (_, d) = b.call("GET", &format!("/api/proposals/{p}"), None).await;
|
||||
assert_eq!(d["proposal"]["myVoteStale"], true);
|
||||
assert_eq!(d["proposal"]["tally"]["for"], 0);
|
||||
b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
let (_, d) = b.call("GET", &format!("/api/proposals/{p}"), None).await;
|
||||
assert_eq!(d["proposal"]["myVoteStale"], false);
|
||||
assert_eq!(d["proposal"]["tally"]["for"], 1);
|
||||
assert_eq!(d["history"].as_array().expect("h").len(), 3);
|
||||
// Someone else cannot change the substance.
|
||||
let (s, _) = b
|
||||
.call(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{p}"),
|
||||
Some(json!({ "title": "Réciprocité", "body": "Autre chose entièrement." })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_contradiction_signal_blocks_entering_into_force() {
|
||||
let (mut a, _) = Client::new().await;
|
||||
a.login("Yvv", false).await;
|
||||
let mut b = a.with(None);
|
||||
b.login("Chiara", false).await;
|
||||
let id = doc_id(&mut a, "essai").await;
|
||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
|
||||
let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned();
|
||||
let (s, v) = b
|
||||
.call(
|
||||
"POST",
|
||||
&format!("/api/proposals/{k1}/signals"),
|
||||
Some(json!({ "targetId": e2, "reason": "Ces règles contredisent la réciprocité en vigueur." })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let sid = v["id"].as_str().expect("sid").to_owned();
|
||||
a.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
let (_, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert!(v["entered"].as_array().expect("e").is_empty(), "blocked while the signal stands");
|
||||
// Only its author lifts it; then the pass lets the text in.
|
||||
let (s, _) = a.call("POST", &format!("/api/signals/{sid}/lift"), None).await;
|
||||
assert_eq!(s, StatusCode::FORBIDDEN);
|
||||
let (_, v) = b.call("POST", &format!("/api/signals/{sid}/lift"), None).await;
|
||||
assert_eq!(v["entered"][0]["code"], "K1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inertia_change_is_voted_one_level_up() {
|
||||
let (mut a, _) = Client::new().await;
|
||||
a.login("Yvv", false).await;
|
||||
let id = doc_id(&mut a, "monnaie-libre").await;
|
||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let uid = unit(&doc, "E1")["id"].as_str().expect("u").to_owned();
|
||||
let (s, v) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "high" }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let p = v["id"].as_str().expect("id").to_owned();
|
||||
let (_, again) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "high" }))).await;
|
||||
assert_eq!(again["id"], p.as_str());
|
||||
let (_, d) = a.call("GET", &format!("/api/proposals/{p}"), None).await;
|
||||
assert_eq!(d["proposal"]["votedAtInertia"], "high");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn previews_and_assistant() {
|
||||
let (mut a, _) = Client::new().await;
|
||||
a.login("Yvv", false).await;
|
||||
let id = doc_id(&mut a, "monnaie-libre").await;
|
||||
let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await;
|
||||
assert_eq!(v["units"].as_array().expect("u").len(), 33);
|
||||
let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=wellrated"), None).await;
|
||||
assert_eq!(v["units"].as_array().expect("u").len(), 3, "nothing is well rated at zero votes");
|
||||
let (_, v) = a
|
||||
.call("POST", &format!("/api/docs/{id}/assist"), Some(json!({
|
||||
"text": "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie G1.\n\nJe m'engage à rester joignable par mes certifieurs.",
|
||||
})))
|
||||
.await;
|
||||
let kinds: Vec<&str> = v["hints"].as_array().expect("h").iter().filter_map(|h| h["kind"].as_str()).collect();
|
||||
assert!(kinds.contains(&"split"), "{v}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn split_a_draft() {
|
||||
let (mut a, _) = Client::new().await;
|
||||
a.login("Yvv", false).await;
|
||||
let id = doc_id(&mut a, "essai").await;
|
||||
let (_, v) = a
|
||||
.call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({ "title": "Deux choses", "body": "a\n\nb" })))
|
||||
.await;
|
||||
let p = v["id"].as_str().expect("id").to_owned();
|
||||
let (s, v) =
|
||||
a.call("POST", &format!("/api/proposals/{p}/split"), Some(json!({ "parts": ["Première.", "Seconde."] }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["drafts"].as_array().expect("d").len(), 2);
|
||||
let (s, _) = a.call("POST", &format!("/api/proposals/{p}/submit"), None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn access_management_is_for_administrators_only() {
|
||||
let (mut user, _) = Client::new().await;
|
||||
user.login("Chiara", false).await;
|
||||
let (s, _) = user.call("GET", "/api/access", None).await;
|
||||
assert_eq!(s, StatusCode::FORBIDDEN);
|
||||
let mut admin = user.with(None);
|
||||
admin.login("Yvv", true).await;
|
||||
let (s, v) =
|
||||
admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Chiara", "groups": ["essai"] }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let link = v["link"].as_str().expect("link").to_owned();
|
||||
let token = link.split('#').nth(1).expect("token").to_owned();
|
||||
let mut invited = user.with(None);
|
||||
let (s, v) = invited.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["me"]["kind"], "invite");
|
||||
let (_, g) = invited.call("GET", "/api/groups", None).await;
|
||||
let open: Vec<&str> = g["groups"]
|
||||
.as_array()
|
||||
.expect("g")
|
||||
.iter()
|
||||
.filter(|g| g["open"] == true)
|
||||
.filter_map(|g| g["slug"].as_str())
|
||||
.collect();
|
||||
assert_eq!(open, ["essai"]);
|
||||
// Single use.
|
||||
let mut again = user.with(None);
|
||||
let (s, _) = again.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
// Monnaie libre is seen but closed to an invited identity.
|
||||
let (s, _) = invited.call("GET", "/api/groups/axiom", None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let (_, ov) = admin.call("GET", "/api/access", None).await;
|
||||
assert!(ov["identities"].as_array().expect("i").iter().any(|i| i["displayName"] == "Chiara"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 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.");
|
||||
}
|
||||
Reference in New Issue
Block a user