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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
1602 lines · 70.2 KB Raw History
//! What the pages write to repositories, as the account logged in to
//! them, against a reference PDS: making them, forking them, and what an
//! owner sets in their settings.

#[path = "../../knotless/tests/common/mod.rs"]
mod common;
use common::*;

use knotless_serve::gateway::Gateway;
use knotless_serve::sites::{Sites, hosted};
use knotless_serve::{App, router};
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;

struct Server {
    base: String,
    /// The gateway's DID, which owners delegate their writes to.
    did: String,
    app: Arc<App>,
    _rt: tokio::runtime::Runtime,
    _proxy: TlsProxy,
    _sandbox: Sandbox,
}

/// A gateway that logs people in to its pages, and an appview whose index
/// follows the PDS's firehose from now on.
fn server() -> Server {
    let mut s = Sandbox::new();
    s.set("KNOTLESS_PASSWORD", "");
    s.set("KNOTLESS_DID_WEB_SCHEME", "http");
    let rt = tokio::runtime::Runtime::new().unwrap();
    let listener = rt
        .block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
        .unwrap();
    // atproto takes a did:web with a port only for localhost.
    let port = listener.local_addr().unwrap().port();
    let base = format!("http://localhost:{port}");
    // The pages find a repository's pulls here, as a branch renamed moves them.
    s.set("KNOTLESS_APPVIEW", &base);
    let mut gw = Gateway::new(&base).unwrap();
    // Accounts are made on the harness's PDS, not anyone's.
    gw.sign_up_pds = net().pds.clone();
    // Authorization servers reach an OAuth client over https.
    let proxy = tls_proxy(port);
    gw.oauth_url = proxy.url.clone();
    let did = gw.did.clone();
    let index = knotless_serve::index::Index::open(&s.path("index.db")).unwrap();
    let app = App::build(ctx(&s), Duration::ZERO, Some(gw), Some(index));
    // Repositories' sites, on their domain, as any host the proxy says.
    let sites = Sites::new(app.clone(), "knotless.page".into(), format!("{base}/"));
    let router = hosted(router(app.clone()), sites);
    rt.spawn(async move { axum::serve(listener, router).await.unwrap() });
    let firehose = net().pds.replace("http://", "ws://");
    rt.spawn(knotless_serve::firehose::watch(app.clone(), firehose));
    Server {
        base,
        did,
        app,
        _rt: rt,
        _proxy: proxy,
        _sandbox: s,
    }
}

/// A sandbox logged in as `account` with a password, so the command line
/// acts as it, and a browser that approves logins as it.
fn person(account: Account) -> Sandbox {
    let mut s = Sandbox::with(account);
    s.set("KNOTLESS_DID_WEB_SCHEME", "http");
    s.set("KNOTLESS_PASSWORD", "");
    s.set("KNOTLESS_BROWSER", &browser(&s.account.clone(), ""));
    let password = s.account.password.clone();
    let handle = s.account.handle.clone();
    let login = ["login", &handle, "--password-stdin"];
    assert_eq!(s.cli(&login, password.as_bytes()).0, 0);
    s
}

/// A request to `url` as a browser on the site sends it, with `cookie` if
/// any: its status, the cookies it sets, and its answer. An XRPC answer is
/// kept too, where the run names a directory for them, for harness/
/// answers.mjs to hold to its lexicon.
fn browse(
    method: &str,
    url: &str,
    cookie: Option<&str>,
    body: Option<Value>,
) -> (u16, Vec<String>, Value) {
    let client = reqwest::blocking::Client::builder()
        .redirect(reqwest::redirect::Policy::none())
        .build()
        .unwrap();
    let mut req = client.request(method.parse().unwrap(), url);
    if let Some(cookie) = cookie {
        req = req.header("cookie", cookie);
    }
    if let Some(body) = body {
        req = req.json(&body);
    }
    let res = req.send().unwrap();
    let status = res.status().as_u16();
    let set = res.headers().get_all("set-cookie").iter();
    let set = set.map(|c| c.to_str().unwrap().to_string()).collect();
    let answer = res.json().unwrap_or(Value::Null);
    let path = url.split_once("/xrpc/").map(|(_, p)| format!("/xrpc/{p}"));
    if let (Some(dir), Some(path), 200) = (std::env::var_os("KNOTLESS_ANSWERS"), path, status) {
        keep(&dir, &path, &answer);
    }
    (status, set, answer)
}

/// Adds the answer at `path` to this process's file of them under `dir`.
fn keep(dir: &std::ffi::OsStr, path: &str, answer: &Value) {
    use std::io::Write;
    static FILE: std::sync::Mutex<()> = std::sync::Mutex::new(());
    let _held = FILE.lock().unwrap();
    std::fs::create_dir_all(dir).unwrap();
    let file = std::path::Path::new(dir).join(format!("pages-{}.jsonl", std::process::id()));
    let mut out = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(file)
        .unwrap();
    writeln!(out, "{}", json!({ "path": path, "body": answer })).unwrap();
}

/// Logs `who` in to the pages of `server`, approving in a browser: the
/// cookie naming them, as the browser sends it back.
fn log_in(server: &Server, who: &Account) -> String {
    let start = json!({"handle": who.handle, "back": "/"});
    log_in_as(server, start, who, "").unwrap()
}

/// Starts a login to the pages of `server` with `start`, and approves it
/// in a browser as `who`, with `extra` its flags: the cookie naming them,
/// if the site set one.
fn log_in_as(server: &Server, start: Value, who: &Account, extra: &str) -> Option<String> {
    let login = format!("{}/oauth/login", server.base);
    let (status, set, answer) = browse("POST", &login, None, Some(start));
    assert_eq!(status, 200, "{answer}");
    let started = set[0].split(';').next().unwrap().to_string();
    let url = answer["url"].as_str().unwrap();
    let command = format!("{} --cookie {started} {url}", browser(who, extra));
    let mut words = command.split_whitespace();
    let out = std::process::Command::new(words.next().unwrap())
        .args(words)
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );
    let set = String::from_utf8(out.stdout).unwrap();
    let session = set.lines().find(|c| c.starts_with("knotless_session="));
    session.map(|c| c.split(';').next().unwrap().to_string())
}

impl Server {
    fn xrpc(&self, nsid: &str) -> String {
        format!("{}/xrpc/dev.knotless.{nsid}", self.base)
    }

    /// The procedure `nsid` given `input`, as the browser holding `cookie`.
    fn call(&self, nsid: &str, cookie: Option<&str>, input: Value) -> (u16, Value) {
        let (status, _, answer) = browse("POST", &self.xrpc(nsid), cookie, Some(input));
        (status, answer)
    }

    /// `getRepo`'s answer for `did`'s repository `name`.
    fn repo(&self, did: &str, name: &str) -> Value {
        let url = format!(
            "{}?repo=at://{did}/dev.knotless.repo/{name}",
            self.xrpc("getRepo")
        );
        let (status, _, answer) = browse("GET", &url, None, None);
        assert_eq!(status, 200, "{answer}");
        answer
    }
}

/// A name the form checks as it is typed, still being looked for when the
/// repository it names is made, keeps no one from the repository after:
/// the reads its page asks for at once find it.
#[test]
fn a_name_checked_as_its_repository_is_made() {
    let server = server();
    let maker = person(account());
    let cookie = log_in(&server, &maker.account);
    let me = Some(cookie.as_str());
    let handle = maker.account.handle.clone();
    for i in 0..20u64 {
        let name = format!("raced{i}");
        let repo = format!("at://{handle}/dev.knotless.repo/{name}");
        let read = |nsid: &str| {
            let url = format!("{}?repo={repo}", server.xrpc(nsid));
            browse("GET", &url, me, None).0
        };
        let read = &read;
        std::thread::scope(|s| {
            let made = s.spawn(|| server.call("createRepo", me, json!({ "name": name })).0);
            // The check, a moment into the making.
            std::thread::sleep(Duration::from_millis(i * 3));
            let checked = s.spawn(|| read("getRepo"));
            assert_eq!(made.join().unwrap(), 200);
            let page = ["getRepo", "getTree", "listBranches", "listTags"];
            let page = page.map(|nsid| s.spawn(move || (nsid, read(nsid))));
            let found = page.map(|asked| asked.join().unwrap());
            assert!(
                found.iter().all(|(_, status)| *status == 200),
                "{name}: {found:?}"
            );
            checked.join().unwrap();
        });
    }
}

