~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
653 lines · 28.3 KB Raw History
//! 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[..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(&not), "{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}");
}