//! Organizations: any account that invites members, who push to every //! repository it owns once they join, through the gateway it delegated to. use super::*; /// Pushes a new branch of `member`'s own to `url`, as git would: whether /// it landed. fn pushes(member: &Sandbox, url: &str) -> bool { static PUSHES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); let n = PUSHES.fetch_add(1, Ordering::Relaxed); let work = member.work(&format!("w{n}"), 1); let refspec = format!("main:refs/heads/theirs{n}"); let (ok, _, _) = member.git_raw(&work, &["push", "-q", url, &refspec]); ok } /// A gateway that is the appview too, and `N` people who read who is in /// an organization from it. fn served() -> (Server, [Sandbox; N]) { let gw = gateway_with(None, true); let mut all = people(); for p in &mut all { p.set("KNOTLESS_APPVIEW", &gw.base); } (gw, all) } /// Has `gw` index the organization `org` is again, as it does once it /// changes it, after what was written to its records here, not through it. fn reindexed(gw: &Server, org: &Sandbox) { reindexed_of(gw, org, &org.account.did); } /// [`reindexed`], with its record of `member` read again too. fn reindexed_of(gw: &Server, org: &Sandbox, member: &str) { gw._rt .block_on(gw.app.reindex_org(&org.account.did, member)); } /// Runs `args` as `s`, which must pass: what it said. fn ok(s: &Sandbox, args: &[&str]) -> String { let (code, out) = s.cli(args, b""); assert_eq!(code, 0, "{args:?}: {out}"); out } /// Runs `args` as `s`, which must fail: what it said. fn refused(s: &Sandbox, args: &[&str]) -> String { let (passed, _, err) = s.knotless(args, b""); assert!(!passed, "{args:?} passed"); err.trim().to_string() } /// What `org add` says of an invitation, where its PDS keeps it. fn invited(who: &str, org: &str, again: &str, role: &str) -> String { let kept = match net().spaces { true => format!("The invitation is private: only {org} and its members see it."), false => "Its PDS has no spaces, so the invitation is in its public records: knotless shows it nowhere until they accept, but anyone reading those records can see it.".into(), }; format!( "Invited {who} to {org}{again} as {role}. They're a member once they accept (`knotless org join {org}`), within 7 days.\n{kept}" ) } /// Invite, then join: a member pushes to each of the organization's /// repositories from joining until leaving or being taken out, and anyone /// sees who is in it, in public. Each command says what it did. #[test] fn organizations_invite_and_members_join() { let (gw, [org, member, third, stranger]) = served(); let (o, m, t, s) = ( org.account.handle.as_str(), member.account.handle.as_str(), third.account.handle.as_str(), stranger.account.handle.as_str(), ); let before = org.url("before"); let work = org.work("w", 1); org.git(&work, &["push", "-q", &before, "main"]); // Not an organization yet, which the account is told how to become. let not = format!("{o} is not an organization."); let how = "`knotless org add ` invites someone, and makes it one"; assert_eq!( ok(&org, &["org", "members"]), format!("{o} is not an organization: {how}.") ); assert_eq!(ok(&member, &["org", "members", o]), not); assert_eq!(ok(&org, &["org", "remove", m]), not); assert_eq!(ok(&org, &["org", "delete"]), not); assert!(refused(&member, &["org", "join", o, "--public"]).ends_with(¬[..not.len() - 1])); // Members act through a gateway, which takes delegating to first. let first = refused(&org, &["org", "add", m]); assert!( first.contains(&format!("`knotless account delegate {o}`")), "{first}" ); ok(&org, &["account", "delegate", o, &gw.did]); let new = format!("{o} is now an organization."); assert_eq!( ok(&org, &["org", "add", m]), format!("{new}\n{}", invited(m, o, "", "a member")) ); assert!( refused(&org, &["org", "add", o]) .ends_with("an organization is not one of its own members") ); assert!(refused(&org, &["org", "add", m, "--bogus"]).ends_with("unexpected --bogus")); assert!(refused(&org, &["org", "add"]).contains("missing ")); assert!(refused(&org, &["org", "bogus"]).ends_with("unknown org command bogus")); // Invited is not in: they push to nothing, and only the organization // sees the invitation. assert_eq!( ok(&org, &["org", "members"]), format!("{m}\tmember\tinvited") ); assert_eq!(ok(&stranger, &["org", "members", o]), ""); assert!(!pushes(&member, &before)); // Joining says who sees it, which is theirs to choose. let choose = refused(&member, &["org", "join", o]); assert!(choose.contains("--public anyone can see"), "{choose}"); let nobody = refused(&stranger, &["org", "join", o, "--public"]); assert!( nobody.contains(&format!("{o} has not invited you")), "{nobody}" ); assert!(stranger.records("dev.knotless.orgAcceptance").is_empty()); let public = format!( "You're a member of {o}, as a member, and anyone can see it.\nYou may push to each of its repositories, and merge, close and label on them.\n`knotless org join {o} --hidden` hides it; `knotless org leave {o}` leaves." ); assert_eq!(ok(&member, &["org", "join", o, "--public"]), public); assert_eq!(ok(&member, &["org", "join", o, "--public"]), public); for who in [&org, &stranger] { assert_eq!( ok(who, &["org", "members", o]), format!("{m}\tmember\tpublic") ); } assert_eq!(ok(&member, &["org", "list"]), o); assert_eq!(ok(&stranger, &["org", "list", m]), o); assert_eq!(ok(&stranger, &["org", "list"]), ""); // They push to each repository, made before they joined or after. let after = org.url("after"); let made = format!("created {after}\n1 member of your organization may push to it."); assert_eq!(ok(&org, &["repo", "create", &after]), made); for url in [&before, &after] { assert!(pushes(&member, url), "{url}"); let members = ok(&org, &["repo", "members", url]); assert!( members.ends_with(&format!("{}\tmember", member.account.did)), "{members}" ); } // An admin changes the organization from their own login. let admin = format!("{m} is now an admin of {o}."); assert_eq!(ok(&org, &["org", "add", m, "--admin"]), admin); let already = format!("{m} is already an admin of {o}."); assert_eq!(ok(&org, &["org", "add", m, "--admin"]), already); assert_eq!( ok(&member, &["org", "add", t, "--org", o]), invited(t, o, "", "a member") ); assert_eq!( ok(&member, &["org", "add", t, "--org", o]), invited(t, o, " again", "a member") ); let withdrew = format!("Withdrew {t}'s invitation to {o}."); assert_eq!(ok(&member, &["org", "remove", t, "--org", o]), withdrew); let not_admin = refused(&stranger, &["org", "add", t, "--org", o]); assert!(not_admin.contains("is not an admin of"), "{not_admin}"); let byadmin = format!("knotless://{}/byadmin", org.account.handle); let made = ok(&member, &["repo", "create", &byadmin, "--runner", s]); assert!(made.starts_with(&format!("created {byadmin}")), "{made}"); assert!(pushes(&member, &byadmin)); // An admin gives its runners secrets from their own login too, sealed // as the organization's, which the runner opens; no one else may. let dir = tempfile::tempdir().unwrap(); let keys = knotless::sealed::RunnerKeys::open_or_make(&dir.path().join("k.json")).unwrap(); let published = json!({ "$type": knotless::sealed::KEY_NSID, "key": keys.public().did_key(), "createdAt": "2026-11-02T00:00:00.000Z", }); stranger.put_record( knotless::sealed::KEY_NSID, knotless::sealed::KEY_RKEY, published, ); let sd = stranger.account.did.as_str(); let (code, said) = member.cli(&["secret", "set", &byadmin, "TOKEN"], b"s3cret"); assert_eq!((code, said), (0, format!("sealed TOKEN to {sd}"))); let listed = ok(&member, &["secret", "list", &byadmin]); assert!(listed.starts_with(&format!("TOKEN\t{sd}\t*")), "{listed}"); let od = org.account.did.as_str(); let key = org.record("dev.knotless.repo", "byadmin")["key"] .as_str() .unwrap() .to_string(); let r = ctx(&stranger); let its = gw._rt.block_on(knotless::session::me(&r)).unwrap(); let left = knotless::secrets::list(&r, &its, od, &key); let left = gw._rt.block_on(left).unwrap(); let bound = knotless::sealed::Bound { owner: od, repo: &key, runner: sd, name: "TOKEN", refs: &[], }; let opened = keys.open(&left[0].key, &bound, left[0].sealed.as_deref().unwrap()); assert_eq!(opened.unwrap(), b"s3cret"); let theirs = refused(&stranger, &["secret", "remove", &byadmin, "TOKEN"]); assert!(theirs.contains("is not an admin of"), "{theirs}"); ok(&member, &["secret", "remove", &byadmin, "TOKEN"]); assert_eq!(ok(&member, &["secret", "list", &byadmin]), ""); // On the pages, an admin changes its settings, written as the // organization, but neither transfers nor deletes it; anyone else // changes none of them. let at = format!("at://{od}/dev.knotless.repo/byadmin"); let xrpc = |nsid: &str| format!("{}/xrpc/dev.knotless.{nsid}", gw.base); let admin = session_cookie(&gw, &member.account); let other = session_cookie(&gw, &stranger.account); let (a, x) = (Some(admin.as_str()), Some(other.as_str())); let write = |nsid: &str, cookie, body: Value| browse("POST", &xrpc(nsid), cookie, None, Some(body)).0; let may = |cookie| { let url = format!("{}?repo={at}", xrpc("getRepo")); browse("GET", &url, cookie, None, None).2["viewer"]["admin"].clone() }; assert_eq!((may(a), may(x)), (json!(true), Value::Null)); let described = json!({"repo": at, "description": "Set by an admin"}); assert_eq!(write("updateRepo", x, described.clone()), 403); assert_eq!(write("updateRepo", a, described), 200); let record = || org.record("dev.knotless.repo", "byadmin"); assert_eq!(record()["description"], "Set by an admin"); assert_eq!( write("protectBranch", a, json!({"repo": at, "branch": "main"})), 200 ); assert_eq!(record()["rules"][0]["branch"], "main"); for nsid in ["renameRepo", "transferRepo", "deleteRepo"] { let body = json!({"repo": at, "to": s, "name": "byadmin2"}); assert_eq!(write(nsid, a, body), 403, "{nsid}"); } assert!(record().is_object()); // From the command line too, from the admin's own login: each change // is the appview's, written as the organization; a stranger's is not. let theirs = refused(&stranger, &["branch", "protect", &byadmin, "main"]); assert!(theirs.contains("only"), "{theirs}"); ok( &member, &[ "describe", &byadmin, "--description", "From the CLI", "--topic", "cli", ], ); assert_eq!(record()["description"], "From the CLI"); assert_eq!(record()["topics"][0]["name"], "cli"); ok( &member, &[ "branch", "protect", &byadmin, "main", "--queue", "--reviews", "1", ], ); assert_eq!(record()["rules"][0]["queue"], true); assert_eq!(record()["rules"][0]["reviews"]["approvals"], 1); ok(&member, &["branch", "protect", &byadmin, "main", "--undo"]); assert_eq!(record()["rules"], Value::Null); let label = [ "label", "define", &byadmin, "kind/bug", "--color", "#d73a4a", ]; ok(&member, &label); assert_eq!(record()["labels"][0]["color"], "#d73a4a"); ok(&member, &["triager", "add", &byadmin, s, "kind"]); assert_eq!(record()["triagers"][0]["did"], sd); ok(&member, &["triager", "remove", &byadmin, s]); ok(&member, &["agent", "add", &byadmin, s]); assert_eq!(record()["agents"][0]["did"], sd); ok(&member, &["agent", "remove", &byadmin, s]); assert_eq!(record()["agents"], Value::Null); ok(&member, &["label", "delete", &byadmin, "kind/bug"]); assert_eq!(record()["labels"], Value::Null); ok(&member, &["repo", "share", &byadmin, t, "--write"]); let pushers = |r: Value| r["collaborators"].as_array().map_or(0, Vec::len); assert_eq!(pushers(record()), 1); ok(&member, &["repo", "share", &byadmin, t, "--undo"]); assert_eq!(pushers(record()), 0); ok(&member, &["runner", "remove", &byadmin, s]); ok(&member, &["runner", "add", &byadmin, s]); assert_eq!(record()["runners"][0]["did"], sd); ok(&member, &["repo", "archive", &byadmin]); assert!(record()["archivedAt"].is_string()); ok(&member, &["repo", "archive", &byadmin, "--undo"]); let compacted = ok(&member, &["repo", "compact", &byadmin]); assert!(compacted.contains("packs"), "{compacted}"); // Renaming and deleting it stay the organization's own. for args in [ &["repo", "rename", &byadmin, "byadmin2"][..], &["repo", "delete", &byadmin, "--yes"], ] { let kept = refused(&member, args); assert!(kept.contains("not logged in as"), "{args:?}: {kept}"); } assert!(record().is_object()); // The gateway acts only for an organization that delegated to it. let lxm = "dev.knotless.joinOrg"; let body = json!({"org": stranger.account.did}) .to_string() .into_bytes(); let call = format!("{}/xrpc/{lxm}", gw.base); let (status, said) = post(&call, Some(&token(&member, &gw.did, lxm)), body, true); assert_eq!(status, 403, "{said}"); assert!( said.contains("has not delegated writes to this gateway"), "{said}" ); let not_theirs = refused( &stranger, &["repo", "create", &format!("knotless://{o}/theirs")], ); assert!(!not_theirs.is_empty()); let linked = format!("knotless://{o}/linked"); let plain = refused( &member, &["repo", "create", &linked, "--link", "https://example.com"], ); assert!(plain.contains("takes its own login"), "{plain}"); // An invitation not taken up within 7 days lapses: invited again, it // starts over. Taken in, an invitee has written their record first. ok(&org, &["org", "add", t]); let lxm = "dev.knotless.joinOrg"; let body = json!({"org": org.account.did}).to_string().into_bytes(); let (status, said) = post(&call, Some(&token(&third, &gw.did, lxm)), body, true); assert_eq!(status, 409, "{said}"); assert!(said.contains(&format!("you have not joined {o}")), "{said}"); let old = json!({ "$type": "dev.knotless.orgMember", "member": third.account.did, "roles": [{"role": "member"}], "invitedAt": "2020-01-01T00:00:00Z", }); let rkey = third.account.did.as_str(); match net().spaces { true => { let space = knotless::org::space_uri(&org.account.did); org.put_space_record(&space, "dev.knotless.orgMember", rkey, old); } false => { org.put_record("dev.knotless.orgMember", rkey, old); } } reindexed_of(&gw, &org, rkey); let lapsed = ok(&org, &["org", "members"]); assert!(lapsed.contains(&format!("{t}\tmember\tlapsed")), "{lapsed}"); let late = refused(&third, &["org", "join", o, "--public"]); assert!( late.contains(&format!("your invitation to {o} lapsed")), "{late}" ); assert_eq!( ok(&org, &["org", "add", t]), invited(t, o, " again", "a member") ); assert!(ok(&third, &["org", "join", o, "--public"]).starts_with("You're a member")); // Leaving ends it at once. let left = format!("You've left {o}: you can no longer push to its repositories."); assert_eq!(ok(&third, &["org", "leave", o]), left); assert!(!pushes(&third, &before)); let gone = refused(&third, &["org", "leave", o]); assert!( gone.ends_with(&format!("you are not a member of {o}")), "{gone}" ); ok(&org, &["org", "add", t]); ok(&third, &["org", "join", o, "--public"]); let out = format!("{t} is no longer a member of {o}: they no longer push to its repositories."); assert_eq!(ok(&org, &["org", "remove", t]), out); let none = format!("{s} is not a member of {o}, and has no invitation to it."); assert_eq!(ok(&org, &["org", "remove", s]), none); assert!(!pushes(&third, &before)); // An invitation is declined as a membership is left. ok(&org, &["org", "add", s]); ok(&stranger, &["org", "leave", o]); let shown = ok(&org, &["org", "members"]); assert!(!shown.contains(s), "{shown}"); // Ending it says what it takes out first. ok(&org, &["org", "add", t]); ok(&third, &["org", "join", o, "--public"]); ok(&org, &["org", "add", s]); let ask = refused(&org, &["org", "delete"]); assert!( ask.ends_with(&format!( "this takes out every member of {o} ({m} and {t}) and withdraws 1 invitation: run `knotless org delete --yes` to go ahead" )), "{ask}" ); let ended = format!("{o} is no longer an organization.\nTook out {m} and {t}."); assert_eq!(ok(&org, &["org", "delete", "--yes"]), ended); assert!(org.records("dev.knotless.org").is_empty()); assert!(org.records("dev.knotless.orgMember").is_empty()); reindexed(&gw, &org); for url in [&before, &after] { assert!(!pushes(&member, url), "{url}"); } } /// An organization whose only invitations are not taken up yet: ending it /// withdraws them. #[test] fn organizations_with_invitations_only() { let (gw, [org, member]) = served(); let (o, m) = (org.account.handle.as_str(), member.account.handle.as_str()); // Begun with no gateway to act through, it says how to name one. let none = refused(&org, &["org", "add", m]); assert!( none.contains(&format!( "acts through no gateway. Whoever holds its login delegates its writes with `knotless account delegate {o}`" )), "{none}" ); ok(&org, &["account", "delegate", o, &gw.did]); // A member no one can resolve is refused before anything changes. let put = format!("{}/xrpc/dev.knotless.putOrgMember", gw.base); let t = token(&org, &gw.did, "dev.knotless.putOrgMember"); let nobody = json!({"org": org.account.did, "member": "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa", "role": "member"}); let (status, _) = post(&put, Some(&t), nobody.to_string().into_bytes(), true); assert_eq!(status, 400); // Nor does one whose records cannot be read change the index. let did = &org.account.did; gw._rt .block_on(gw.app.reindex_org(did, "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa")); // A record of being one that names no gateway is made to name it, and // keeps when it was made. let made = "2020-01-01T00:00:00.000Z"; let bare = json!({"$type": "dev.knotless.org", "createdAt": made}); org.put_record("dev.knotless.org", "self", bare); ok(&org, &["org", "add", m]); let said = org.records("dev.knotless.org"); assert_eq!(said[0].1["gateway"], json!(gw.did), "{said:?}"); assert_eq!(said[0].1["createdAt"], made, "{said:?}"); let ask = refused(&org, &["org", "delete"]); assert!( ask.ends_with(&format!( "this takes out every member of {o} and withdraws 1 invitation: run `knotless org delete --yes` to go ahead" )), "{ask}" ); ok(&member, &["org", "join", o, "--public"]); let ended = format!("{o} is no longer an organization.\nTook out {m}."); assert_eq!(ok(&org, &["org", "delete", "--yes"]), ended); reindexed(&gw, &org); // Left where no gateway answers, the record goes all the same. let joined = json!({"$type": "dev.knotless.orgAcceptance", "subject": org.account.did, "createdAt": "2026-10-28T00:00:00Z"}); member.put_record("dev.knotless.orgAcceptance", &org.account.did, joined); let left = ok(&member, &["org", "leave", o]); assert!( left.contains(&format!("warning: {o}'s gateway did not take you out")), "{left}" ); assert!(member.records("dev.knotless.orgAcceptance").is_empty()); } /// A hidden member: only the organization's members see it. They push to /// and read its private repositories, and push to its public ones, where /// maintaining takes a public membership, as no one reading those can see /// a hidden one. #[test] fn organizations_keep_hidden_members() { let (gw, [org, member, stranger]) = served(); let (o, m) = (org.account.handle.as_str(), member.account.handle.as_str()); ok(&org, &["account", "delegate", o, &gw.did]); if !net().spaces { ok(&org, &["org", "add", m]); let no = refused(&member, &["org", "join", o, "--hidden"]); assert!(no.contains("or its PDS has no spaces"), "{no}"); return; } let open = org.url("open"); let work = org.work("w", 1); org.git(&work, &["push", "-q", &open, "main"]); ok(&org, &["label", "define", &open, "kind/bug"]); let inner = org.url("inner"); ok(&org, &["repo", "create", &inner, "--private"]); ok(&org, &["org", "add", m]); let hidden = format!( "You're a member of {o}, as a member, hidden: only its members see it.\nYou may push to each of its repositories and open pulls. On its public ones, which anyone reads, your pulls' checks run once a maintainer asks, and merging, closing and labelling take a public membership (`knotless org join {o} --public`)." ); assert_eq!(ok(&member, &["org", "join", o, "--hidden"]), hidden); assert_eq!( ok(&org, &["org", "members"]), format!("{m}\tmember\thidden") ); assert_eq!( ok(&member, &["org", "members", o]), format!("{m}\tmember\thidden") ); assert_eq!(ok(&stranger, &["org", "members", o]), ""); // The member sees it as the stranger does, asked to. assert_eq!(ok(&member, &["org", "members", o, "--public"]), ""); assert_eq!(ok(&stranger, &["org", "list", m]), ""); assert!(member.records("dev.knotless.orgAcceptance").is_empty()); for url in [&open, &inner] { assert!(pushes(&member, url), "{url}"); } let (_, refs) = member.cli(&["repo", "refs", &inner], b""); assert!(refs.contains("refs/heads/theirs"), "{refs}"); // They hear of the issues opened on its repositories, public and // private, as its owner does. ok(&stranger, &["issue", "create", &open, "Out in the open"]); ok(&org, &["issue", "create", &inner, "Kept inside"]); let heard = || ok(&member, &["notification", "list", "--peek"]); wait_until(|| heard().matches("\tissue\t").count() == 2); let said = heard(); assert!( said.contains("Out in the open") && said.contains("Kept inside"), "{said}" ); let members = ok(&org, &["repo", "members", &inner]); assert!( members.contains(&format!("{}\tmember", member.account.did)), "{members}" ); // A server not keeping the organization's space counts every member // a private repository's record names, as an outside reader does; and // keeping that repository for a reader, it follows that space as them. let (app, did) = (&gw.app, &org.account.did); let index = app.spaces.as_ref().unwrap(); let org_space = knotless::org::space_uri(did); app.unfollow(index, &org_space); // One who may not read that space asking leaves it unkept. assert_eq!(ok(&stranger, &["org", "members", o]), ""); assert_eq!(index.syncer_of(&org_space).unwrap(), None); let inner_space = knotless::space::uri(did, "inner"); assert!(gw._rt.block_on(app.keep(&inner_space, did)).is_ok()); let counted = app.org_members(did, Some(&inner_space)); assert!(counted.contains(&member.account.did), "{counted:?}"); assert_eq!( index.syncer_of(&org_space).unwrap().as_deref(), Some(did.as_str()) ); reindexed(&gw, &org); // Kept, the organization's space says they are a member still. let counted = app.org_members(did, Some(&inner_space)); assert!(counted.contains(&member.account.did), "{counted:?}"); let pull = format!("at://{}/dev.knotless.pull/x", member.account.did); let label = refused(&member, &["pull", "label", &open, &pull, "kind/bug"]); let public = format!( "on {o}'s public repositories takes a public membership, as those reading them cannot see a hidden one. Show your membership with `knotless org join {o} --public`" ); assert!(label.ends_with(&format!("labelling {public}")), "{label}"); // Their own pull there, asked whether it merges, says why not as a // merge would; a stranger, that only collaborators merge. let clone = member.path("open"); member.git(member.dir.path(), &["clone", "-q", &open, "open"]); member.git(&clone, &["checkout", "-q", "-b", "x"]); member.commit_file(&clone, "x", "x\n"); member.git(&clone, &["push", "-q", "origin", "x"]); let pull = ok(&member, &["pull", "create", &open, "main", &open, "x", "X"]); let checked = ok(&member, &["pull", "merge", &open, &pull, "--check"]); assert!(checked.ends_with(&format!("merging {public}")), "{checked}"); let checked = ok(&stranger, &["pull", "merge", &open, &pull, "--check"]); let not = format!("only the owner and collaborators of {open} merge its pulls"); assert!(checked.ends_with(¬), "{checked}"); // Shown, then hidden again: the record moves with it. assert!(ok(&member, &["org", "join", o, "--public"]).contains("anyone can see it")); assert_eq!( ok(&stranger, &["org", "members", o]), format!("{m}\tmember\tpublic") ); assert_eq!(member.records("dev.knotless.orgAcceptance").len(), 1); assert_eq!(ok(&member, &["org", "join", o, "--hidden"]), hidden); assert_eq!(ok(&stranger, &["org", "members", o]), ""); // A private repository made after names them for its readers. let later = org.url("later"); let made = ok(&org, &["repo", "create", &later, "--private"]); assert!( made.ends_with("1 member of your organization may push to it."), "{made}" ); assert!(pushes(&member, &later)); // Out, they read none of it, but what was shared with them on its own, // for reading or writing; and unsharing leaves a member in. let shared = org.url("shared"); ok(&org, &["repo", "create", &shared, "--private"]); ok(&org, &["repo", "share", &inner, m]); ok(&org, &["repo", "share", &shared, m, "--write"]); ok(&org, &["repo", "share", &later, m, "--undo"]); ok(&org, &["repo", "share", &later, &stranger.account.handle]); assert_eq!( person(member.account.clone()) .cli(&["repo", "refs", &later], b"") .0, 0 ); // Gone while no gateway answers, their join record is all the index // goes by as it hears of it: they push to none of it, and no reader // counts them, though its records name them until they are taken out. let space = knotless::org::space_uri(&org.account.did); let did = &org.account.did; member.delete_space_record(&space, "dev.knotless.orgAcceptance", did); reindexed(&gw, &org); for url in [&open, &inner] { assert!(!pushes(&member, url), "{url}"); } // Shared with on its own, they read it still. let members = ok(&org, &["repo", "members", &inner]); let shared_only = format!("{}\tread", member.account.did); assert!(members.ends_with(&shared_only), "{members}"); ok(&org, &["org", "remove", m]); let reader = person(member.account.clone()); for (url, reads) in [(&inner, 0), (&shared, 0), (&later, 1)] { assert_eq!(reader.cli(&["repo", "refs", url], b"").0, reads, "{url}"); } assert!(!pushes(&member, &open)); } /// An invitation kept in the organization's space reaches its invitee as /// a notification though neither ever opened the pages: the organization /// invites from the command line, and the invitee asks from it. #[test] fn private_invitations_heard_of_from_clients() { let (gw, [org, member]) = served(); if !net().spaces { return; } let (o, m) = (org.account.handle.as_str(), member.account.handle.as_str()); ok(&org, &["account", "delegate", o, &gw.did]); assert!(ok(&org, &["org", "add", m]).contains("private")); let heard = || ok(&member, &["notification", "list", "--peek"]); wait_until(|| heard().contains("\tinvite\t")); let said = heard(); assert!(said.contains(o) && said.contains("as member"), "{said}"); }