/// An account taken down cannot log in, and the cookie it holds names no
/// one until it is restored.
#[test]
fn a_taken_down_account_is_logged_out() {
    let server = server();
    let who = person(account());
    let cookie = log_in(&server, &who.account);
    let me = Some(cookie.as_str());
    let did = who.account.did.clone();
    let (status, _) = server.call("createRepo", me, json!({"name": "before"}));
    assert_eq!(status, 200);

    let index = server.app.index.clone().unwrap();
    let subject = json!({"$type": "com.atproto.admin.defs#repoRef", "did": did});
    let down = |applied| index.take_down(&server.did, &did, &subject, applied);
    down(true).unwrap();
    let (status, _) = server.call("createRepo", me, json!({"name": "during"}));
    assert_eq!(status, 401);
    let start = json!({"handle": who.account.handle, "back": "/"});
    let login = format!("{}/oauth/login", server.base);
    // Refused as text, not JSON: the pages show it as it is.
    let refused = reqwest::blocking::Client::new().post(&login).json(&start);
    let refused = refused.send().unwrap();
    assert_eq!(refused.status().as_u16(), 403);
    let why = refused.text().unwrap();
    assert!(why.contains("taken down here"), "{why}");

    // Restored, the same cookie names it again.
    down(false).unwrap();
    let (status, _) = server.call("createRepo", me, json!({"name": "after"}));
    assert_eq!(status, 200);
}

/// Someone with no account makes one on the PDS the site sends people to,
/// on its sign-up page, and comes back logged in as it. An account taken
/// down that signs in there instead is still refused.
#[test]
fn making_an_account_to_log_in() {
    let server = server();
    let start = json!({"create": true, "back": "/"});
    // Made once in this binary, so its tag sets it apart.
    let handle = format!("n{}.test", *common::TAG);
    let made = Account {
        handle: handle.clone(),
        did: String::new(),
        password: format!("{handle}-password"),
    };
    let cookie = log_in_as(&server, start.clone(), &made, "--create").unwrap();
    let viewer = server.xrpc("getViewer");
    let (status, _, shown) = browse("GET", &viewer, Some(&cookie), None);
    assert_eq!(status, 200, "{shown}");
    let did = shown["actor"]["did"].as_str().unwrap();
    assert!(did.starts_with("did:plc:"), "{shown}");
    // Its session is the pages', kept under the account the PDS named.
    let ctx = knotless_serve::login::ctx_for(&server.app, did);
    let kept = knotless::session::load(&ctx, did).unwrap();
    assert_eq!(kept.handle, handle);

    let who = account();
    let index = server.app.index.clone().unwrap();
    let subject = json!({"$type": "com.atproto.admin.defs#repoRef", "did": who.did});
    index
        .take_down(&server.did, &who.did, &subject, true)
        .unwrap();
    assert_eq!(log_in_as(&server, start, &who, ""), None);
}

/// The app's login, through the browser it opens, as `who`: where it
/// came back to.
fn app_log_in(server: &Server, who: &Account, challenge: &str, extra: &str) -> String {
    let client = reqwest::blocking::Client::builder()
        .redirect(reqwest::redirect::Policy::none())
        .build()
        .unwrap();
    let url = format!("{}/oauth/app-login", server.base);
    let ask = [("handle", who.handle.as_str()), ("challenge", challenge)];
    let res = client.get(&url).query(&ask).send().unwrap();
    assert_eq!(res.status().as_u16(), 303);
    let headers = res.headers();
    let started = headers["set-cookie"].to_str().unwrap();
    let started = started.split(';').next().unwrap();
    let pds = headers["location"].to_str().unwrap();
    let command = format!("{} --cookie {started} {pds}", browser(who, extra));
    let mut words = command.split_whitespace();
    let out = std::process::Command::new(words.next().unwrap())
        .args(words)
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );
    let out = String::from_utf8(out.stdout).unwrap();
    let back = out.lines().find_map(|l| l.strip_prefix("app: "));
    back.unwrap().to_string()
}

/// The app logs in through a browser, comes back with a code only its
/// verifier can trade, once, and with what it gets asks and writes as a
/// page's cookie does. A login refused gives it nothing, and nor does an
/// account taken down since.
#[test]
fn the_app_logs_in_with_a_code_only_it_can_trade() {
    let server = server();
    let who = person(account());
    let verifier = "the-app-keeps-this-to-itself-and-sends-it-once";
    let challenge = knotless::oauth::sha256(verifier);
    let trade = format!("{}/oauth/app-token", server.base);
    let code_from = |back: &str| back.split_once("?code=").unwrap().1.to_string();
    let swap = |code: &str, verifier: &str| {
        let ask = json!({"code": code, "verifier": verifier});
        browse("POST", &trade, None, Some(ask))
    };

    let start = format!("{}/oauth/app-login?handle=a&challenge=short", server.base);
    assert_eq!(browse("GET", &start, None, None).0, 400);

    let refused = app_log_in(&server, &who.account, &challenge, "--reject");
    let where_to = knotless_serve::login::APP_RETURN;
    assert_eq!(refused, format!("{}{where_to}?error=refused", server.base));

    // A browser with no app to take over is told so, and iOS can see it may.
    let (status, _, _) = browse("GET", &format!("{}{where_to}", server.base), None, None);
    assert_eq!(status, 200);
    let named = format!("{}/.well-known/apple-app-site-association", server.base);
    let (status, _, said) = browse("GET", &named, None, None);
    assert_eq!(status, 200);
    // What it answers is what the app is built to ask: without both, a browser
    // session won't open for it, or won't come back. The file on Apple's CDN
    // follows this one.
    let project = include_str!("../../../ios/project.yml");
    let entitlements = include_str!("../../../ios/Knotless/Knotless.entitlements");
    let setting = |key: &str| {
        project
            .lines()
            .find_map(|line| line.trim().strip_prefix(key))
            .unwrap()
    };
    let app = format!(
        "{}.{}",
        setting("DEVELOPMENT_TEAM: "),
        setting("PRODUCT_BUNDLE_IDENTIFIER: ")
    );
    let details = &said["applinks"]["details"][0];
    assert_eq!(details["appIDs"][0], json!(app));
    assert_eq!(details["components"][0]["/"], json!(where_to));
    assert_eq!(said["webcredentials"]["apps"][0], json!(app));
    for service in ["applinks:knotless.dev", "webcredentials:knotless.dev"] {
        assert!(
            project.contains(service) && entitlements.contains(service),
            "{service}"
        );
    }

    // A wrong verifier spends the code, for whoever stole it.
    let code = code_from(&app_log_in(&server, &who.account, &challenge, ""));
    assert_eq!(swap(&code, "not-the-verifier").0, 400);
    assert_eq!(swap(&code, verifier).0, 400);

    let code = code_from(&app_log_in(&server, &who.account, &challenge, ""));
    let (status, set, answer) = swap(&code, verifier);
    assert_eq!(status, 200, "{answer}");
    assert_eq!(set, Vec::<String>::new());
    assert_eq!(answer["did"], json!(who.account.did));
    assert_eq!(swap(&code, verifier).0, 400);

    let cookie = format!("knotless_session={}", answer["token"].as_str().unwrap());
    let me = Some(cookie.as_str());
    let (status, _, shown) = browse("GET", &server.xrpc("getViewer"), me, None);
    assert_eq!(status, 200, "{shown}");
    assert_eq!(shown["actor"]["did"], json!(who.account.did));
    let (status, made) = server.call("createRepo", me, json!({"name": "from-the-app"}));
    assert_eq!(status, 200, "{made}");

    let code = code_from(&app_log_in(&server, &who.account, &challenge, ""));
    let index = server.app.index.clone().unwrap();
    let subject = json!({"$type": "com.atproto.admin.defs#repoRef", "did": who.account.did});
    let down = |applied| index.take_down(&server.did, &who.account.did, &subject, applied);
    down(true).unwrap();
    assert_eq!(swap(&code, verifier).0, 400);
    down(false).unwrap();
}

