v3 : liens d'équipe, publication sur git.open.us.org, huitième relecture

Liens d'équipe (Yvv, 2026-10-21) : un lien réutilisable jusqu'à une date
et pour un nombre de personnes, révocable. Chaque ouverture demande un nom
et crée sa propre identité d'invitation, jamais administratrice ; un nom
déjà pris est refusé ; une personne déjà connectée reçoit les groupes.
trustinWallet montre par quel lien chaque identité est venue, et donne un
nouveau lien de connexion à une personne invitée (autre appareil).
Migration additive 0002 (table team_links, colonne identities.team_link).

Publication : yvv/libredecision-textes sur git.open.us.org, pour
l'instant (un dépôt pour l'ouverture, puis le dépôt officiel, viendront).

Huitième relecture (c3d6bf8 : ok), points corrigés :
- une négation n'est jamais coupée ni formée par collage (« ja mais ») ;
- caractères invisibles : tout l'ensemble Unicode des ignorables et les
  contrôles autres que saut de ligne et tabulation ;
- sauts de ligne et indentation font partie de la forme (listes) ;
- un titre tient sur une ligne.

Boucle : 65 tests Rust, svelte-check, 9 vitest, e2e 13/13 (C12 ajouté).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 08:12:21 +02:00
co-authored by Claude Opus 5.5
parent c3d6bf88b9
commit fd19dbf4ec
29 changed files with 938 additions and 85 deletions
+88
View File
@@ -251,6 +251,11 @@ async fn editing_is_not_voting() {
)
.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");
@@ -460,6 +465,89 @@ async fn access_management_is_for_administrators_only() {
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);
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "luna" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")));
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");
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;