//! The appview knows an organization's members from its index: a member's //! push is credited to them, their merge lands a pull, and `getOrg` and //! `getProfile` name them, with the invitations and hidden members only to //! those inside. use super::*; /// An organization, a member in public, one invited (and on spaces, a /// hidden member), and an outsider, as the pages and their queries see /// them. #[test] fn the_appview_counts_organization_members() { // Started first, so its index sees the records made after. let server = gateway_with(None, true); let mut all: [Sandbox; 4] = people(); // Organizations, and pulls on public repositories, are read from the // appview. for p in &mut all { p.set("KNOTLESS_APPVIEW", &server.base); } let [org, member, hidden, outsider] = all; let ok = |s: &Sandbox, args: &[&str]| { let (code, out) = s.cli(args, b""); assert_eq!(code, 0, "{args:?}: {out}"); out }; let o = org.account.handle.as_str(); ok(&org, &["account", "delegate", o, &server.did]); ok(&org, &["org", "add", &member.account.handle]); ok(&org, &["org", "add", &hidden.account.handle]); let url = org.url("shared"); // Made once an organization, it names the gateway members push through. ok(&org, &["repo", "create", &url]); let work = org.work("w", 1); org.git(&work, &["push", "-q", &url, "main"]); org.git(&work, &["checkout", "-q", "-b", "x"]); org.commit_file(&work, "x", "x\n"); org.git(&work, &["push", "-q", &url, "x"]); let pull = ok(&org, &["pull", "create", &url, "main", &url, "x", "X"]); ok(&member, &["org", "join", o, "--public"]); // On spaces, a hidden member; else an invitation in public. if net().spaces { ok(&hidden, &["org", "join", o, "--hidden"]); } let xrpc = |nsid: &str, query: &str| format!("{}/xrpc/dev.knotless.{nsid}?{query}", server.base); let ask = |nsid: &str, query: &str, cookie: Option<&str>| { browse("GET", &xrpc(nsid, query), cookie, None, None) }; let actor = |s: &Sandbox| json!({"did": s.account.did, "handle": s.account.handle}); let get_org = |cookie: Option<&str>| ask("getOrg", &format!("actor={o}"), cookie).2; let profile = |s: &Sandbox, cookie: Option<&str>| { ask("getProfile", &format!("actor={}", s.account.did), cookie).2 }; // The member's push is theirs, as a collaborator's is. let theirs = member.work("theirs", 1); member.git(&theirs, &["push", "-q", &url, "main:refs/heads/theirs"]); let activity = format!("actor={}", member.account.did); wait_until(|| { let days = ask("getActorActivity", &activity, None).2["days"].clone(); let days = days.as_array().cloned().unwrap_or_default(); days.iter().any(|d| d["pushes"].as_u64() > Some(0)) }); // Their merge lands the pull, as the index reads the repository. let repo = format!("at://{}/dev.knotless.repo/shared", org.account.did); let thread = format!("repo={repo}&uri={pull}"); wait_until(|| ask("getThread", &thread, None).2["item"]["state"] == "open"); ok(&member, &["pull", "merge", &url, &pull]); wait_until(|| ask("getThread", &thread, None).2["item"]["state"] == "merged"); // Anyone sees its members in public, and the member its organization. let public = json!([{ "actor": actor(&member), "role": "member", "standing": "public", }]); let shown = |answer: &Value| { let mut members = answer["members"].clone(); for m in members.as_array_mut().into_iter().flatten() { let m = m.as_object_mut().unwrap(); assert!(m.remove("invitedAt").is_some(), "{m:?}"); // Each invitation says when it lapses. assert!(m.remove("expiresAt").is_some(), "{m:?}"); m.remove("joinedAt"); } members }; wait_until(|| shown(&get_org(None)) == public); let outside = session_cookie(&server, &outsider.account); let seen = get_org(Some(&outside)); assert_eq!( (&seen["org"], &seen["organization"], &seen["inside"]), (&actor(&org), &json!(true), &json!(false)) ); assert_eq!(shown(&seen), public); let of = json!([{"actor": actor(&org)}]); assert_eq!(profile(&member, None)["orgs"], of); assert_eq!(profile(&outsider, None).get("orgs"), None); // Its profile says it is one, so the pages show its members. assert_eq!(profile(&org, None)["organization"], true); assert_eq!(profile(&member, None).get("organization"), None); // Inside, its invitations and hidden members too. let standing = if net().spaces { "hidden" } else { "invited" }; let all = json!([ {"actor": actor(&member), "role": "member", "standing": "public"}, {"actor": actor(&hidden), "role": "member", "standing": standing}, ]); let mine = session_cookie(&server, &org.account); let as_org = get_org(Some(&mine)); assert_eq!(as_org["inside"], true, "{as_org}"); let mut listed = shown(&as_org); let by_did = |v: &Value| v["actor"]["did"].as_str().unwrap_or_default().to_string(); listed.as_array_mut().unwrap().sort_by_key(by_did); let mut want = all.clone(); want.as_array_mut().unwrap().sort_by_key(by_did); assert_eq!(listed, want); if net().spaces { // A hidden member sees it all, and their membership on their own // profile, which no one else sees. let theirs = session_cookie(&server, &hidden.account); assert_eq!(get_org(Some(&theirs))["inside"], true); assert_eq!(profile(&hidden, Some(&theirs))["orgs"], of); assert_eq!(profile(&hidden, None).get("orgs"), None); } // An account that is none, and one there is not. let none = ask("getOrg", &format!("actor={}", outsider.account.did), None).2; assert_eq!( (&none["organization"], &none["members"]), (&json!(false), &json!([])) ); let (code, _, said) = ask("getOrg", "actor=nobody.invalid", None); assert_eq!((code, &said["error"]), (400, &json!("ProfileNotFound"))); }