/// Someone logged in to the pages makes a repository there, and forks
/// another, as `knotless repo create` and `knotless repo fork` do; and only someone
/// logged in can.
#[test]
fn making_repositories_from_the_pages() {
    // Started first, so its index sees the repositories made after.
    let server = server();
    let owner = person(account());
    let source = owner.url("source");
    let work = owner.work("w", 2);
    owner.git(&work, &["push", "-q", &source, "main"]);
    let maker = person(account());
    let cookie = log_in(&server, &maker.account);
    let me = Some(cookie.as_str());
    let did = maker.account.did.clone();

    // Made with what the form asks for, and shown at once.
    let asked =
        json!({"name": " made ", "description": "From the pages", "defaultBranch": "trunk"});
    let (status, made) = server.call("createRepo", me, asked);
    assert_eq!(status, 200, "{made}");
    let uri = format!("at://{did}/dev.knotless.repo/made");
    assert_eq!(made, json!({"uri": uri, "name": "made"}));
    let record = maker.record("dev.knotless.repo", "made");
    assert_eq!(record["description"], "From the pages");
    assert_eq!(record["defaultBranch"], "trunk");
    assert_eq!(server.repo(&did, "made")["description"], "From the pages");
    // Blank fields are left out, and the default branch is main.
    let (status, _) = server.call(
        "createRepo",
        me,
        json!({"name": "plain", "description": ""}),
    );
    assert_eq!(status, 200);
    let plain = maker.record("dev.knotless.repo", "plain");
    assert_eq!(
        (plain.get("description"), &plain["defaultBranch"]),
        (None, &json!("main"))
    );
    // Not twice, nor under a name no repository can have.
    let (status, again) = server.call("createRepo", me, json!({"name": "made"}));
    assert_eq!(status, 400);
    assert!(
        again["message"]
            .as_str()
            .unwrap()
            .contains("already exists"),
        "{again}"
    );
    let (status, bad) = server.call("createRepo", me, json!({"name": "no good"}));
    assert_eq!(status, 400);
    assert!(
        bad["message"]
            .as_str()
            .unwrap()
            .contains("no repository's name"),
        "{bad}"
    );
    // Only as someone logged in.
    let (status, _) = server.call("createRepo", None, json!({"name": "nobody"}));
    assert_eq!(status, 401);

    // A fork takes the repository's name, or one given, and every branch.
    let theirs = format!("at://{}/dev.knotless.repo/source", owner.account.did);
    let (status, forked) = server.call("forkRepo", me, json!({ "repo": theirs }));
    assert_eq!(status, 200, "{forked}");
    assert_eq!(forked["name"], "source");
    let shown = server.repo(&did, "source");
    assert_eq!(shown["forkOf"]["name"], "source");
    assert_eq!(shown["defaultBranch"], "main");
    let named = json!({"repo": theirs, "name": "copy"});
    assert_eq!(server.call("forkRepo", me, named).1["name"], "copy");
    let (status, twice) = server.call("forkRepo", me, json!({ "repo": theirs }));
    assert_eq!(status, 400, "{twice}");
    let missing = format!("at://{}/dev.knotless.repo/missing", owner.account.did);
    assert_eq!(
        server.call("forkRepo", me, json!({ "repo": missing })).0,
        404
    );
    assert_eq!(
        server.call("forkRepo", None, json!({ "repo": theirs })).0,
        401
    );
}

impl Server {
    /// `getRepoSettings`' answer for `did`'s repository `name`.
    fn settings(&self, did: &str, name: &str) -> Value {
        let url = format!(
            "{}?repo=at://{did}/dev.knotless.repo/{name}",
            self.xrpc("getRepoSettings")
        );
        let (status, _, answer) = browse("GET", &url, None, None);
        assert_eq!(status, 200, "{answer}");
        answer
    }
}

/// An owner changes their repository's settings from the pages, as the
/// command line does: what it says it is and its default branch, its name,
/// its packs, an offer to move it, which the account offered it takes up,
/// and deleting it. Anyone else is refused, whatever the pages show.
#[test]
fn owners_change_their_settings_from_the_pages() {
    let server = server();
    let owner = person(account());
    let other = person(account());
    let kept = owner.url("kept");
    let work = owner.work("w", 2);
    owner.git(&work, &["push", "-q", &kept, "main", "main:topic"]);
    let triager = ["triager", "add", &kept, &other.account.handle, "kind"];
    assert_eq!(owner.cli(&triager, b"").0, 0);
    let (did, theirs) = (owner.account.did.clone(), other.account.did.clone());
    let mine = log_in(&server, &owner.account);
    let me = Some(mine.as_str());
    let them = log_in(&server, &other.account);
    let not_me = Some(them.as_str());
    let uri = |name: &str| format!("at://{did}/dev.knotless.repo/{name}");

    // What it has set, from its record.
    let set = server.settings(&did, "kept");
    assert_eq!(set["uri"], uri("kept"));
    assert_eq!(set["defaultBranch"], "main");
    assert_eq!((&set["rules"], &set["labels"]), (&json!([]), &json!([])));
    assert_eq!(set["collaborators"], json!([]));
    assert_eq!(set["triagers"][0]["actor"]["did"], theirs);
    assert_eq!(set["triagers"][0]["scopes"], json!([{"name": "kind"}]));
    assert_eq!(set.get("transfer"), None);
    // What it takes up, apart from its settings, which no longer count it;
    // and the same again from what was counted, as nothing of it moved.
    assert_eq!(set.get("storage"), None);
    let storage = |name: &str| {
        let url = format!("{}?repo={}", server.xrpc("getRepoStorage"), uri(name));
        let (status, _, answer) = browse("GET", &url, None, None);
        assert_eq!(status, 200, "{answer}");
        answer
    };
    let counted = storage("kept");
    assert!(counted["packs"].as_u64().unwrap() >= 1, "{counted}");
    assert_eq!(storage("kept"), counted);

    // Its description and default branch, by the owner alone.
    let links = json!([{"url": "https://kept.example"}, {"url": "https://kept.example/docs", "title": "Docs"}]);
    let topics = json!([{"name": "rust"}, {"name": "cli"}]);
    let change = json!({"repo": uri("kept"), "description": "Now said", "links": links, "topics": topics, "defaultBranch": "topic"});
    assert_eq!(server.call("updateRepo", None, change.clone()).0, 401);
    let (status, refused) = server.call("updateRepo", not_me, change.clone());
    assert_eq!(status, 403, "{refused}");
    assert!(
        refused["message"].as_str().unwrap().contains("owner"),
        "{refused}"
    );
    let (status, changed) = server.call("updateRepo", me, change);
    assert_eq!(status, 200, "{changed}");
    assert_eq!(
        changed,
        json!({"description": "Now said", "links": links, "topics": topics, "defaultBranch": "topic"})
    );
    let shown = server.repo(&did, "kept");
    let said = (&shown["description"], &shown["defaultBranch"]);
    assert_eq!(said, (&json!("Now said"), &json!("topic")));
    assert_eq!((&shown["links"], &shown["topics"]), (&links, &topics));
    assert_eq!(shown.get("collaborators"), None);
    let set = server.settings(&did, "kept");
    assert_eq!((&set["links"], &set["topics"]), (&links, &topics));
    // Listed among those about one of them, and not another.
    let about = |topic: &str| {
        let url = format!("{}?topic={topic}", server.xrpc("listRepos"));
        let (status, _, listed) = browse("GET", &url, None, None);
        assert_eq!(status, 200, "{listed}");
        let names = listed["repos"].as_array().unwrap().iter();
        names
            .map(|r| r["name"].as_str().unwrap().to_string())
            .collect::<Vec<_>>()
    };
    assert!(about("rust").contains(&"kept".to_string()));
    assert!(!about("haskell").contains(&"kept".to_string()));
    let untopical = json!({"repo": uri("kept"), "topics": [{"name": "no spaces"}]});
    assert_eq!(server.call("updateRepo", me, untopical).0, 400);
    let nowhere = json!({"repo": uri("kept"), "links": [{"url": "kept.example"}]});
    assert_eq!(server.call("updateRepo", me, nowhere).0, 400);
    let nowhere = json!({"repo": uri("kept"), "defaultBranch": "nowhere"});
    assert_eq!(server.call("updateRepo", me, nowhere).0, 400);

    // Its packs folded.
    let kept_uri = json!({ "repo": uri("kept") });
    let (status, compacted) = server.call("compactRepo", me, kept_uri.clone());
    assert_eq!(status, 200, "{compacted}");
    assert!(compacted["packs"].as_u64().unwrap() >= 1, "{compacted}");
    assert_eq!(server.call("compactRepo", not_me, kept_uri.clone()).0, 403);
    // Counted again at once, before the firehose tells of it.
    let (before, folded) = (&compacted["packs"], &compacted["folded"]);
    let (before, folded) = (before.as_u64().unwrap(), folded.as_u64().unwrap());
    let now = before - folded + u64::from(folded > 0);
    assert_eq!(storage("kept")["packs"], now, "{compacted}");

    // Archived, which its page says, then unarchived; only by its owner,
    // and only once each way.
    let archive = json!({ "repo": uri("kept") });
    let unarchive = json!({"repo": uri("kept"), "unarchive": true});
    assert_eq!(server.call("archiveRepo", not_me, archive.clone()).0, 403);
    let (status, archived) = server.call("archiveRepo", me, archive.clone());
    assert_eq!(status, 200, "{archived}");
    assert!(archived["archivedAt"].is_string(), "{archived}");
    assert!(server.repo(&did, "kept")["archivedAt"].is_string());
    assert_eq!(server.call("archiveRepo", me, archive).0, 400);
    let (status, back) = server.call("archiveRepo", me, unarchive.clone());
    assert_eq!((status, back), (200, json!({})));
    assert_eq!(server.repo(&did, "kept").get("archivedAt"), None);
    assert_eq!(server.call("archiveRepo", me, unarchive).0, 400);

    // Offered to another account, withdrawn, and offered again under
    // another name, which that account sees on the repository's page.
    let offer = json!({"repo": uri("kept"), "to": other.account.handle});
    let (status, offered) = server.call("transferRepo", me, offer);
    assert_eq!(status, 200, "{offered}");
    let to = (&offered["transfer"]["did"], &offered["transfer"]["name"]);
    assert_eq!(to, (&json!(theirs), &json!("kept")));
    assert_eq!(server.settings(&did, "kept")["transfer"]["name"], "kept");
    let (status, withdrawn) = server.call("transferRepo", me, kept_uri.clone());
    assert_eq!((status, withdrawn), (200, json!({})));
    let nobody = json!({"repo": uri("kept"), "to": "nobody.invalid"});
    assert_eq!(server.call("transferRepo", me, nobody).0, 400);
    let offer = json!({"repo": uri("kept"), "to": theirs, "name": "taken"});
    assert_eq!(server.call("transferRepo", not_me, offer.clone()).0, 403);
    assert_eq!(server.call("transferRepo", me, offer).0, 200);
    let page = |cookie: Option<&str>| {
        let url = format!("{}?repo={}", server.xrpc("getRepo"), uri("kept"));
        browse("GET", &url, cookie, None).2
    };
    assert_eq!(page(not_me)["viewer"]["transfer"]["name"], "taken");
    assert_eq!(page(me)["viewer"].get("transfer"), None);
    // Taken up by that account alone, under the name offered.
    assert_eq!(server.call("acceptTransfer", me, kept_uri.clone()).0, 400);
    let (status, taken) = server.call("acceptTransfer", not_me, kept_uri);
    assert_eq!(status, 200, "{taken}");
    let there = format!("at://{theirs}/dev.knotless.repo/taken");
    assert_eq!(taken, json!({"uri": there, "name": "taken"}));
    assert_eq!(server.repo(&theirs, "taken")["description"], "Now said");

    // Renamed, the old name leads to the new.
    assert_eq!(
        owner.cli(&["repo", "create", &owner.url("named")], b"").0,
        0
    );
    let rename = json!({"repo": uri("named"), "name": "renamed"});
    assert_eq!(server.call("renameRepo", not_me, rename.clone()).0, 403);
    let (status, renamed) = server.call("renameRepo", me, rename);
    assert_eq!(status, 200, "{renamed}");
    assert_eq!(renamed, json!({"uri": uri("renamed"), "name": "renamed"}));
    assert_eq!(server.repo(&did, "named")["name"], "renamed");
    let again = json!({"repo": uri("renamed"), "name": "renamed"});
    assert_eq!(server.call("renameRepo", me, again).0, 400);

    // Deleted, by the owner alone.
    let gone = json!({ "repo": uri("renamed") });
    assert_eq!(server.call("deleteRepo", not_me, gone.clone()).0, 403);
    let (status, deleted) = server.call("deleteRepo", me, gone.clone());
    assert_eq!((status, deleted), (200, json!({"uri": uri("renamed")})));
    assert!(owner.record("dev.knotless.repo", "renamed").is_null());
    assert_eq!(server.call("deleteRepo", me, gone).0, 404);
}

