johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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<const N: usize>() -> (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 <handle>` 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 <handle>"));
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}");
}