/// An owner manages a repository's branches, which are protected, its
/// labels, and who may push to it or label it, from the pages' settings, as
/// the command line does; anyone else is refused.
#[test]
fn owners_manage_branches_labels_and_access() {
    let server = server();
    let mut owner = person(account());
    owner.set("KNOTLESS_APPVIEW", &server.base);
    let other = person(account());
    let helper = person(account());
    let managed = owner.url("managed");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &managed, "main"]);
    let mine = log_in(&server, &owner.account);
    let me = Some(mine.as_str());
    let them = log_in(&server, &other.account);
    let not_me = Some(them.as_str());
    let repo = format!("at://{}/dev.knotless.repo/managed", owner.account.did);
    let with = |more: Value| {
        let mut input = json!({ "repo": repo });
        let more = more.as_object().unwrap().clone();
        input.as_object_mut().unwrap().extend(more);
        input
    };

    // Branches, made, renamed and deleted; the default one stays.
    let (status, made) = server.call(
        "createBranch",
        me,
        with(json!({"name": "topic", "from": "main"})),
    );
    assert_eq!(status, 200, "{made}");
    assert_eq!(made["name"], "topic");
    assert_eq!(made["sha"].as_str().map(str::len), Some(40));
    let again = with(json!({"name": "topic"}));
    assert_eq!(server.call("createBranch", me, again).0, 400);
    // Anyone else pushes through the gateway, which takes no one but
    // collaborators, and there is none yet.
    let theirs = with(json!({"name": "theirs"}));
    assert_eq!(server.call("createBranch", not_me, theirs.clone()).0, 400);
    assert_eq!(server.call("createBranch", None, theirs.clone()).0, 401);
    let rename = with(json!({"name": "topic", "to": "twig"}));
    let (status, renamed) = server.call("renameBranch", me, rename);
    assert_eq!((status, renamed), (200, json!({"name": "twig"})));
    let (_, refs) = owner.cli(&["repo", "refs", &managed], b"");
    assert!(
        refs.contains("heads/twig") && !refs.contains("heads/topic"),
        "{refs}"
    );
    // The command line renames one the same way.
    let bough = ["branch", "rename", &managed, "twig", "bough"];
    let said = "renamed twig to bough";
    assert_eq!(owner.cli(&bough, b""), (0, said.to_string()));
    let gone = with(json!({"name": "bough"}));
    let (status, deleted) = server.call("deleteBranch", me, gone);
    assert_eq!((status, deleted), (200, json!({"name": "bough"})));
    let main = with(json!({"name": "main"}));
    assert_eq!(server.call("deleteBranch", me, main).0, 400);

    // Protected, and no longer, by the owner alone.
    let protect = with(json!({"branch": "main", "merges": true}));
    let (status, rules) = server.call("protectBranch", me, protect);
    assert_eq!(status, 200, "{rules}");
    let held = json!([{"branch": "main", "history": "merges"}]);
    assert_eq!(rules, json!({ "rules": held }));
    let set = server.settings(&owner.account.did, "managed");
    assert_eq!(set["rules"], held);
    assert_eq!(set.get("gates"), None);
    // Its gates, named twice, are shown once, as accounts.
    let gate = json!({ "did": other.account.did });
    let reviews = json!({ "gates": [gate, gate] });
    let gated = with(json!({"branch": "main", "reviews": reviews}));
    assert_eq!(server.call("protectBranch", me, gated).0, 200);
    let set = server.settings(&owner.account.did, "managed");
    let gates = set["gates"].as_array().unwrap();
    assert_eq!(gates.len(), 1, "{set}");
    assert_eq!(gates[0]["did"], other.account.did.as_str());
    let main = with(json!({"branch": "main"}));
    assert_eq!(server.call("protectBranch", not_me, main.clone()).0, 403);
    let (status, rules) = server.call("unprotectBranch", me, main.clone());
    assert_eq!((status, rules), (200, json!({"rules": []})));
    assert_eq!(server.call("unprotectBranch", me, main).0, 400);

    // Labels, defined, refused a bad color, and taken away.
    let bug = json!({"name": " kind/bug ", "color": "#d73a4a", "description": "Broken", "exclusive": true});
    let (status, labels) = server.call("defineLabel", me, with(bug));
    assert_eq!(status, 200, "{labels}");
    let defined = json!([{"name": "kind/bug", "color": "#d73a4a", "description": "Broken", "exclusive": true}]);
    assert_eq!(labels["labels"], defined);
    let plain = with(json!({"name": "plain", "color": "", "description": ""}));
    let (_, labels) = server.call("defineLabel", me, plain);
    assert_eq!(labels["labels"][1], json!({"name": "plain"}));
    let red = with(json!({"name": "x", "color": "red"}));
    assert_eq!(server.call("defineLabel", me, red).0, 400);
    let x = with(json!({"name": "x"}));
    assert_eq!(server.call("defineLabel", not_me, x).0, 403);
    let plain = with(json!({"name": "plain"}));
    let (status, labels) = server.call("removeLabel", me, plain.clone());
    assert_eq!((status, &labels["labels"]), (200, &defined));
    assert_eq!(server.call("removeLabel", me, plain).0, 400);

    // Triagers, named for scopes, and no longer.
    let triage = with(json!({"who": other.account.handle, "scopes": [{"name": "kind"}]}));
    let (status, triagers) = server.call("setTriager", me, triage.clone());
    assert_eq!(status, 200, "{triagers}");
    assert_eq!(triagers["triagers"][0]["actor"]["did"], other.account.did);
    assert_eq!(triagers["triagers"][0]["scopes"], json!([{"name": "kind"}]));
    let set = server.settings(&owner.account.did, "managed");
    assert_eq!(set["triagers"], triagers["triagers"]);
    assert_eq!(server.call("setTriager", not_me, triage).0, 403);
    let none = with(json!({"who": other.account.handle, "scopes": []}));
    assert_eq!(server.call("setTriager", me, none).0, 400);
    let gone = with(json!({ "who": other.account.did }));
    let (status, triagers) = server.call("removeTriager", me, gone.clone());
    assert_eq!((status, triagers), (200, json!({"triagers": []})));
    assert_eq!(server.call("removeTriager", me, gone).0, 400);

    // Collaborators push through a gateway, which the owner must delegate
    // to first; the pages share once the repository names it.
    let share = with(json!({ "with": helper.account.handle }));
    let (status, refused) = server.call("shareRepo", me, share.clone());
    assert_eq!(status, 400);
    let said = refused["message"].as_str().unwrap();
    // Said as the pages do it, by the control there, never a command.
    let control = "with Delegate writes to this server, under Delegated writes in your settings";
    assert!(said.ends_with(control), "{refused}");
    assert!(!said.contains('`'), "{refused}");
    let delegate = ["account", "delegate", &owner.account.handle, &server.did];
    let (code, out) = owner.cli(&delegate, b"");
    assert_eq!(code, 0, "{out}");
    let first = ["repo", "share", &managed, &other.account.handle, "--write"];
    assert_eq!(owner.cli(&first, b"").0, 0);
    let (status, shared) = server.call("shareRepo", me, share.clone());
    assert_eq!(status, 200, "{shared}");
    let writers = shared["collaborators"].as_array().unwrap().iter();
    let dids: Vec<&str> = writers.map(|a| a["did"].as_str().unwrap()).collect();
    assert_eq!(
        dids,
        [other.account.did.as_str(), helper.account.did.as_str()]
    );
    // Its page names them, in its record's order, and how many.
    let shown = server.repo(&owner.account.did, "managed");
    let named = shown["collaborators"].as_array().unwrap().iter();
    let named: Vec<&str> = named.map(|a| a["did"].as_str().unwrap()).collect();
    assert_eq!(named, dids, "{shown}");
    assert_eq!(shown["collaboratorCount"], 2);
    assert_eq!(server.call("shareRepo", not_me, share.clone()).0, 403);
    let (status, unshared) = server.call("unshareRepo", me, share);
    assert_eq!(status, 200, "{unshared}");
    let left = unshared["collaborators"].as_array().unwrap();
    assert_eq!(
        (left.len(), &left[0]["did"]),
        (1, &json!(other.account.did))
    );
    let set = server.settings(&owner.account.did, "managed");
    assert_eq!(set["collaborators"][0]["did"], other.account.did);
    // A collaborator makes branches from the pages too, through the gateway.
    let (status, made) = server.call("createBranch", not_me, theirs);
    assert_eq!(status, 200, "{made}");
}

/// A member who never logged in here reads a private repository with a
/// delegation token from their own PDS, one to each query, as a site that
/// signed them in elsewhere asks: the repository, its files and its
/// issues, for them alone. A token spent once reads nothing more, and a
/// stranger's, or one for another space, reads nothing.
#[test]
fn delegation_tokens_read_a_private_repository() {
    if !net().spaces {
        return;
    }
    let server = server();
    let owner = person(account());
    let member = person(account());
    let stranger = person(account());
    let url = owner.url("kept");
    assert_eq!(owner.cli(&["repo", "create", &url, "--private"], b"").0, 0);
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let share = ["repo", "share", &url, &member.account.handle];
    assert_eq!(owner.cli(&share, b"").0, 0);
    let (code, out) = member.cli(&["issue", "create", &url, "Kept trouble"], b"");
    assert_eq!(code, 0, "{out}");
    let at = format!("at://{}/dev.knotless.repo/kept", owner.account.did);
    let space = knotless::space::uri(&owner.account.did, "kept");

    let rt = tokio::runtime::Runtime::new().unwrap();
    let token = |s: &Sandbox, space: &str| {
        let c = ctx(s);
        let me = rt.block_on(knotless::session::me(&c)).unwrap();
        rt.block_on(knotless::space::delegation(&c, space, &me))
            .unwrap()
    };
    let client = reqwest::blocking::Client::new();
    let ask = |method: &str, query: &str, token: Option<&str>| {
        let mut req = client.get(format!("{}?repo={at}{query}", server.xrpc(method)));
        if let Some(token) = token {
            req = req.header(knotless::space::DELEGATION_HEADER, token);
        }
        let res = req.send().unwrap();
        let cache = res.headers().get("cache-control").cloned();
        let cache = cache.map(|c| c.to_str().unwrap().to_string());
        (
            res.status().as_u16(),
            cache,
            res.json().unwrap_or(Value::Null),
        )
    };

    // A page on another site may ask, with the token in a header of its
    // own.
    let preflight = client
        .request(
            reqwest::Method::OPTIONS,
            format!("{}?repo={at}", server.xrpc("getRepo")),
        )
        .header("origin", "https://elsewhere.example")
        .header("access-control-request-method", "GET")
        .header(
            "access-control-request-headers",
            knotless::space::DELEGATION_HEADER,
        )
        .send()
        .unwrap();
    assert!(preflight.status().is_success(), "{}", preflight.status());
    let allowed = preflight.headers().get("access-control-allow-origin");
    assert_eq!(allowed.unwrap(), "*");
    // And read where to ask, from this server's DID document.
    let doc = client
        .get(format!("{}/.well-known/did.json", server.base))
        .header("origin", "https://elsewhere.example")
        .send()
        .unwrap();
    let allowed = doc.headers().get("access-control-allow-origin");
    assert_eq!(allowed.unwrap(), "*");

    // No one's: as if there were none.
    assert_eq!(ask("getRepo", "", None).0, 404);
    // The member's, each query with its own, and kept by no cache.
    let (status, cache, repo) = ask("getRepo", "", Some(&token(&member, &space)));
    assert_eq!(status, 200, "{repo}");
    assert_eq!(repo["private"], true);
    assert_eq!(cache.as_deref(), Some("private, no-store"));
    let (status, _, tree) = ask("getTree", "", Some(&token(&member, &space)));
    assert_eq!(status, 200, "{tree}");
    assert!(!tree["entries"].as_array().unwrap().is_empty(), "{tree}");
    // Its issues from this server's index of the space, synced with what
    // the token got, as no session here syncs it.
    let issues = ask("listItems", "&kind=issue", Some(&token(&member, &space)));
    assert_eq!(issues.0, 200, "{}", issues.2);
    assert_eq!(
        issues.2["items"][0]["title"], "Kept trouble",
        "{}",
        issues.2
    );

    // What it reads again as them from their PDS, such as the workflows
    // a ref has, finds the credential it got.
    let asked = ask("listWorkflows", "", Some(&token(&member, &space)));
    assert_eq!(asked.0, 200, "{}", asked.2);
    // The template a new issue is asked to follow, read from the mirror.
    let asked = ask("getTemplate", "&kind=issue", Some(&token(&member, &space)));
    assert_eq!(asked.0, 200, "{}", asked.2);

    // Spent once.
    let once = token(&member, &space);
    assert_eq!(ask("getRepo", "", Some(&once)).0, 200);
    assert_eq!(ask("getRepo", "", Some(&once)).0, 401);
    // Not a member: their own PDS signs one, which the owner's refuses.
    assert_eq!(ask("getRepo", "", Some(&token(&stranger, &space))).0, 404);
    // For another space, it reads this one as anyone.
    let other = knotless::space::uri(&owner.account.did, "elsewhere");
    assert_eq!(ask("getRepo", "", Some(&token(&member, &other))).0, 404);
    // With a service token, it must be the same account's; and what is
    // sent as one must be one.
    let service = |s: &Sandbox| {
        let c = ctx(s);
        let me = rt.block_on(knotless::session::me(&c)).unwrap();
        let lxm = "dev.knotless.getRepo";
        rt.block_on(knotless::auth::service_token(&c, &me, &server.did, lxm))
            .unwrap()
    };
    let both = |bearer: &str, delegation: &str| {
        let req = client.get(format!("{}?repo={at}", server.xrpc("getRepo")));
        let req = req.header("authorization", format!("Bearer {bearer}"));
        let req = req.header(knotless::space::DELEGATION_HEADER, delegation);
        req.send().unwrap().status().as_u16()
    };
    assert_eq!(both(&service(&member), &token(&member, &space)), 200);
    assert_eq!(both(&service(&stranger), &token(&member, &space)), 401);
    assert_eq!(ask("getRepo", "", Some(&service(&member))).0, 401);
}

/// A private repository's settings, read by its owner alone; and a member
/// forks it from the pages into a fork as private as it is.
#[test]
fn private_repositories_settings_and_forks() {
    if !net().spaces {
        return;
    }
    let server = server();
    let owner = person(account());
    let member = person(account());
    let url = owner.url("hidden");
    assert_eq!(owner.cli(&["repo", "create", &url, "--private"], b"").0, 0);
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let share = ["repo", "share", &url, &member.account.handle];
    assert_eq!(owner.cli(&share, b"").0, 0);
    let mine = log_in(&server, &owner.account);
    let me = Some(mine.as_str());
    let at = format!("at://{}/dev.knotless.repo/hidden", owner.account.did);

    // Its settings, read as its owner, with nothing of indexing in them:
    // every private repository read here is indexed here.
    let settings = |cookie: Option<&str>| {
        let asked = format!("{}?repo={at}", server.xrpc("getRepoSettings"));
        let (status, _, answer) = browse("GET", &asked, cookie, None);
        (status, answer)
    };
    let (status, set) = settings(me);
    assert_eq!(status, 200, "{set}");
    assert_eq!(set.get("indexed"), None);
    let counted = format!("{}?repo={at}", server.xrpc("getRepoStorage"));
    let (status, _, storage) = browse("GET", &counted, me, None);
    assert_eq!((status, &storage["packs"]), (200, &json!(1)), "{storage}");
    assert_eq!(browse("GET", &counted, None, None).0, 404);
    assert_eq!(settings(None).0, 404);
    // Who reads it but does not push, to its owner, as its record says:
    // the member it was shared with, for reading, and one shared so from
    // the pages, at once. A member is shown none.
    let read = |set: &Value| -> Vec<String> {
        let readers = set["readers"].as_array().unwrap().iter();
        readers
            .map(|r| r["did"].as_str().unwrap().to_string())
            .collect()
    };
    assert_eq!(
        read(&set),
        std::slice::from_ref(&member.account.did),
        "{set}"
    );
    let reader = person(account());
    let sharing = json!({"repo": at, "with": reader.account.handle, "write": false});
    let (status, shared) = server.call("shareRepo", me, sharing);
    assert_eq!(status, 200, "{shared}");
    let mut both = read(&settings(me).1);
    both.sort();
    let mut want = [member.account.did.clone(), reader.account.did.clone()];
    want.sort();
    assert_eq!(both, want);
    let theirs = log_in(&server, &member.account);
    let (status, set) = settings(Some(theirs.as_str()));
    assert_eq!((status, &set["readers"]), (200, &json!([])), "{set}");
    // Changed from the pages, they say so at once: read from this server's
    // index of its space, which the change is put in as it is made.
    let described = json!({"repo": at, "description": "Kept quiet"});
    assert_eq!(server.call("updateRepo", me, described).0, 200);
    assert_eq!(settings(me).1["description"], "Kept quiet");

    // Searched for, it is found by its owner, first and marked private, as
    // the search palette lists it, and by no one else.
    let search = format!("{}?q=quiet", server.xrpc("listRepos"));
    let (status, _, found) = browse("GET", &search, me, None);
    assert_eq!(status, 200, "{found}");
    assert_eq!(found["repos"][0]["name"], "hidden", "{found}");
    assert_eq!(found["repos"][0]["private"], true);
    let (_, _, found) = browse("GET", &search, None, None);
    let mut repos = found["repos"].as_array().unwrap().iter();
    assert!(repos.all(|r| r["name"] != "hidden"), "{found}");

    // An issue opened from the pages is read back at once, as the page it
    // goes to reads it: from this server's index of the space, before its
    // sync brings it.
    let asked = json!({"repo": at, "title": "Opened here"});
    let (status, opened) = server.call("createIssue", me, asked);
    assert_eq!(status, 200, "{opened}");
    let issue = opened["uri"].as_str().unwrap();
    let thread = format!("{}?repo={at}&uri={issue}", server.xrpc("getThread"));
    let (status, _, shown) = browse("GET", &thread, me, None);
    assert_eq!(status, 200, "{shown}");
    assert_eq!(shown["item"]["title"], "Opened here");

    // A member forks it from the pages, and the fork is private too.
    let theirs = log_in(&server, &member.account);
    let them = Some(theirs.as_str());
    let (status, forked) = server.call("forkRepo", them, json!({ "repo": at }));
    assert_eq!(status, 200, "{forked}");
    let fork = forked["uri"].as_str().unwrap();
    let asked = format!("{}?repo={fork}", server.xrpc("getRepo"));
    let (status, _, shown) = browse("GET", &asked, them, None);
    assert_eq!(status, 200, "{shown}");
    assert_eq!(shown["private"], true);
    assert_eq!(browse("GET", &asked, None, None).0, 404);
}

impl Server {
    /// `getAccountData`'s status and answer, as the browser holding `cookie`.
    fn account_data(&self, cookie: Option<&str>) -> (u16, Value) {
        let (status, _, answer) = browse("GET", &self.xrpc("getAccountData"), cookie, None);
        (status, answer)
    }

    /// Asks `deleteAccountData` for `did`'s data, `limit` at a time, until
    /// it is done: the answers, and the cookies the last one set.
    fn delete_data(&self, cookie: &str, did: &str, limit: u64) -> (Vec<Value>, Vec<String>) {
        let mut answers = Vec::new();
        loop {
            let asked = json!({"did": did, "limit": limit});
            let (status, set, answer) = browse(
                "POST",
                &self.xrpc("deleteAccountData"),
                Some(cookie),
                Some(asked),
            );
            assert_eq!(status, 200, "{answer}");
            answers.push(answer.clone());
            if answer["done"] == true {
                return (answers, set);
            }
            assert!(answers.len() < 100, "{answers:?}");
        }
    }
}

/// An account deletes its knotless data from the pages: told first what
/// there is, its repositories others push to among it, then deleted a part
/// a call, each call carrying on where the last stopped, until none is
/// left; then the appview forgets it and logs it out, keeping the
/// operator's takedowns. The command line does the same with its own
/// session, and asks the appview only to forget it, which it does once
/// nothing is left.
#[test]
fn an_account_deletes_its_knotless_data() {
    let server = server();
    let owner = person(account());
    let mut other = person(account());
    let did = owner.account.did.clone();
    let theirs = other.url("theirs");
    assert_eq!(other.cli(&["repo", "create", &theirs], b"").0, 0);
    for name in ["one", "two"] {
        assert_eq!(owner.cli(&["repo", "create", &owner.url(name)], b"").0, 0);
    }
    // Each lets the other push, as sharing for writing does once delegated.
    let push = |by: &Sandbox, name: &str, to: &Sandbox| {
        let mut repo = by.record("dev.knotless.repo", name);
        repo["collaborators"] = json!([{ "did": to.account.did }]);
        by.put_record("dev.knotless.repo", name, repo);
    };
    push(&owner, "two", &other);
    push(&other, "theirs", &owner);
    assert_eq!(owner.cli(&["issue", "create", &theirs, "Hello"], b"").0, 0);
    assert_eq!(owner.cli(&["repo", "star", &theirs], b"").0, 0);
    let (rkey, _) = owner.records("dev.knotless.issue").remove(0);
    let issue = format!("at://{did}/dev.knotless.issue/{rkey}");
    let index = server.app.index.clone().unwrap();
    let subject = json!({"$type": "com.atproto.repo.strongRef", "uri": issue});
    index
        .take_down(&server.did, &issue, &subject, true)
        .unwrap();

    // Only the account, logged in to the pages, is told what it holds.
    assert_eq!(server.account_data(None).0, 401);
    let cookie = log_in(&server, &owner.account);
    let me = Some(cookie.as_str());
    let (status, held) = server.account_data(me);
    assert_eq!(status, 200, "{held}");
    assert_eq!(held["did"], did.as_str());
    assert_eq!(held["spaces"], 0);
    let counted = |collection: &str| {
        let all = held["collections"].as_array().unwrap().iter();
        let found = all.clone().find(|c| c["collection"] == collection);
        found.map_or(json!(0), |c| c["count"].clone())
    };
    assert_eq!(
        (counted("dev.knotless.repo"), counted("dev.knotless.issue")),
        (json!(2), json!(1)),
        "{held}"
    );
    assert_eq!(counted("dev.knotless.star"), 1, "{held}");
    let repos = json!([
        {"name": "one", "private": false, "collaborators": 0},
        {"name": "two", "private": false, "collaborators": 1},
    ]);
    assert_eq!(held["repos"], repos);

    // Only its own, and only from the pages.
    let named = json!({"did": other.account.did});
    assert_eq!(server.call("deleteAccountData", me, named).0, 403);
    let mine = json!({ "did": did });
    assert_eq!(server.call("deleteAccountData", None, mine).0, 401);

    // A record at a time, a repository whole: the first call deletes one.
    let (answers, set) = server.delete_data(&cookie, &did, 1);
    assert_eq!(answers[0]["done"], false, "{answers:?}");
    assert!(answers.len() > 2, "{answers:?}");
    let deleted: u64 = answers.iter().map(|a| a["deleted"].as_u64().unwrap()).sum();
    assert!(deleted >= 4, "{answers:?}");
    // Logged out, the browser holding no other account.
    assert!(set[0].starts_with("knotless_session=;"), "{set:?}");
    assert_eq!(server.account_data(me).0, 401);
    let owners = ctx(&owner);
    let rt = &server._rt;
    assert!(
        !rt.block_on(knotless::account::holds_any(&owners, &did))
            .unwrap()
    );
    assert!(owner.record("dev.knotless.repo", "one").is_null());
    // Forgotten here, but for the takedown.
    let asked = format!(
        "{}?repo=at://{did}/dev.knotless.repo/one",
        server.xrpc("getRepo")
    );
    wait_until(|| browse("GET", &asked, None, None).0 == 404);
    assert!(index.taken_down(&issue).unwrap().is_some());
    // Theirs stands, with what its owner said of it.
    assert_eq!(server.repo(&other.account.did, "theirs")["name"], "theirs");

    // The command line: asked to be forgotten while records remain, the
    // appview refuses. A repository it shares with no one, and a follow,
    // left after its repositories go.
    assert_eq!(
        other.cli(&["repo", "create", &other.url("plain")], b"").0,
        0
    );
    let follow = ["account", "follow", &owner.account.handle];
    assert_eq!(other.cli(&follow, b"").0, 0);
    other.set("KNOTLESS_APPVIEW", &server.base);
    let early = rt.block_on(knotless::account::forget(&ctx(&other)));
    let early = format!("{:#}", early.unwrap_err());
    assert!(early.contains("RecordsRemain"), "{early}");
    let handle = other.account.handle.clone();
    let (code, said) = other.cli(&["account", "delete-data", &handle, "--now"], b"");
    assert_ne!(code, 0, "{said}");
    // It says what there is, and deletes nothing until the handle is typed.
    let (code, said) = other.cli(&["account", "delete-data", &handle], b"someone.else\n");
    assert_ne!(code, 0, "{said}");
    assert!(said.contains("repository theirs, shared with 1"), "{said}");
    assert!(said.contains("repository plain\n"), "{said}");
    assert!(!other.record("dev.knotless.repo", "theirs").is_null());
    let (code, said) = other.cli(&["account", "delete-data", &handle, "--yes"], b"");
    assert_eq!(code, 0, "{said}");
    assert!(said.ends_with("and logged out"), "{said}");
    assert!(other.record("dev.knotless.repo", "theirs").is_null());
    let theirs = format!(
        "{}?repo=at://{}/dev.knotless.repo/theirs",
        server.xrpc("getRepo"),
        other.account.did
    );
    wait_until(|| browse("GET", &theirs, None, None).0 == 404);
}

/// Private repositories go with their spaces, a space a part, those shared
/// named first with how many read them.
#[test]
fn an_account_deletes_its_private_repositories() {
    if !net().spaces {
        return;
    }
    let server = server();
    let owner = person(account());
    let member = person(account());
    let runner = person(account());
    for name in ["hidden", "secret"] {
        let url = owner.url(name);
        assert_eq!(owner.cli(&["repo", "create", &url, "--private"], b"").0, 0);
    }
    // One who pushes too, through the server as gateway, and a runner,
    // which reads it but is not counted.
    let delegate = ["account", "delegate", &owner.account.handle, &server.did];
    assert_eq!(owner.cli(&delegate, b"").0, 0);
    let secret = owner.url("secret");
    let share = ["repo", "share", &secret, &member.account.handle, "--write"];
    assert_eq!(owner.cli(&share, b"").0, 0);
    let runs = ["runner", "add", &secret, &runner.account.handle];
    assert_eq!(owner.cli(&runs, b"").0, 0);
    let cookie = log_in(&server, &owner.account);
    let (status, held) = server.account_data(Some(&cookie));
    assert_eq!(status, 200, "{held}");
    assert_eq!(held["spaces"], 2, "{held}");
    let repos = json!([
        {"name": "hidden", "private": true, "collaborators": 0, "members": 0},
        {"name": "secret", "private": true, "collaborators": 1, "members": 1},
    ]);
    assert_eq!(held["repos"], repos);
    // Its runner reads it, so its settings name it among those who do; the
    // member pushes, so is not.
    let at = format!("at://{}/dev.knotless.repo/secret", owner.account.did);
    let asked = format!("{}?repo={at}", server.xrpc("getRepoSettings"));
    let (status, _, set) = browse("GET", &asked, Some(&cookie), None);
    assert_eq!(status, 200, "{set}");
    let readers = set["readers"].as_array().unwrap();
    let dids: Vec<&Value> = readers.iter().map(|r| &r["did"]).collect();
    assert_eq!(dids, [&json!(runner.account.did)], "{set}");

    let did = owner.account.did.clone();
    let (answers, _) = server.delete_data(&cookie, &did, 1);
    assert_eq!(answers[0], json!({"deleted": 1, "done": false}));
    let owners = ctx(&owner);
    let spaces = server._rt.block_on(async {
        let me = knotless::session::me(&owners).await?;
        knotless::remote::Remote::own_spaces(&owners, &me).await
    });
    assert_eq!(spaces.unwrap(), Some(Vec::new()));
}

/// An organization run from the pages, as `knotless org` runs one: begun
/// with its first invitation, through the server it delegated to from
/// the command line; heard of in the notifications of those it invited,
/// who join, show or hide it, leave and decline; changed by an admin; and
/// ended.
#[test]
fn an_organization_from_the_pages() {
    let server = server();
    let [org, member, third] = [account(), account(), account()];
    let owner = person(org.clone());
    let delegate = ["account", "delegate", &org.handle, &server.did];
    assert_eq!(owner.cli(&delegate, b"").0, 0);
    let (o, m) = (log_in(&server, &org), log_in(&server, &member));
    let (o, m) = (Some(o.as_str()), Some(m.as_str()));
    let viewer = |cookie| {
        let url = server.xrpc("getViewer");
        browse("GET", &url, cookie, None).2
    };
    let roster = |cookie| {
        let url = format!("{}?actor={}", server.xrpc("getOrg"), org.did);
        let (status, _, answer) = browse("GET", &url, cookie, None);
        assert_eq!(status, 200, "{answer}");
        answer
    };
    let standing = |cookie, did: &str| {
        let all = roster(cookie)["members"].as_array().unwrap().clone();
        let found = all.into_iter().find(|v| v["actor"]["did"] == did);
        found.map(|v| (v["role"].clone(), v["standing"].clone()))
    };
    let invite = |cookie, who: &str, role: &str| {
        let input = json!({"org": org.did, "member": who, "role": role});
        server.call("inviteToOrg", cookie, input)
    };
    let with = |visibility: &str| json!({"org": org.did, "visibility": visibility});
    let only = json!({ "org": org.did });
    // The invitations `cookie`'s account has to answer, by who and the
    // role, as its notifications have them.
    let invitations = |cookie| {
        let url = server.xrpc("listNotifications");
        let (status, _, answer) = browse("GET", &url, cookie, None);
        assert_eq!(status, 200, "{answer}");
        let all = answer["notifications"].as_array().unwrap().clone();
        let invites = all.into_iter().filter(|n| n["reason"] == "invite");
        invites
            .map(|n| (n["author"]["did"].clone(), n["role"].clone()))
            .collect::<Vec<_>>()
    };

    // Logged out, nothing; someone else cannot begin it for them.
    assert_eq!(invite(None, &member.handle, "member").0, 401);
    assert_eq!(invite(m, &third.handle, "member").0, 400);
    assert_eq!(viewer(o).get("organization"), None);
    assert_eq!(invite(o, &member.handle, "admin"), (200, json!({})));
    assert_eq!(viewer(o)["organization"], true);
    let invited = (json!("admin"), json!("invited"));
    assert_eq!(standing(m, &member.did), Some(invited));
    assert_eq!(standing(None, &member.did), None);
    // It waits in their notifications until they answer it.
    let waiting = vec![(json!(org.did), json!("admin"))];
    assert_eq!(invitations(m), waiting);

    // Joined in public, anyone sees it.
    assert_eq!(server.call("setOrgMembership", m, with("public")).0, 200);
    assert_eq!(invitations(m), vec![]);
    let public = (json!("admin"), json!("public"));
    assert_eq!(standing(None, &member.did), Some(public));
    if net().spaces {
        assert_eq!(server.call("setOrgMembership", m, with("hidden")).0, 200);
        assert_eq!(standing(None, &member.did), None);
        let hidden = (json!("admin"), json!("hidden"));
        assert_eq!(standing(o, &member.did), Some(hidden));
    }

    // An admin invites and withdraws; the organization changes a role.
    assert_eq!(invite(m, &third.handle, "member").0, 200);
    let third_in = (json!("member"), json!("invited"));
    assert_eq!(standing(o, &third.did), Some(third_in));
    let gone = json!({"org": org.did, "member": third.did});
    assert_eq!(server.call("removeFromOrg", m, gone).0, 200);
    assert_eq!(standing(o, &third.did), None);
    assert_eq!(invite(o, &member.did, "member").0, 200);
    assert_eq!(standing(o, &member.did).unwrap().0, "member");

    // Left, and invited again, declined.
    let (status, left) = server.call("endOrgMembership", m, only.clone());
    assert_eq!((status, left), (200, json!({})));
    assert_eq!(standing(o, &member.did), None);
    assert_eq!(invite(o, &member.handle, "member").0, 200);
    assert_eq!(invitations(m), vec![(json!(org.did), json!("member"))]);
    assert_eq!(server.call("endOrgMembership", m, only).0, 200);
    assert_eq!(standing(o, &member.did), None);
    assert_eq!(invitations(m), vec![]);

    // Ended, it is a plain account again.
    assert_eq!(server.call("deleteOrg", o, json!({})), (200, json!({})));
    assert_eq!(viewer(o).get("organization"), None);
    assert_eq!(roster(None)["organization"], false);
}

/// What a request to `server` as the site at `host` got: its status, where
/// it goes, the cookie it sets, how it may be cached, and its body.
struct Visit {
    status: u16,
    location: String,
    cookie: Option<String>,
    cache: String,
    body: String,
}

fn visit(server: &Server, host: &str, path: &str, cookie: Option<&str>) -> Visit {
    let client = reqwest::blocking::Client::builder()
        .redirect(reqwest::redirect::Policy::none())
        .build()
        .unwrap();
    let mut req = client.get(format!("{}{path}", server.base));
    req = req.header("x-forwarded-host", host);
    if let Some(cookie) = cookie {
        req = req.header("cookie", cookie);
    }
    let res = req.send().unwrap();
    let header = |name: &str| {
        let value = res.headers().get(name);
        value.map(|v| v.to_str().unwrap().to_string())
    };
    let location = header("location").unwrap_or_default();
    let cookie = header("set-cookie").map(|c| c.split(';').next().unwrap().to_string());
    let cache = header("cache-control").unwrap_or_default();
    Visit {
        status: res.status().as_u16(),
        location,
        cookie,
        cache,
        body: res.text().unwrap(),
    }
}

/// A private repository's site, deployed into its space and read by its
/// members alone: sent from its host to the pages for a ticket, which
/// they are given once logged in and proven a member, and let in by the
/// pass the host trades it for. A stranger is given none; a member
/// removed, or logged out, reads it no more.
#[test]
fn private_sites_are_read_by_members() {
    if !net().spaces {
        return;
    }
    let server = server();
    let mut owner = person(account());
    // The appview hears of a deploy, as nothing in a space reaches the
    // firehose.
    owner.set("KNOTLESS_APPVIEW", &server.base);
    let member = person(account());
    let stranger = person(account());
    let url = owner.url("hush");
    assert_eq!(owner.cli(&["repo", "create", &url, "--private"], b"").0, 0);
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let share = |undo: bool| {
        let mut share = vec!["repo", "share", &url, &member.account.handle];
        if undo {
            share.push("--undo");
        }
        owner.cli(&share, b"").0
    };
    assert_eq!(share(false), 0);
    let dist = owner.path("dist");
    std::fs::create_dir_all(dist.join("docs")).unwrap();
    std::fs::write(dist.join("index.html"), b"<h1>hushed</h1>").unwrap();
    std::fs::write(dist.join("docs/index.html"), b"docs").unwrap();
    let dir = dist.to_string_lossy().into_owned();
    let (code, out) = owner.cli(&["repo", "site", &url, &dir], b"");
    assert_eq!(code, 0, "{out}");
    // At a host of its own, named by its key and its owner's plc id.
    let address = out.lines().last().unwrap();
    let host = address
        .strip_prefix("https://")
        .and_then(|a| a.strip_suffix("/"))
        .unwrap()
        .to_string();
    let plc = owner.account.did.strip_prefix("did:plc:").unwrap();
    assert!(
        host.ends_with(&format!("-{plc}.private.knotless.page")),
        "{host}"
    );
    // Its record is in the space, not among the owner's public ones.
    assert!(owner.records("dev.knotless.site").is_empty());
    let (_, live) = owner.cli(&["repo", "site", &url], b"");
    assert!(live.starts_with(&format!("{address}\t2 files\t")), "{live}");

    // No pass: sent to the pages for a ticket, back to the page asked.
    let first = visit(&server, &host, "/docs/?x=1", None);
    assert_eq!(first.status, 302);
    assert_eq!(
        first.location,
        format!(
            "{}/oauth/site?host={host}&to=%2Fdocs%2F%3Fx%3D1",
            server.base
        )
    );
    assert_eq!(first.cache, "private, no-store");
    let tickets = first.location.strip_prefix(&server.base).unwrap();
    // Logged in as no one, the pages ask for a login, and come back.
    let asked = visit(&server, "localhost", tickets, None);
    assert_eq!(asked.status, 302);
    assert!(
        asked.location.starts_with("/?login=%2Foauth%2Fsite%3F"),
        "{}",
        asked.location
    );

    // A member logged in is given a ticket for that host alone.
    let theirs = log_in(&server, &member.account);
    // Their page of its repository shows it.
    let repo = format!("at://{}/dev.knotless.repo/hush", owner.account.did);
    let page = format!("{}?repo={repo}", server.xrpc("getRepo"));
    wait_until(|| {
        let (status, _, answer) = browse("GET", &page, Some(&theirs), None);
        status == 200 && answer["site"]["url"] == address
    });
    let given = visit(&server, "localhost", tickets, Some(&theirs));
    assert_eq!(given.status, 302);
    let enter = format!("http://{host}/.knotless/enter?ticket=");
    assert!(given.location.starts_with(&enter), "{}", given.location);
    let enter = given
        .location
        .strip_prefix(&format!("http://{host}"))
        .unwrap();
    assert!(enter.ends_with("&to=%2Fdocs%2F%3Fx%3D1"), "{enter}");
    // Not taken on another site's host.
    let other_key = if host.starts_with('2') { "3" } else { "2" };
    let elsewhere = format!("{other_key}{}", &host[1..]);
    let wrong = visit(&server, &elsewhere, enter, None);
    assert!(
        wrong.location.contains("/oauth/site?"),
        "{}",
        wrong.location
    );
    assert_eq!(wrong.cookie, None);
    // Traded on its own for a pass, once.
    let entered = visit(&server, &host, enter, None);
    assert_eq!(
        (entered.status, entered.location.as_str()),
        (302, "/docs/?x=1")
    );
    let pass = entered.cookie.unwrap();
    assert!(pass.starts_with("knotless_site="), "{pass}");
    let again = visit(&server, &host, enter, None);
    assert!(
        again.location.contains("/oauth/site?"),
        "{}",
        again.location
    );
    assert_eq!(again.cookie, None);

    // With it, the site, for them alone.
    let page = visit(&server, &host, "/", Some(&pass));
    assert_eq!((page.status, page.body.as_str()), (200, "<h1>hushed</h1>"));
    assert_eq!(page.cache, "private, no-store");
    assert_eq!(visit(&server, &host, "/docs/", Some(&pass)).body, "docs");
    let moved = visit(&server, &host, "/docs", Some(&pass));
    assert_eq!((moved.status, moved.location.as_str()), (302, "docs/"));
    assert_eq!(visit(&server, &host, "/gone.js", Some(&pass)).status, 404);
    assert_eq!(visit(&server, &host, "/%ff", Some(&pass)).status, 404);
    // The pass is the host's alone.
    let other = visit(&server, &elsewhere, "/", Some(&pass));
    assert!(
        other.location.contains("/oauth/site?"),
        "{}",
        other.location
    );

    // A stranger logged in is given no ticket.
    let strangers = log_in(&server, &stranger.account);
    let refused = visit(&server, "localhost", tickets, Some(&strangers));
    assert_eq!(refused.status, 404);
    // Nor is a host that names no private site's.
    let odd = "/oauth/site?host=nobody.knotless.page&to=%2F";
    assert_eq!(visit(&server, "localhost", odd, Some(&theirs)).status, 404);

    // Removed, the member reads it no more once membership is asked
    // again; shared again, they do.
    assert_eq!(share(true), 0);
    server
        .app
        .reprove
        .store(0, std::sync::atomic::Ordering::Relaxed);
    assert_eq!(visit(&server, &host, "/", Some(&pass)).status, 404);
    assert_eq!(
        visit(&server, "localhost", tickets, Some(&theirs)).status,
        404
    );
    assert_eq!(share(false), 0);
    assert_eq!(visit(&server, &host, "/", Some(&pass)).status, 200);

    // Redeployed, the new site is read once the space is synced, as
    // following it hears of the write.
    let space = knotless::space::uri(&owner.account.did, "hush");
    let index = server.app.spaces.clone().unwrap();
    let rt = tokio::runtime::Runtime::new().unwrap();
    let synced = || {
        let c = ctx(&owner);
        rt.block_on(knotless_serve::spaces::sync(&c, &index, &space))
            .unwrap();
    };
    std::fs::write(dist.join("index.html"), b"<h1>again</h1>").unwrap();
    assert_eq!(owner.cli(&["repo", "site", &url, &dir], b"").0, 0);
    synced();
    assert_eq!(
        visit(&server, &host, "/", Some(&pass)).body,
        "<h1>again</h1>"
    );

    // Logged out, their pass no longer holds.
    let logout = format!("{}/oauth/logout", server.base);
    assert_eq!(browse("POST", &logout, Some(&theirs), None).0, 200);
    let out = visit(&server, &host, "/", Some(&pass));
    assert!(out.location.contains("/oauth/site?"), "{}", out.location);

    // Taken down by its owner, it is gone.
    let back = log_in(&server, &member.account);
    let given = visit(&server, "localhost", tickets, Some(&back));
    let enter = given
        .location
        .strip_prefix(&format!("http://{host}"))
        .unwrap();
    let pass = visit(&server, &host, enter, None).cookie.unwrap();
    assert_eq!(visit(&server, &host, "/", Some(&pass)).status, 200);
    assert_eq!(owner.cli(&["repo", "site", &url, "--down"], b"").0, 0);
    synced();
    assert_eq!(visit(&server, &host, "/", Some(&pass)).status, 404);
}