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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
4095 lines · 172.0 KB Raw History
//! The gateway against a reference PDS: owners delegate, collaborators push
//! and commit through it, and commits to one branch are batched.

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

use knotless_serve::gateway::Gateway;
use knotless_serve::usage::Usage;
use knotless_serve::{App, router};
use serde_json::{Value, json};
use std::sync::atomic::Ordering;
use std::sync::{Arc, Barrier};
use std::time::Duration;

#[path = "gateway/accounts.rs"]
mod accounts;
#[path = "gateway/opening.rs"]
mod opening;
#[path = "gateway/org_index.rs"]
mod org_index;
#[path = "gateway/orgs.rs"]
mod orgs;
#[path = "gateway/private_flow.rs"]
mod private_flow;
#[path = "gateway/push.rs"]
mod push;
#[path = "gateway/reviewed.rs"]
mod reviewed;
#[path = "gateway/subscriptions.rs"]
mod subscriptions;
#[path = "gateway/threads.rs"]
mod threads;

struct Server {
    base: String,
    /// Where it keeps its bots' sessions.
    bots: std::path::PathBuf,
    did: String,
    app: Arc<App>,
    /// Where it counts its usage.
    usage: std::path::PathBuf,
    _rt: tokio::runtime::Runtime,
    _proxy: TlsProxy,
    _sandbox: Sandbox,
}

/// A gateway with config and cache of its own, where owners' delegated
/// sessions live.
fn gateway() -> Server {
    gateway_for(None)
}

/// [`gateway`], keeping the sessions of `bot` too, once it logs in.
fn gateway_for(bot: Option<&Account>) -> Server {
    gateway_with(bot, false)
}

/// [`gateway_for`], an appview too if `indexed`: with an index following
/// the PDS's firehose.
fn gateway_with(bot: Option<&Account>, indexed: bool) -> Server {
    gateway_kept(bot, indexed, Duration::ZERO)
}

/// [`gateway_with`], whose mirrors are read again once `ttl` old.
fn gateway_kept(bot: Option<&Account>, indexed: bool, ttl: Duration) -> Server {
    gateway_run(bot, indexed, ttl, "")
}

/// [`gateway_kept`], with `admins` (DIDs, comma-separated) its operators.
fn gateway_run(bot: Option<&Account>, indexed: bool, ttl: Duration, admins: &str) -> Server {
    let mut s = Sandbox::new();
    s.set("KNOTLESS_PASSWORD", "");
    s.set("KNOTLESS_ADMIN_DIDS", admins);
    if let Some(bot) = bot {
        s.set("KNOTLESS_BOT_DIDS", &bot.did);
    }
    let bots = s.path("bots");
    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}");
    let mut gw = Gateway::new(&base).unwrap();
    // Authorization servers reach a gateway, an OAuth client, over https.
    let proxy = tls_proxy(port);
    gw.oauth_url = proxy.url.clone();
    // Short, so that delegating asks more than once.
    gw.wait = Duration::from_millis(100);
    gw.bots = bot.map(|_| bots.clone());
    let did = gw.did.clone();
    // An appview's own pulls and issues are its own to read, as
    // `knotless serve` reads them.
    if indexed {
        s.set("KNOTLESS_APPVIEW", &base);
    }
    let index = indexed.then(|| knotless_serve::index::Index::open(&s.path("index.db")).unwrap());
    let mut app = App::build(ctx(&s), ttl, Some(gw), index);
    let usage = Usage::open(&s.path("usage.db")).unwrap();
    Arc::get_mut(&mut app).unwrap().usage = Some(Arc::new(usage));
    let router = router(app.clone());
    rt.spawn(async move { axum::serve(listener, router).await.unwrap() });
    // As `knotless serve` counts what repositories are written in: at once
    // done, with no index.
    rt.spawn(knotless_serve::languages::follow(app.clone()));
    if indexed {
        let firehose = net().pds.replace("http://", "ws://");
        rt.spawn(knotless_serve::firehose::watch(app.clone(), firehose));
    }
    Server {
        base,
        bots,
        did,
        app,
        usage: s.path("usage.db"),
        _rt: rt,
        _proxy: proxy,
        _sandbox: s,
    }
}

/// Whether the gateway has told `gateway.served` for the owner `did`.
fn served(server: &Server, did: &str) -> bool {
    server.app.usage.as_ref().unwrap().flush().unwrap();
    let db = rusqlite::Connection::open(&server.usage).unwrap();
    let query = "SELECT count(*) FROM account WHERE name = 'gateway.served' AND did = ?1";
    db.query_row(query, [did], |r| r.get::<_, u64>(0)).unwrap() > 0
}

fn web(mut s: Sandbox) -> Sandbox {
    s.set("KNOTLESS_DID_WEB_SCHEME", "http");
    s
}

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

/// `N` people, each a new account logged in: their logins side by side,
/// as each takes the PDS a third of a second at least.
fn people<const N: usize>() -> [Sandbox; N] {
    let accounts: [Account; N] = std::array::from_fn(|_| account());
    std::thread::scope(|s| {
        let logins = accounts.map(|a| s.spawn(move || person(a)));
        logins.map(|l| l.join().unwrap())
    })
}

/// A service token for `s` to call `lxm` on `aud`.
fn token(s: &Sandbox, aud: &str, lxm: &str) -> String {
    let ctx = ctx(s);
    tokio::runtime::Runtime::new().unwrap().block_on(async {
        let id = knotless::identity::resolve(&ctx, &s.account.handle)
            .await
            .unwrap();
        let me = knotless::session::ensure(&ctx, &id).await.unwrap();
        knotless::auth::service_token(&ctx, &me, aud, lxm)
            .await
            .unwrap()
    })
}

fn post(url: &str, token: Option<&str>, body: Vec<u8>, json: bool) -> (u16, String) {
    let client = reqwest::blocking::Client::new();
    let mut req = client.post(url).body(body);
    if json {
        req = req.header("content-type", "application/json");
    }
    if let Some(t) = token {
        req = req.bearer_auth(t);
    }
    let res = req.send().unwrap();
    (res.status().as_u16(), res.text().unwrap())
}

/// `person`, logged in with OAuth rather than a password: scoped to what
/// knotless asks for.
fn oauth_person(account: Account) -> Sandbox {
    let mut s = web(Sandbox::with(account));
    s.set("KNOTLESS_PASSWORD", "");
    s.set("KNOTLESS_BROWSER", &browser(&s.account.clone(), ""));
    let handle = s.account.handle.clone();
    assert_eq!(s.cli(&["login", &handle], b"").0, 0);
    s
}

/// What an OAuth login calls a gateway for, its scope allows: delegating
/// to it, and pushing and committing through it.
#[test]
fn oauth_logins_reach_a_gateway() {
    let gw = gateway();
    let owner = oauth_person(account());
    let collab = oauth_person(account());
    let url = owner.url("scoped");
    let work = owner.work("w", 2);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let (code, out) = owner.cli(
        &["account", "delegate", &owner.account.handle, &gw.did],
        b"",
    );
    assert_eq!(code, 0, "{out}");
    let shared = owner.cli(
        &["repo", "share", &url, &collab.account.handle, "--write"],
        b"",
    );
    assert_eq!(shared.0, 0, "{}", shared.1);
    let clone = collab.path("clone");
    collab.git(collab.dir.path(), &["clone", "-q", &url, "clone"]);
    let sha = collab.commit_file(&clone, "theirs", "from a collaborator\n");
    collab.git(&clone, &["push", "-q", "origin", "main"]);
    let (_, refs) = owner.cli(&["repo", "refs", &url], b"");
    assert!(refs.contains(&format!("{sha}\trefs/heads/main")), "{refs}");
    let committed = collab.cli(
        &[
            "repo", "commit", &url, "-b", "main", "-m", "Scoped", "--put", "x", "-",
        ],
        b"x\n",
    );
    assert_eq!(committed.0, 0, "{}", committed.1);
}

/// Pushes `refspec` from `dir` to its origin, as git would: the error, if
/// the push is refused.
fn refused(s: &Sandbox, dir: &std::path::Path, refspec: &str) -> Option<String> {
    let (ok, _, err) = s.git_raw(dir, &["push", "-q", "origin", refspec]);
    (!ok).then_some(err)
}

#[test]
fn protected_branches_hold_collaborators() {
    let gw = gateway();
    let [owner, collab] = people();
    let url = owner.url("guarded");
    let work = owner.work("w", 2);
    owner.git(&work, &["push", "-q", &url, "main"]);
    assert_eq!(
        owner
            .cli(
                &["account", "delegate", &owner.account.handle, &gw.did],
                b""
            )
            .0,
        0
    );
    let share = ["repo", "share", &url, &collab.account.handle, "--write"];
    assert_eq!(owner.cli(&share, b"").0, 0);

    // Only the owner says which branches are protected.
    let (code, out) = collab.cli(&["branch", "protect", &url, "main"], b"");
    assert_eq!(code, 1, "{out}");
    let missing = owner.url("missing");
    for bad in [
        vec!["branch", "rules", &missing],
        vec!["branch", "protect", &url, "main", "--bogus"],
        vec!["branch", "protect", &url, "main", "--merges", "--undo"],
        vec!["branch", "protect", &url, "main", "--undo"],
    ] {
        assert_eq!(owner.cli(&bad, b"").0, 1, "{bad:?}");
    }
    let (code, out) = owner.cli(&["branch", "protect", &url, "main"], b"");
    assert_eq!((code, out.as_str()), (0, "main\tlinear"));
    assert_eq!(
        collab.cli(&["branch", "rules", &url], b"").1,
        "main\tlinear"
    );

    let clone = collab.path("clone");
    collab.git(collab.dir.path(), &["clone", "-q", &url, "clone"]);
    let base = collab.git(&clone, &["rev-parse", "HEAD"]);
    // A merge commit, of a side branch off where main was.
    collab.git(&clone, &["checkout", "-q", "-b", "side", "HEAD~1"]);
    collab.commit_file(&clone, "side", "side\n");
    collab.git(&clone, &["checkout", "-q", "main"]);
    collab.git(
        &clone,
        &["merge", "-q", "--no-ff", "-m", "merge side", "side"],
    );
    let err = refused(&collab, &clone, "main").unwrap();
    assert!(err.contains("no merge commits"), "{err}");
    // A push that moved nothing served the owner nothing.
    assert!(!served(&gw, &owner.account.did));
    // Nor by way of a branch that took it: what counts is what main gains.
    assert_eq!(refused(&collab, &clone, "main:refs/heads/feature"), None);
    // One that moved a ref did, though a collaborator pushed it.
    assert!(served(&gw, &owner.account.did));
    let err = refused(&collab, &clone, "main").unwrap();
    assert!(err.contains("no merge commits"), "{err}");
    // A new protected branch takes none in all its history.
    owner.cli(&["branch", "protect", &url, "release"], b"");
    let err = refused(&collab, &clone, "main:refs/heads/release").unwrap();
    assert!(err.contains("no merge commits"), "{err}");
    // Nor does a commit, made on a base with a merge in its history.
    let merged = collab.git(&clone, &["rev-parse", "HEAD"]);
    let note = collab.path("note");
    std::fs::write(&note, "n\n").unwrap();
    let path = note.to_string_lossy();
    let on_merge = [
        "repo", "commit", &url, "-b", "release", "-m", "n", "--base", &merged, "--put", "n", &path,
    ];
    let (done, _, err) = collab.knotless(&on_merge, b"");
    assert!(!done && err.contains("no merge commits"), "{err}");
    collab.git(&clone, &["reset", "-q", "--hard", &base]);
    let err = refused(&collab, &clone, "+HEAD~1:main").unwrap();
    assert!(err.contains("only moves forward"), "{err}");
    let err = refused(&collab, &clone, ":main").unwrap();
    assert!(err.contains("not deleted"), "{err}");

    // Forward, one parent at a time, it lands.
    let ahead = collab.commit_file(&clone, "f", "linear\n");
    assert_eq!(refused(&collab, &clone, "main"), None);
    let (_, refs) = owner.cli(&["repo", "refs", &url], b"");
    assert!(
        refs.contains(&format!("{ahead}\trefs/heads/main")),
        "{refs}"
    );

    // The owner writes past the gateway, rules or none.
    owner.git(&work, &["push", "-q", "--force", &url, "main"]);

    // Merges allowed, merge commits land; history still only moves forward.
    let (code, out) = owner.cli(&["branch", "protect", &url, "main", "--merges"], b"");
    assert_eq!((code, out.as_str()), (0, "release\tlinear\nmain\tmerges"));
    collab.git(&clone, &["fetch", "-q", "origin"]);
    collab.git(&clone, &["reset", "-q", "--hard", "origin/main"]);
    collab.git(
        &clone,
        &["merge", "-q", "--no-ff", "-m", "merge side", "side"],
    );
    assert_eq!(refused(&collab, &clone, "main"), None);
    let err = refused(&collab, &clone, "+HEAD~1:main").unwrap();
    assert!(err.contains("only moves forward"), "{err}");
    let (code, out) = owner.cli(&["branch", "protect", &url, "main", "--undo"], b"");
    assert_eq!((code, out.as_str()), (0, "release\tlinear"));
    assert_eq!(refused(&collab, &clone, "+HEAD~1:main"), None);

    // Taking pulls only through the merge queue, it moves for no
    // collaborator, by push or by commit, but the runner that lands it.
    let (code, out) = owner.cli(
        &["branch", "protect", &url, "main", "--merges", "--queue"],
        b"",
    );
    assert_eq!(
        (code, out.as_str()),
        (0, "release\tlinear\nmain\tmerges\tqueue")
    );
    let err = refused(&collab, &clone, "main").unwrap();
    assert!(err.contains("only through the merge queue"), "{err}");
    let commit = [
        "repo", "commit", &url, "-b", "main", "-m", "n", "--put", "n", &path,
    ];
    let (done, _, err) = collab.knotless(&commit, b"");
    assert!(
        !done && err.contains("only through the merge queue"),
        "{err}"
    );
    // Other branches move as ever.
    assert_eq!(refused(&collab, &clone, "main:refs/heads/topic"), None);
    let (code, out) = owner.cli(&["runner", "add", &url, &collab.account.handle], b"");
    assert_eq!(code, 0, "{out}");
    assert_eq!(refused(&collab, &clone, "main"), None);
}

#[test]
fn collaborators_push_and_commit_through_a_gateway() {
    let gw = gateway();
    let [owner, collab, outsider] = people();
    let url = owner.url("shared");
    let work = owner.work("w", 2);
    owner.git(&work, &["push", "-q", &url, "main"]);

    // Sharing for writes needs a gateway to write through.
    let (code, _) = owner.cli(
        &["repo", "share", &url, &collab.account.handle, "--write"],
        b"",
    );
    assert_eq!(code, 1);
    let (code, out) = owner.cli(
        &["account", "delegate", &owner.account.handle, &gw.did],
        b"",
    );
    assert_eq!(code, 0, "{out}");
    assert_eq!(
        owner
            .cli(
                &["repo", "share", &url, &collab.account.handle, "--write"],
                b""
            )
            .0,
        0
    );

    // A collaborator clones, commits, and pushes as git would.
    let clone = collab.path("clone");
    collab.git(collab.dir.path(), &["clone", "-q", &url, "clone"]);
    let sha = collab.commit_file(&clone, "theirs", "from a collaborator\n");
    collab.git(
        &clone,
        &["push", "-q", "origin", "main", "main:refs/heads/feature"],
    );
    let (_, refs) = owner.cli(&["repo", "refs", &url], b"");
    assert!(refs.contains(&format!("{sha}\trefs/heads/main")), "{refs}");
    assert!(
        refs.contains(&format!("{sha}\trefs/heads/feature")),
        "{refs}"
    );
    collab.git(&clone, &["push", "-q", "origin", ":refs/heads/feature"]);
    let (_, refs) = owner.cli(&["repo", "refs", &url], b"");
    assert!(!refs.contains("feature"), "{refs}");
    // The gateway names the collaborator on what it writes for them: the
    // delete of the ref it made, whose record went with it, the move of
    // main, and main itself.
    let them = Some(collab.account.did.clone());
    assert_eq!(
        pusher(&owner, "dev.knotless.claim", "refs/heads/feature"),
        them
    );
    assert_eq!(
        pusher(&owner, "dev.knotless.claim", "refs/heads/main"),
        them
    );
    assert_eq!(pusher(&owner, "dev.knotless.ref", "refs/heads/main"), them);

    // The gateway refreshes its grant itself, as the client it was granted
    // to, once the access token runs out.
    let held = gw
        ._sandbox
        .path("config/sessions")
        .join(format!("{}.json", owner.account.did.replace(':', "_")));
    let mut session: knotless::session::Session = knotless::store::read(&held).unwrap();
    let oauth = session.oauth.as_mut().unwrap();
    assert!(
        oauth
            .client_id
            .as_ref()
            .unwrap()
            .ends_with("/oauth-client-metadata.json")
    );
    oauth.expires_at = 0;
    let used = session.refresh_jwt.clone();
    knotless::store::write(&held, &session, true).unwrap();
    collab.git(&clone, &["push", "-q", "origin", "main:refs/heads/again"]);
    let refreshed: knotless::session::Session = knotless::store::read(&held).unwrap();
    assert_ne!(refreshed.refresh_jwt, used);

    // git's rules hold at the gateway.
    owner.commit_file(&work, "f", "moved on\n");
    owner.git(&work, &["pull", "-q", "--rebase", &url, "main"]);
    owner.git(&work, &["push", "-q", &url, "main"]);
    // The owner's own push names no one.
    assert_eq!(pusher(&owner, "dev.knotless.ref", "refs/heads/main"), None);
    collab.commit_file(&clone, "theirs", "stale\n");
    let (ok, _, err) = collab.git_raw(&clone, &["push", "origin", "main"]);
    assert!(!ok);
    assert!(
        err.contains("non-fast-forward") || err.contains("rejected"),
        "{err}"
    );

    // Commits without a checkout go through it too, and a base that the
    // branch has moved on from conflicts on the paths it touched.
    let note = collab.path("note");
    std::fs::write(&note, "n\n").unwrap();
    let (code, out) = collab.cli(
        &[
            "repo",
            "commit",
            &url,
            "-b",
            "main",
            "-m",
            "note",
            "--put",
            "note",
            &note.to_string_lossy(),
            "--json",
        ],
        b"",
    );
    assert_eq!(code, 0, "{out}");
    let committed: Value = serde_json::from_str(&out).unwrap();
    assert_eq!(pusher(&owner, "dev.knotless.ref", "refs/heads/main"), them);
    let (code, out) = collab.cli(
        &[
            "repo",
            "commit",
            &url,
            "-b",
            "main",
            "-m",
            "late",
            "--put",
            "f",
            &note.to_string_lossy(),
            "--base",
            &sha,
        ],
        b"",
    );
    assert_eq!(code, 1, "f changed since: {out}");
    let (code, out) = collab.cli(
        &[
            "repo",
            "commit",
            &url,
            "-b",
            "main",
            "-m",
            "clean",
            "--put",
            "elsewhere",
            &note.to_string_lossy(),
            "--base",
            committed["sha"].as_str().unwrap(),
        ],
        b"",
    );
    assert_eq!(code, 0, "{out}");
    let (code, out) = collab.cli(
        &[
            "repo",
            "commit",
            &url,
            "-b",
            "main",
            "-m",
            "tidy",
            "--delete",
            "elsewhere",
        ],
        b"",
    );
    assert_eq!(code, 0, "{out}");
    // A path git refuses fails the batch it is in.
    let (code, _) = collab.cli(
        &[
            "repo",
            "commit",
            &url,
            "-b",
            "main",
            "-m",
            "bad",
            "--put",
            ".git/x",
            &note.to_string_lossy(),
        ],
        b"",
    );
    assert_eq!(code, 1);
    // So does a base the owner alone writes against, directly.
    let (code, out) = owner.cli(
        &[
            "repo",
            "commit",
            &url,
            "-b",
            "main",
            "-m",
            "late",
            "--put",
            "note",
            &note.to_string_lossy(),
            "--base",
            &sha,
        ],
        b"",
    );
    assert_eq!(code, 1, "{out}");
    assert!(out.is_empty());

    // Not on the list: refused.
    let elsewhere = outsider.path("clone");
    outsider.git(outsider.dir.path(), &["clone", "-q", &url, "clone"]);
    outsider.commit_file(&elsewhere, "x", "x\n");
    let (ok, _, err) = outsider.git_raw(&elsewhere, &["push", "origin", "main"]);
    assert!(!ok);
    assert!(err.contains("not a collaborator"), "{err}");

    // A repository with no gateway cannot take anyone else's push.
    let lonely = owner.url("lonely");
    owner.git(&work, &["push", "-q", &lonely, "main"]);
    let (ok, _, err) = outsider.git_raw(&elsewhere, &["push", &lonely, "main"]);
    assert!(!ok);
    assert!(err.contains("only its owner can"), "{err}");
    let (code, _) = outsider.cli(
        &[
            "repo", "commit", &lonely, "-b", "main", "-m", "m", "--delete", "f",
        ],
        b"",
    );
    assert_eq!(code, 1);
}

/// The pusher on the newest of `owner`'s records of `collection` for the
/// ref `name`.
fn pusher(owner: &Sandbox, collection: &str, name: &str) -> Option<String> {
    let listed: Value = reqwest::blocking::Client::new()
        .get(format!("{}/xrpc/com.atproto.repo.listRecords", net().pds))
        .query(&[
            ("repo", owner.account.did.as_str()),
            ("collection", collection),
        ])
        .send()
        .and_then(|r| r.json())
        .unwrap();
    let records = listed["records"].as_array().unwrap();
    let newest = records
        .iter()
        .map(|r| &r["value"])
        .filter(|v| v["name"] == name)
        .max_by_key(|v| {
            v["updatedAt"]
                .as_str()
                .or(v["createdAt"].as_str())
                .map(str::to_string)
        })
        .unwrap();
    newest["pusher"].as_str().map(str::to_string)
}

#[test]
fn commits_to_one_branch_are_batched() {
    let gw = gateway();
    let owner = person(account());
    let url = owner.url("busy");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    assert_eq!(
        owner
            .cli(
                &["account", "delegate", &owner.account.handle, &gw.did],
                b""
            )
            .0,
        0
    );
    let collab = person(account());
    assert_eq!(
        owner
            .cli(
                &["repo", "share", &url, &collab.account.handle, "--write"],
                b""
            )
            .0,
        0
    );

    let n = 16;
    let barrier = Arc::new(Barrier::new(n));
    let shared = Arc::new((owner, collab));
    let handles: Vec<_> = (0..n)
        .map(|i| {
            let barrier = barrier.clone();
            let shared = shared.clone();
            let url = url.clone();
            std::thread::spawn(move || {
                let (owner, collab) = &*shared;
                let who = if i % 2 == 0 { owner } else { collab };
                let file = who.path(&format!("in{i}"));
                std::fs::write(&file, format!("{i}\n")).unwrap();
                barrier.wait();
                let mut args = vec![
                    "repo", "commit", &url, "-b", "main", "-m", "agent", "--json",
                ];
                let path = format!("agent{i}");
                let file = file.to_string_lossy().to_string();
                args.extend(["--put", &path, &file]);
                if who.account.did == owner.account.did {
                    args.push("--batch");
                }
                let (code, out) = who.cli(&args, b"");
                assert_eq!(code, 0, "{out}");
                serde_json::from_str::<Value>(&out).unwrap()
            })
        })
        .collect();
    let results: Vec<Value> = handles.into_iter().map(|h| h.join().unwrap()).collect();
    let (owner, _) = &*shared;
    let clone = owner.path("check");
    owner.git(owner.dir.path(), &["clone", "-q", &url, "check"]);
    let count = owner.git(&clone, &["rev-list", "--count", "HEAD"]);
    assert_eq!(count, (n + 1).to_string());
    for i in 0..n {
        assert!(clone.join(format!("agent{i}")).exists());
    }
    assert!(results.iter().all(|r| r["sha"].is_string()));
    // Fewer writes than commits: they arrived together.
    let writes = gw
        .app
        .gateway
        .as_ref()
        .unwrap()
        .writes
        .load(Ordering::Relaxed);
    assert!(writes < n as u64, "{writes} writes for {n} commits");
}

#[test]
fn racing_gateways_rebuild_on_the_moved_branch() {
    let (a, b) = (gateway(), gateway());
    let owner = person(account());
    let url = owner.url("contested");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    for gw in [&a, &b] {
        assert_eq!(
            owner
                .cli(
                    &["account", "delegate", &owner.account.handle, &gw.did],
                    b""
                )
                .0,
            0
        );
    }
    let owner = Arc::new(owner);
    let mut round = 0;
    while a
        .app
        .gateway
        .as_ref()
        .unwrap()
        .rebuilt
        .load(Ordering::Relaxed)
        + b.app
            .gateway
            .as_ref()
            .unwrap()
            .rebuilt
            .load(Ordering::Relaxed)
        == 0
    {
        round += 1;
        assert!(round < 50, "the gateways never raced");
        let barrier = Arc::new(Barrier::new(2));
        let handles: Vec<_> = [&a.did, &b.did]
            .into_iter()
            .enumerate()
            .map(|(i, did)| {
                let (owner, barrier, did) = (owner.clone(), barrier.clone(), did.clone());
                std::thread::spawn(move || {
                    let req = json!({
                        "repo": owner.account.did, "name": "contested", "branch": "main",
                        "message": "race", "changes": [{"path": format!("r{round}-{i}"), "data": {"$bytes": "eA"}}],
                    });
                    let t = token(&owner, &did, "dev.knotless.createCommit");
                    let base = did.strip_prefix("did:web:").unwrap().replace("%3A", ":");
                    barrier.wait();
                    post(&format!("http://{base}/xrpc/dev.knotless.createCommit"), Some(&t), req.to_string().into_bytes(), true)
                })
            })
            .collect();
        for h in handles {
            let (status, body) = h.join().unwrap();
            assert_eq!(status, 200, "{body}");
        }
    }
    let (_, refs) = owner.cli(&["repo", "refs", &url], b"");
    assert!(refs.contains("refs/heads/main"), "{refs}");
}

#[test]
fn private_repositories_through_a_gateway() {
    if !net().spaces {
        eprintln!("skipped: the PDS is not on the spaces alpha");
        return;
    }
    let gw = gateway();
    let [owner, collab] = people();
    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"]);
    assert_eq!(
        owner
            .cli(
                &["account", "delegate", &owner.account.handle, &gw.did],
                b""
            )
            .0,
        0
    );
    assert_eq!(
        owner
            .cli(
                &["repo", "share", &url, &collab.account.handle, "--write"],
                b""
            )
            .0,
        0
    );

    // The collaborator reads it on the pages first, which keeps a mirror
    // of it for them alone.
    let cookie = session_cookie(&gw, &collab.account);
    let at = format!(
        "{}/xrpc/dev.knotless.getRepo?repo=at://{}/dev.knotless.repo/hidden",
        gw.base, owner.account.did
    );
    let (status, .., shown) = looked(&at, Some(&cookie));
    assert_eq!(status, 200, "{shown}");
    let underscored = |did: &str| did.replace(':', "_");
    let theirs = (gw.app.ctx.env.cache_dir.join("web"))
        .join(underscored(&collab.account.did))
        .join("mirrors")
        .join(underscored(&owner.account.did));
    let mut kept = std::fs::read_dir(&theirs)
        .unwrap()
        .map(|e| e.unwrap().path());
    let mirror = kept.find(|p| p.extension().is_some_and(|x| x == "git"));
    let mirror = mirror.expect("a mirror of it, theirs");

    let clone = collab.path("clone");
    collab.git(collab.dir.path(), &["clone", "-q", &url, "clone"]);
    let sha = collab.commit_file(&clone, "theirs", "private work\n");
    collab.git(&clone, &["push", "-q", "origin", "main"]);
    let (_, refs) = owner.cli(&["repo", "refs", &url], b"");
    assert_eq!(refs, format!("{sha}\trefs/heads/main"));
    // What they pushed through the gateway is in their mirror before they
    // look again.
    let has = |git: &std::path::Path| {
        let found = std::process::Command::new("git")
            .args(["cat-file", "-e", &sha])
            .current_dir(git)
            .status();
        found.is_ok_and(|s| s.success())
    };
    wait_until(|| has(&mirror));
    let (_, members) = owner.cli(&["repo", "members", &url], b"");
    assert_eq!(
        members,
        format!(
            "{}\towner\n{}\twrite",
            owner.account.did, collab.account.did
        )
    );

    // The owner on another machine, which never delegated, keeps the
    // gateway the record already names.
    let elsewhere = person(owner.account.clone());
    let third = account();
    assert_eq!(
        elsewhere
            .cli(&["repo", "share", &url, &third.handle, "--write"], b"")
            .0,
        0
    );
}

#[test]
fn gateway_refusals() {
    let gw = gateway();
    let owner = person(account());
    let url = owner.url("guarded");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let push = format!("{}/xrpc/dev.knotless.pushRefs", gw.base);
    let commit = format!("{}/xrpc/dev.knotless.createCommit", gw.base);
    let delegate = format!("{}/xrpc/dev.knotless.putDelegation", gw.base);
    let header = |updates: Value| {
        let mut body = json!({"repo": owner.account.did, "name": "guarded", "updates": updates})
            .to_string()
            .into_bytes();
        body.push(b'\n');
        body
    };

    // No token, or one for another method or service.
    assert_eq!(post(&push, None, header(json!([])), false).0, 401);
    let wrong = token(&owner, &gw.did, "dev.knotless.createCommit");
    assert_eq!(post(&push, Some(&wrong), header(json!([])), false).0, 401);
    let elsewhere = token(&owner, "did:web:elsewhere.test", "dev.knotless.pushRefs");
    assert_eq!(
        post(&push, Some(&elsewhere), header(json!([])), false).0,
        401
    );

    // A refused grant does not delegate; until one is approved, nothing
    // writes.
    let mut refuser = person(owner.account.clone());
    refuser.set("KNOTLESS_BROWSER", &browser(&owner.account, "--reject"));
    let (ok, _, err) = refuser.knotless(
        &["account", "delegate", &owner.account.handle, &gw.did],
        b"",
    );
    assert!(!ok);
    assert!(err.contains("rejected"), "{err}");
    let t = token(&owner, &gw.did, "dev.knotless.pushRefs");
    assert_eq!(post(&push, Some(&t), header(json!([])), false).0, 403);
    // Nobody waits on a delegation nobody started, nor takes one back.
    let t = token(&owner, &gw.did, "dev.knotless.getDelegation");
    let res = reqwest::blocking::Client::new()
        .get(format!(
            "{}/xrpc/dev.knotless.getDelegation?state=nope",
            gw.base
        ))
        .bearer_auth(&t)
        .send()
        .unwrap();
    assert_eq!(res.status().as_u16(), 404);
    let res =
        reqwest::blocking::get(format!("{}/oauth/callback?state=nope&code=x", gw.base)).unwrap();
    assert_eq!(res.status().as_u16(), 400);
    // Started, and the browser comes back with a code that is no good.
    let t = token(&owner, &gw.did, "dev.knotless.putDelegation");
    let (status, body) = post(&delegate, Some(&t), vec![], false);
    assert_eq!(status, 200, "{body}");
    let state = serde_json::from_str::<Value>(&body).unwrap()["state"]
        .as_str()
        .unwrap()
        .to_string();
    let issuer = net().pds.clone();
    let page = reqwest::blocking::get(format!(
        "{}/oauth/callback?state={state}&code=bogus&iss={issuer}",
        gw.base
    ))
    .unwrap()
    .text()
    .unwrap();
    assert!(page.contains("was not delegated"), "{page}");
    // The metadata the PDS reads the gateway's client from.
    let metadata: Value = reqwest::blocking::get(format!("{}/oauth-client-metadata.json", gw.base))
        .unwrap()
        .json()
        .unwrap();
    assert!(
        metadata["client_id"]
            .as_str()
            .unwrap()
            .starts_with("https://")
    );
    assert_eq!(metadata["jwks"]["keys"].as_array().unwrap().len(), 1);
    assert_eq!(
        owner
            .cli(
                &["account", "delegate", &owner.account.handle, &gw.did],
                b""
            )
            .0,
        0
    );
    // Nothing answers for this one, and that one is no gateway.
    let (code, _) = owner.cli(
        &[
            "account",
            "delegate",
            &owner.account.handle,
            "did:web:localhost%3A1",
        ],
        b"",
    );
    assert_eq!(code, 1);
    let (code, _) = owner.cli(
        &["delegate", &owner.account.handle, &owner.account.did],
        b"",
    );
    assert_eq!(code, 1);

    // Malformed pushes.
    let t = token(&owner, &gw.did, "dev.knotless.pushRefs");
    assert_eq!(post(&push, Some(&t), b"no header".to_vec(), false).0, 400);
    let bad = header(json!([{"dst": "refs/heads/x", "src": "main"}]));
    assert_eq!(post(&push, Some(&t), bad, false).0, 400);
    let bad = header(json!([{"dst": "HEAD", "src": null}]));
    assert_eq!(post(&push, Some(&t), bad, false).0, 400);
    let mut garbage = header(json!([]));
    garbage.extend(b"PACK not really");
    assert_eq!(post(&push, Some(&t), garbage, false).0, 400);
    let absent = header(json!([{"dst": "refs/heads/x", "src": "1".repeat(40)}]));
    let (status, body) = post(&push, Some(&t), absent, false);
    assert_eq!(status, 400);
    assert!(body.contains("does not hold"), "{body}");
    let missing = json!({"repo": owner.account.did, "name": "nothing-here", "updates": []});
    let mut body = missing.to_string().into_bytes();
    body.push(b'\n');
    let (status, text) = post(&push, Some(&t), body, false);
    assert_eq!(status, 404, "{text}");
    let mut body = json!({"repo": owner.account.did, "name": "bad name!", "updates": []})
        .to_string()
        .into_bytes();
    body.push(b'\n');
    assert_eq!(post(&push, Some(&t), body, false).0, 400);

    // Malformed commits.
    let t = token(&owner, &gw.did, "dev.knotless.createCommit");
    let empty = json!({"repo": owner.account.did, "name": "guarded", "branch": "", "message": "m", "changes": []});
    assert_eq!(
        post(&commit, Some(&t), empty.to_string().into_bytes(), true).0,
        400
    );
    // One that changes nothing lands nothing.
    let noop = json!({"repo": owner.account.did, "name": "guarded", "branch": "main", "message": "m", "changes": []});
    let (status, body) = post(&commit, Some(&t), noop.to_string().into_bytes(), true);
    assert_eq!(status, 200, "{body}");
    assert_eq!(
        serde_json::from_str::<Value>(&body).unwrap()["changed"],
        false
    );
    // An edit is made in the file as the branch holds it; one whose text
    // is no longer there fails on its own.
    let edit = json!({"repo": owner.account.did, "name": "guarded", "branch": "main", "message": "e",
        "changes": [{"path": "f", "edits": [{"old": "w 0", "new": "w edited"}]}]});
    let (status, body) = post(&commit, Some(&t), edit.to_string().into_bytes(), true);
    assert_eq!(status, 200, "{body}");
    let (status, body) = post(&commit, Some(&t), edit.to_string().into_bytes(), true);
    assert_eq!(status, 409, "{body}");
    assert!(
        body.contains("edit 1 of f: old is not in the file"),
        "{body}"
    );
    // Archived, it takes nothing.
    assert_eq!(owner.cli(&["repo", "archive", &url], b"").0, 0);
    let (status, body) = post(&commit, Some(&t), noop.to_string().into_bytes(), true);
    assert_eq!(status, 403, "{body}");
    assert!(body.contains("guarded is archived"), "{body}");
    assert_eq!(owner.cli(&["repo", "archive", &url, "--undo"], b"").0, 0);

    // The DID document says where to find it.
    let doc: Value = reqwest::blocking::get(format!("{}/.well-known/did.json", gw.base))
        .unwrap()
        .json()
        .unwrap();
    assert_eq!(doc["id"], gw.did);
    assert_eq!(doc["service"][0]["serviceEndpoint"], gw.base);
    assert!(Gateway::new("ftp://x").is_err());
    assert!(Gateway::new("https://").is_err());
    assert!(Gateway::new("https://a/b").is_err());
    assert_eq!(
        Gateway::new("https://knot.example/").unwrap().did,
        "did:web:knot.example"
    );
}

/// A runner the repository names that may not push lands its merge queue
/// through the gateway the owner delegated to, and pushes nothing else:
/// each commit a pull queued squashed onto the one before, and those
/// pulls' branches deleted. What it marks merged counts.
#[test]
fn runners_land_the_merge_queue_through_a_gateway() {
    let gw = gateway_with(None, true);
    let mut owner = person(account());
    let mut runner = person(account());
    owner.set("KNOTLESS_APPVIEW", &gw.base);
    runner.set("KNOTLESS_APPVIEW", &gw.base);
    let ok = |s: &Sandbox, args: &[&str]| {
        let (code, out) = s.cli(args, b"");
        assert_eq!(code, 0, "{args:?}: {out}");
        out.trim().to_string()
    };
    let url = owner.url("queued");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    ok(&owner, &["runner", "add", &url, &runner.account.handle]);
    ok(
        &owner,
        &["account", "delegate", &owner.account.handle, &gw.did],
    );
    ok(
        &owner,
        &["branch", "protect", &url, "main", "--merges", "--queue"],
    );
    for b in ["a", "b", "other"] {
        owner.git(&work, &["checkout", "-q", "-B", b, "main"]);
        owner.commit_file(&work, b, &format!("{b}\n"));
        owner.git(&work, &["push", "-q", &url, b]);
    }
    let a = ok(&owner, &["pull", "create", &url, "main", &url, "a", "a"]);
    let b = ok(&owner, &["pull", "create", &url, "main", &url, "b", "b"]);
    for pull in [&a, &b] {
        ok(&owner, &["pull", "merge", &url, pull, "--queue"]);
    }
    wait_until(|| {
        let line = owner.cli(&["queue", "list", &url], b"").1;
        line.contains(&a) && line.contains(&b)
    });

    // Anything else it pushes is refused.
    let clone = runner.path("clone");
    runner.git(runner.dir.path(), &["clone", "-q", &url, "clone"]);
    runner.git(&clone, &["fetch", "-q", "origin"]);
    let refuses = |refspecs: &[&str], says: &str| {
        let mut args = vec!["push", "-q", "origin"];
        args.extend(refspecs);
        let (pushed, _, err) = runner.git_raw(&clone, &args);
        assert!(!pushed && err.contains(says), "{refspecs:?}: {err}");
    };
    runner.commit_file(&clone, "x", "x\n");
    refuses(&["HEAD:main"], "is no pull queued into main squashed");
    refuses(
        &["HEAD:refs/heads/fresh"],
        "there is no branch fresh to land on",
    );
    refuses(&["+origin/a:main"], "one branch moved forward");
    refuses(&[":refs/heads/other"], "one branch moved forward");
    runner.git(&clone, &["checkout", "-q", "-B", "m", "origin/main"]);
    runner.git(&clone, &["merge", "-q", "--no-ff", "origin/a", "-m", "m"]);
    refuses(&["m:main"], "is not one commit on the one before");
    let squash = |onto: &str, head: &str| {
        let merged = runner.git(&clone, &["merge-tree", "--write-tree", onto, head]);
        let tree = merged.lines().next().unwrap().to_string();
        runner.git(&clone, &["commit-tree", &tree, "-p", onto, "-m", head])
    };
    let landed = squash("origin/main", "origin/a");
    let main = format!("{landed}:refs/heads/main");
    refuses(
        &[&main, ":refs/heads/other"],
        "other is not the branch of a pull landed",
    );
    // Nor does it commit, nor move a branch to where it is.
    let at = runner.git(&clone, &["rev-parse", "origin/main"]);
    let header = json!({"repo": owner.account.did, "name": "queued",
        "updates": [{"dst": "refs/heads/main", "src": at}]});
    let mut body = header.to_string().into_bytes();
    body.push(b'\n');
    let t = token(&runner, &gw.did, "dev.knotless.pushRefs");
    let push = format!("{}/xrpc/dev.knotless.pushRefs", gw.base);
    let (status, said) = post(&push, Some(&t), body, false);
    assert_eq!(status, 403, "{said}");
    assert!(said.contains("is no batch on main"), "{said}");
    let t = token(&runner, &gw.did, "dev.knotless.createCommit");
    let commit = json!({"repo": owner.account.did, "name": "queued", "branch": "main",
        "message": "x", "changes": [{"path": "x", "data": {"$bytes": "eA"}}]});
    let commit_url = format!("{}/xrpc/dev.knotless.createCommit", gw.base);
    let (status, said) = post(&commit_url, Some(&t), commit.to_string().into_bytes(), true);
    assert_eq!(status, 403, "{said}");
    assert!(said.contains("is not a collaborator"), "{said}");

    // The batch lands, as the merge queue lands it, committed as a runner.
    let mut ctx = ctx(&runner);
    ctx.env.signer = knotless::env::Signer::Runner;
    let repo = knotless::url::RepoUrl::parse(&url).unwrap();
    tokio::runtime::Runtime::new().unwrap().block_on(async {
        let t = knotless::forge::Target::open(&ctx, &repo).await.unwrap();
        let entries = t.queue_entries(&ctx).await.unwrap();
        let items = knotless::forge::assemble(&t.record, t.remote.did(), &entries);
        let key = t.reference().key;
        let mirror = knotless::mirror::Mirror::open(&ctx, t.remote.did(), &key)
            .await
            .unwrap();
        mirror.sync(&t.remote, &t.snap).await.unwrap();
        let mut pulls = Vec::new();
        for item in knotless::queue::queued(&items) {
            let head = item.head.as_ref().unwrap();
            let at = knotless::forge::head_of(&ctx, &mirror.git, head).await;
            pulls.push((item.clone(), at.unwrap().unwrap()));
        }
        let me = knotless::session::me(&ctx).await.unwrap();
        let tip = t.snap.find_ref("refs/heads/main").unwrap();
        let base = tip.value.target.clone().unwrap();
        let stack = knotless::queue::stack(&ctx, &mirror.git, &base, &pulls, &me)
            .await
            .unwrap();
        assert_eq!(stack.stacked.len(), 2);
        // Its author wrote it, and knotless, as the runner's account, made it.
        let made = [
            "log",
            "-1",
            "--format=%an|%cn|%ce",
            &stack.stacked[1].commit,
        ];
        let made = mirror.git.text(&made, None).await.unwrap();
        assert_eq!(made, format!("Test|knotless|{}@knotless.invalid", me.did));
        knotless::queue::land(&ctx, &repo, &mirror.git, "main", &stack)
            .await
            .unwrap();
        // Each pull's merge names the batch's tip, whose checks let it land.
        let runner_url = knotless::url::RepoUrl::parse(&format!("knotless://{}/_", me.did));
        let mine = knotless::remote::Remote::connect(&ctx, &runner_url.unwrap())
            .await
            .unwrap();
        let states = mine
            .list_all::<knotless::records::StateRecord>(knotless::records::nsid::STATE)
            .await
            .unwrap();
        let batches: Vec<_> = states
            .iter()
            .filter(|s| s.value.state.as_deref() == Some("merged"))
            .map(|s| s.value.batch.as_deref())
            .collect();
        assert_eq!(batches, [Some(stack.tip()); 2]);
    });
    let refs = ok(&owner, &["repo", "refs", &url]);
    let has = |branch: &str| {
        refs.lines()
            .any(|l| l.ends_with(&format!("\trefs/heads/{branch}")))
    };
    assert!(!has("a") && !has("b") && has("other"), "{refs}");
    for pull in [&a, &b] {
        wait_until(|| ok(&owner, &["pull", "show", &url, pull]).contains("merged pull"));
    }

    // A pull set to merge when its checks pass, into a branch with no
    // queue, it merges as a merge does; nothing else onto that branch.
    for (branch, from) in [("dev", "main"), ("c", "dev")] {
        owner.git(&work, &["checkout", "-q", "-B", branch, from]);
        owner.git(&work, &["push", "-q", &url, branch]);
    }
    owner.commit_file(&work, "c", "c\n");
    owner.git(&work, &["push", "-q", &url, "c"]);
    let c = ok(&owner, &["pull", "create", &url, "dev", &url, "c", "c"]);
    wait_until(|| owner.cli(&["pull", "merge", &url, &c, "--auto"], b"").0 == 0);
    // Another into dev was set and cancelled: it lets nothing through.
    owner.git(&work, &["checkout", "-q", "-B", "d", "dev"]);
    owner.commit_file(&work, "d", "d\n");
    owner.git(&work, &["push", "-q", &url, "d"]);
    let d = ok(&owner, &["pull", "create", &url, "dev", &url, "d", "d"]);
    wait_until(|| owner.cli(&["pull", "merge", &url, &d, "--auto"], b"").0 == 0);
    ok(&owner, &["pull", "merge", &url, &d, "--auto", "--undo"]);
    runner.git(&clone, &["fetch", "-q", "origin"]);
    runner.git(&clone, &["checkout", "-q", "-B", "n", "origin/dev"]);
    runner.commit_file(&clone, "n", "n\n");
    refuses(&["n:dev"], "is no pull queued into dev squashed");
    let merging = knotless::forge::merge(&ctx, &repo, &c, true, Default::default());
    tokio::runtime::Runtime::new()
        .unwrap()
        .block_on(merging)
        .unwrap();
    wait_until(|| ok(&owner, &["pull", "show", &url, &c]).contains("merged pull"));
}

/// Lands, as `runner` does in the merge queue, the pulls queued in the
/// repository at `url`, through the gateway: each squashed onto the one
/// before, and the batch landed. Answers how many it landed.
fn land_queued(runner: &Sandbox, url: &str) -> usize {
    let ctx = ctx(runner);
    let repo = knotless::url::RepoUrl::parse(url).unwrap();
    tokio::runtime::Runtime::new().unwrap().block_on(async {
        let t = knotless::forge::Target::open(&ctx, &repo).await.unwrap();
        let entries = t.queue_entries(&ctx).await.unwrap();
        let items = knotless::forge::assemble(&t.record, t.remote.did(), &entries);
        let key = t.reference().key;
        let mirror = knotless::mirror::Mirror::open(&ctx, t.remote.did(), &key)
            .await
            .unwrap();
        mirror.sync(&t.remote, &t.snap).await.unwrap();
        let mut pulls = Vec::new();
        for item in knotless::queue::queued(&items) {
            let head = item.head.as_ref().unwrap();
            let at = knotless::forge::head_of(&ctx, &mirror.git, head).await;
            pulls.push((item.clone(), at.unwrap().unwrap()));
        }
        let me = knotless::session::me(&ctx).await.unwrap();
        let tip = t.snap.find_ref("refs/heads/main").unwrap();
        let base = tip.value.target.clone().unwrap();
        let stack = knotless::queue::stack(&ctx, &mirror.git, &base, &pulls, &me)
            .await
            .unwrap();
        knotless::queue::land(&ctx, &repo, &mirror.git, "main", &stack)
            .await
            .unwrap();
        stack.stacked.len()
    })
}

/// A private repository's runner that may not push lands its merge queue
/// through the gateway its owner delegated to, which reads the queue in
/// the repository's space as the owner, nobody being logged in there.
/// These need a PDS on the spaces alpha, and pass trivially without one.
#[test]
fn runners_land_a_private_merge_queue_through_a_gateway() {
    if !net().spaces {
        eprintln!("skipped: the PDS is not on the spaces alpha");
        return;
    }
    let gw = gateway_with(None, true);
    let [mut owner, mut runner] = people();
    owner.set("KNOTLESS_APPVIEW", &gw.base);
    runner.set("KNOTLESS_APPVIEW", &gw.base);
    let ok = |s: &Sandbox, args: &[&str]| {
        let (code, out) = s.cli(args, b"");
        assert_eq!(code, 0, "{args:?}: {out}");
        out.trim().to_string()
    };
    let url = owner.url("hushed");
    let handle = runner.account.handle.clone();
    ok(
        &owner,
        &["repo", "create", &url, "--private", "--runner", &handle],
    );
    ok(
        &owner,
        &["account", "delegate", &owner.account.handle, &gw.did],
    );
    ok(
        &owner,
        &["branch", "protect", &url, "main", "--merges", "--queue"],
    );
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    for b in ["a", "b"] {
        owner.git(&work, &["checkout", "-q", "-B", b, "main"]);
        owner.commit_file(&work, b, &format!("{b}\n"));
        owner.git(&work, &["push", "-q", &url, b]);
    }
    let a = ok(&owner, &["pull", "create", &url, "main", &url, "a", "a"]);
    let b = ok(&owner, &["pull", "create", &url, "main", &url, "b", "b"]);
    for pull in [&a, &b] {
        ok(&owner, &["pull", "merge", &url, pull, "--queue"]);
    }
    wait_until(|| {
        let line = owner.cli(&["queue", "list", &url], b"").1;
        line.contains(&a) && line.contains(&b)
    });

    assert_eq!(land_queued(&runner, &url), 2);
    let refs = ok(&owner, &["repo", "refs", &url]);
    let has = |branch: &str| {
        refs.lines()
            .any(|l| l.ends_with(&format!("\trefs/heads/{branch}")))
    };
    assert!(!has("a") && !has("b") && has("main"), "{refs}");
    for pull in [&a, &b] {
        wait_until(|| ok(&owner, &["pull", "show", &url, pull]).contains("merged pull"));
    }
}

#[test]
fn a_server_that_is_not_a_gateway() {
    let s = Sandbox::new();
    let rt = tokio::runtime::Runtime::new().unwrap();
    let listener = rt
        .block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
        .unwrap();
    let base = format!("http://{}", listener.local_addr().unwrap());
    let router = router(App::new(ctx(&s), Duration::ZERO));
    rt.spawn(async move { axum::serve(listener, router).await.unwrap() });
    let res = reqwest::blocking::get(format!("{base}/.well-known/did.json")).unwrap();
    assert_eq!(res.status().as_u16(), 404);
    let (status, _) = post(
        &format!("{base}/xrpc/dev.knotless.pushRefs"),
        Some("x"),
        vec![],
        false,
    );
    assert_eq!(status, 404);
    // Nor has it a DID for a query's service token to be for.
    let res = reqwest::blocking::Client::new()
        .get(format!("{base}/xrpc/dev.knotless.getProfile?actor=x"))
        .bearer_auth("x")
        .send()
        .unwrap();
    assert_eq!(res.status().as_u16(), 401);
}

/// A query knows who asks it from a service token their PDS signed for the
/// server's DID and that query, as atproto's appviews do: sent by an app
/// signed in to the PDS, or by the PDS proxying to the `#knotless_appview`
/// service. A token that does not hold is refused; with none, a query
/// answers as for anyone.
#[test]
fn queries_know_who_asks() {
    let server = gateway();
    let viewer = person(account());
    let did = viewer.account.did.clone();
    let doc: Value = reqwest::blocking::get(format!("{}/.well-known/did.json", server.base))
        .unwrap()
        .json()
        .unwrap();
    let services = doc["service"].as_array().unwrap();
    let appview = services.iter().find(|s| s["id"] == "#knotless_appview");
    assert_eq!(appview.unwrap()["serviceEndpoint"], server.base);

    let get = |base: &str, token: Option<&str>| {
        let mut req = reqwest::blocking::Client::new()
            .get(format!("{base}/xrpc/dev.knotless.getProfile?actor={did}"));
        if let Some(t) = token {
            req = req.bearer_auth(t);
        }
        req.send().unwrap()
    };
    let anyone = get(&server.base, None);
    assert_eq!(anyone.status().as_u16(), 200);
    assert_eq!(anyone.headers()["vary"], "authorization");
    let signed = token(&viewer, &server.did, "dev.knotless.getProfile");
    let theirs = get(&server.base, Some(&signed));
    assert_eq!(theirs.status().as_u16(), 200);
    assert_eq!(theirs.headers()["cache-control"], "private, no-cache");
    assert_eq!(theirs.headers()["vary"], "authorization");
    let other_method = token(&viewer, &server.did, "dev.knotless.getRepo");
    let other_server = token(
        &viewer,
        "did:web:elsewhere.example",
        "dev.knotless.getProfile",
    );
    for wrong in [other_method.as_str(), other_server.as_str(), "x"] {
        let res = get(&server.base, Some(wrong));
        assert_eq!(res.status().as_u16(), 401);
        let failure: Value = res.json().unwrap();
        assert_eq!(failure["error"], "AuthenticationRequired");
    }

    // What the query is handed: the account, or no one.
    let echo = axum::Router::new()
        .route(
            "/xrpc/dev.knotless.getProfile",
            axum::routing::get(
                |axum::Extension(v): axum::Extension<knotless_serve::viewer::Viewer>| async move {
                    v.0.unwrap_or_default()
                },
            ),
        )
        .route_layer(axum::middleware::from_fn_with_state(
            server.app.clone(),
            knotless_serve::viewer::viewer,
        ))
        .with_state(server.app.clone());
    let listener = server
        ._rt
        .block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
        .unwrap();
    let echoed = format!("http://{}", listener.local_addr().unwrap());
    server
        ._rt
        .spawn(async move { axum::serve(listener, echo).await.unwrap() });
    assert_eq!(get(&echoed, Some(&signed)).text().unwrap(), did);
    assert_eq!(get(&echoed, None).text().unwrap(), "");
}

/// A bot logs in through the gateway, as the account KNOTLESS_BOT_DIDS
/// names, and keeps the session in the bots' directory: one of the
/// gateway's client, which reads private repositories as an app password
/// cannot, and refreshes with the key kept beside it.
#[test]
fn bots_log_in_through_the_gateway() {
    let bot = person(account());
    let url = bot.url("watched");
    if net().spaces {
        assert_eq!(bot.cli(&["repo", "create", &url, "--private"], b"").0, 0);
    }
    let work = bot.work("w", 1);
    bot.git(&work, &["push", "-q", &url, "main"]);
    let sha = bot.git(&work, &["rev-parse", "HEAD"]);
    let get = |url: &str| {
        let res = reqwest::blocking::Client::builder()
            .redirect(reqwest::redirect::Policy::none())
            .build()
            .unwrap()
            .get(url)
            .send()
            .unwrap();
        let to = res
            .headers()
            .get("location")
            .map(|l| l.to_str().unwrap().to_string());
        (res.status().as_u16(), to, res.text().unwrap())
    };

    // A gateway that keeps no bots' sessions has no client for them.
    let plain = gateway();
    let (status, _, _) = get(&format!("{}/oauth/bot-client-metadata.json", plain.base));
    assert_eq!(status, 404);

    let gw = gateway_for(Some(&bot.account));
    let (status, _, metadata) = get(&format!("{}/oauth/bot-client-metadata.json", gw.base));
    assert_eq!(status, 200);
    let metadata: Value = serde_json::from_str(&metadata).unwrap();
    assert!(
        metadata["scope"].as_str().unwrap().contains("space:"),
        "{metadata}"
    );
    assert!(
        metadata["redirect_uris"][0]
            .as_str()
            .unwrap()
            .ends_with("/oauth/bot-callback")
    );
    // Only its own bots, and only logins it started.
    let stranger = account();
    let (status, _, _) = get(&format!(
        "{}/oauth/bot?account={}",
        gw.base, stranger.handle
    ));
    assert_eq!(status, 403);
    let (status, _, _) = get(&format!("{}/oauth/bot-callback?state=nope&code=x", gw.base));
    assert_eq!(status, 400);
    // A login started and never finished waits, until abandoned.
    let started = format!("{}/oauth/bot?account={}", gw.base, bot.account.handle);
    assert_eq!(get(&started).0, 303);
    // The operator approves, as the bot's account, in a browser: once
    // refused, which keeps nothing, then approved.
    let approve = |extra: &str| {
        let (status, to, _) = get(&format!(
            "{}/oauth/bot?account={}",
            gw.base, bot.account.handle
        ));
        assert_eq!(status, 303);
        let command = format!("{} {}", browser(&bot.account, extra), to.unwrap());
        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)
        );
    };
    approve("--reject");
    assert!(!gw.bots.join("sessions").exists());
    approve("");

    // The bot, with no password, acts from the bots' directory.
    let mut runner = web(Sandbox::with(bot.account.clone()));
    runner.set("KNOTLESS_PASSWORD", "");
    runner.set("KNOTLESS_CONFIG_DIR", gw.bots.to_str().unwrap());
    let stored = knotless::session::load(&ctx(&runner), &bot.account.did).unwrap();
    assert_eq!(stored.handle, bot.account.handle);
    let oauth = stored.oauth.clone().unwrap();
    assert!(
        oauth
            .client_id
            .unwrap()
            .ends_with("/oauth/bot-client-metadata.json")
    );
    let (_, refs) = runner.cli(&["repo", "refs", &url], b"");
    assert_eq!(refs, format!("{sha}\trefs/heads/main"));
    // And refreshes, signing as the gateway's client.
    let mut expired = stored.clone();
    expired.oauth.as_mut().unwrap().expires_at = 0;
    let bots = ctx(&runner);
    knotless::session::save(&bots, &expired).unwrap();
    let fresh = tokio::runtime::Runtime::new().unwrap().block_on(async {
        let id = knotless::identity::resolve(&bots, &bot.account.did)
            .await
            .unwrap();
        knotless::session::ensure(&bots, &id).await.unwrap()
    });
    assert_ne!(fresh.refresh_jwt, stored.refresh_jwt);
}

/// A request to `base` as a browser on the site sends it: with `cookie`,
/// if any, and from the page at `origin`, if any. Its status, the cookies
/// it sets, and its answer.
fn browse(
    method: &str,
    url: &str,
    cookie: Option<&str>,
    origin: 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(origin) = origin {
        req = req.header("origin", origin);
    }
    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);
    (status, set, answer)
}

/// The `name=value` a set cookie holds.
fn pair(set: &str) -> &str {
    set.split(';').next().unwrap()
}

/// Logs `who` in to the pages of `server`, approving (or with `extra`
/// `--reject`, refusing) in a browser: the cookies the site set on the
/// way back, the session's among them once approved.
fn log_in(server: &Server, who: &Account, extra: &str) -> Vec<String> {
    log_in_beside(server, who, extra, "")
}

/// [`log_in`], in a browser that holds `held`, its cookies `;` between.
fn log_in_beside(server: &Server, who: &Account, extra: &str, held: &str) -> Vec<String> {
    let start = json!({"handle": who.handle, "back": "/somewhere/else"});
    let login = format!("{}/oauth/login", server.base);
    let (status, set, answer) = browse("POST", &login, None, None, Some(start));
    assert_eq!(status, 200, "{answer}");
    let started = pair(&set[0]).to_string();
    assert!(started.starts_with("knotless_login="), "{started}");
    let started = [started.as_str(), held].join(";");
    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)
    );
    String::from_utf8(out.stdout)
        .unwrap()
        .lines()
        .map(str::to_string)
        .collect()
}

/// People log in to the pages, and star repositories there, as the
/// command line does: the server keeps their session, apart from what
/// owners delegate, and the browser holds a cookie naming them.
#[test]
fn logging_in_to_the_pages_and_starring() {
    // Started first, so its index sees the repositories made after.
    let server = gateway_with(None, true);
    let owner = person(account());
    let liked = owner.url("liked");
    let work = owner.work("w", 1);
    owner.commit_file(&work, "main.rs", "fn main() {}\n");
    owner.git(&work, &["push", "-q", &liked, "main"]);
    let mut fan = person(account());
    assert_eq!(fan.cli(&["repo", "fork", &liked, "liked-too"], b"").0, 0);
    let third = person(account());
    let xrpc = |nsid: &str| format!("{}/xrpc/dev.knotless.{nsid}", server.base);
    let viewer = |cookie: Option<&str>| browse("GET", &xrpc("getViewer"), cookie, None, None).2;

    // The pages' own client, asking for all knotless asks for.
    let metadata = format!("{}/oauth/web-client-metadata.json", server.base);
    let (status, _, metadata) = browse("GET", &metadata, None, None, None);
    assert_eq!(status, 200);
    assert!(metadata["scope"].as_str().unwrap().contains("space:"));
    let back = metadata["redirect_uris"][0].as_str().unwrap();
    assert!(back.ends_with("/oauth/web-callback"), "{back}");
    assert_eq!(viewer(None), json!({"login": true}));

    // No account, another site's page, or a login this browser did not
    // start, logs no one in.
    let login = format!("{}/oauth/login", server.base);
    let nobody = json!({"handle": "nobody.invalid"});
    assert_eq!(browse("POST", &login, None, None, Some(nobody)).0, 400);
    let them = json!({"handle": fan.account.handle});
    let away = Some("https://evil.example");
    assert_eq!(browse("POST", &login, None, away, Some(them)).0, 403);
    let callback = format!("{}/oauth/web-callback?state=nope&code=x", server.base);
    let stray = Some("knotless_login=nope");
    assert_eq!(browse("GET", &callback, None, None, None).0, 400);
    assert_eq!(browse("GET", &callback, stray, None, None).0, 400);
    // Refused at the PDS, it goes back logged in as no one.
    let refused = log_in(&server, &fan.account, "--reject");
    assert!(refused.iter().all(|c| !c.starts_with("knotless_session=")));

    let set = log_in(&server, &fan.account, "");
    let session = set
        .iter()
        .find(|c| c.starts_with("knotless_session="))
        .unwrap();
    assert!(session.contains("HttpOnly") && session.contains("SameSite=Lax"));
    let cookie = pair(session).to_string();
    let me = Some(cookie.as_str());
    let shown = viewer(me);
    assert_eq!(shown["actor"]["did"], fan.account.did);
    assert_eq!(shown["actor"]["handle"], fan.account.handle);
    // Its session is the pages', not a delegation to the gateway.
    let pages = server._sandbox.path("config/web");
    assert!(pages.exists(), "{}", pages.display());
    let delegated = format!("config/sessions/{}.json", fan.account.did.replace(':', "_"));
    assert!(!server._sandbox.path(&delegated).exists());

    // Starring, as the account logged in.
    let repo = format!("at://{}/dev.knotless.repo/liked", owner.account.did);
    let get_repo = |cookie: Option<&str>| {
        let url = format!("{}?repo={repo}", xrpc("getRepo"));
        browse("GET", &url, cookie, None, None).2
    };
    assert_eq!(get_repo(me)["starCount"], 0);
    assert_eq!(get_repo(me)["viewer"], json!({}));
    let star = |nsid: &str, cookie: Option<&str>, origin: Option<&str>, repo: &str| {
        browse(
            "POST",
            &xrpc(nsid),
            cookie,
            origin,
            Some(json!({"repo": repo})),
        )
    };
    let (status, _, starred) = star("starRepo", me, Some(&server.base), &repo);
    assert_eq!(status, 200, "{starred}");
    assert_eq!(starred["starCount"], 1);
    let uri = starred["star"].as_str().unwrap().to_string();
    assert!(uri.starts_with(&format!("at://{}/dev.knotless.star/", fan.account.did)));
    let records = fan.records("dev.knotless.star");
    assert_eq!(records.len(), 1);
    assert_eq!(records[0].1["subject"]["uri"], repo);
    // Again, the star there is.
    assert_eq!(star("starRepo", me, None, &repo).2["star"], uri.as_str());
    assert_eq!(get_repo(me)["starCount"], 1);
    assert_eq!(get_repo(me)["viewer"]["star"], uri.as_str());
    assert_eq!(get_repo(None)["starCount"], 1);
    assert_eq!(get_repo(None).get("viewer"), None);
    let theirs = format!("at://{}/dev.knotless.repo/liked-too", fan.account.did);
    assert_eq!(star("starRepo", me, None, &theirs).0, 200);

    // Only from this site's pages, as someone logged in to them, and only
    // a repository.
    assert_eq!(star("starRepo", None, None, &repo).0, 401);
    // A service token names someone, even one logged in here, but writes
    // nothing with their session.
    let service = token(&fan, &server.did, "dev.knotless.starRepo");
    let body = serde_json::to_vec(&json!({"repo": repo})).unwrap();
    assert_eq!(post(&xrpc("starRepo"), Some(&service), body, true).0, 401);
    assert_eq!(star("starRepo", me, away, &repo).0, 403);
    assert_eq!(star("starRepo", me, None, "at://nope").0, 400);
    if net().spaces {
        let hidden = owner.url("hidden");
        assert_eq!(
            owner.cli(&["repo", "create", &hidden, "--private"], b"").0,
            0
        );
        let private = format!("at://{}/dev.knotless.repo/hidden", owner.account.did);
        assert_eq!(star("starRepo", me, None, &private).0, 404);
    }

    // What the account starred, as the index has it, the latest first.
    let stars = format!("{}?actor={}", xrpc("getActorStars"), fan.account.handle);
    wait_until(|| {
        browse("GET", &stars, None, None, None).2["repos"]
            .as_array()
            .map_or(0, Vec::len)
            == 2
    });
    let listed = browse("GET", &stars, None, None, None).2;
    assert_eq!(listed["repos"][0]["name"], "liked-too");
    assert_eq!(listed["repos"][0]["forkOf"]["name"], "liked");
    assert_eq!(listed["repos"][1]["owner"]["did"], owner.account.did);
    // Each with its stars and forks, and what it is written in: counted
    // in the background as a list first asks, never waited on.
    let spoken =
        || browse("GET", &stars, None, None, None).2["repos"][1]["language"]["name"].clone();
    wait_until(|| spoken() == "Rust");
    // And counted again as its default branch moves.
    owner.commit_file(&work, "main.py", &"print()\n".repeat(100));
    owner.git(&work, &["push", "-q", &liked, "main"]);
    wait_until(|| spoken() == "Python");
    assert_eq!(listed["repos"][1]["starCount"], 1);
    assert_eq!(listed["repos"][1]["forkCount"], 1);
    assert_eq!(listed["repos"][0]["forkCount"], 0);
    let unknown = format!("{}?actor=nobody.invalid", xrpc("getActorStars"));
    assert_eq!(browse("GET", &unknown, None, None, None).0, 400);
    let plain = gateway();
    let unindexed = format!(
        "{}/xrpc/dev.knotless.getActorStars?actor={}",
        plain.base, fan.account.did
    );
    assert_eq!(browse("GET", &unindexed, None, None, None).0, 404);

    // Unstarring takes every star of theirs naming it back.
    let (status, _, unstarred) = star("unstarRepo", me, None, &repo);
    assert_eq!(status, 200, "{unstarred}");
    assert_eq!(unstarred, json!({"starCount": 0}));
    assert_eq!(
        star("unstarRepo", me, None, &repo).2,
        json!({"starCount": 0})
    );
    assert_eq!(fan.records("dev.knotless.star").len(), 1);

    // Following, as the account logged in.
    let profile = |of: &str, cookie: Option<&str>| {
        let url = format!("{}?actor={of}", xrpc("getProfile"));
        browse("GET", &url, cookie, None, None).2
    };
    let them = owner.account.did.as_str();
    assert_eq!(profile(them, me)["followersCount"], 0);
    assert_eq!(profile(them, me)["viewer"], json!({}));
    let follow = |nsid: &str, cookie: Option<&str>, subject: &str| {
        let body = Some(json!({ "subject": subject }));
        browse("POST", &xrpc(nsid), cookie, None, body)
    };
    let (status, _, followed) = follow("followActor", me, them);
    assert_eq!(status, 200, "{followed}");
    assert_eq!(followed["followersCount"], 1);
    let uri = followed["following"].as_str().unwrap().to_string();
    assert!(uri.starts_with(&format!("at://{}/dev.knotless.follow/", fan.account.did)));
    // Again, the follow there is.
    assert_eq!(follow("followActor", me, them).2["following"], uri.as_str());
    assert_eq!(fan.records("dev.knotless.follow").len(), 1);
    assert_eq!(profile(them, me)["viewer"]["following"], uri.as_str());
    assert_eq!(profile(them, None)["followersCount"], 1);
    assert_eq!(profile(them, None).get("viewer"), None);
    assert_eq!(profile(&fan.account.did, None)["followsCount"], 1);
    assert_eq!(follow("followActor", me, &third.account.did).0, 200);
    // Not itself, nor no one, and only as someone logged in.
    assert_eq!(follow("followActor", me, &fan.account.did).0, 400);
    assert_eq!(follow("followActor", me, "nobody.invalid").0, 400);
    assert_eq!(follow("followActor", None, them).0, 401);

    // Whom it follows, a page at a time, the latest first; and who
    // follows whom.
    let follows = |cursor: &str| {
        let url = format!(
            "{}?actor={}&limit=1{cursor}",
            xrpc("getFollows"),
            fan.account.handle
        );
        browse("GET", &url, None, None, None).2
    };
    let first = follows("");
    assert_eq!(first["actors"][0]["did"], third.account.did);
    let cursor = first["cursor"].as_str().unwrap().replace(' ', "%20");
    let next = follows(&format!("&cursor={cursor}"));
    assert_eq!(next["actors"][0]["did"], owner.account.did);
    assert_eq!(next.get("cursor"), None);
    let followers = format!("{}?actor={them}", xrpc("getFollowers"));
    let followers = browse("GET", &followers, None, None, None).2;
    assert_eq!(followers["actors"][0]["handle"], fan.account.handle);
    let unknown = format!("{}?actor=nobody.invalid", xrpc("getFollowers"));
    assert_eq!(browse("GET", &unknown, None, None, None).0, 400);
    let unindexed = format!("{}/xrpc/dev.knotless.getFollows?actor={them}", plain.base);
    assert_eq!(browse("GET", &unindexed, None, None, None).0, 404);
    // The command line asks the appview who follows an account.
    fan.set("KNOTLESS_APPVIEW", &server.base);
    let (code, out) = fan.cli(&["account", "followers", &owner.account.handle], b"");
    assert_eq!((code, out), (0, fan.account.handle.clone()));

    // The feed of whom it follows: what they and it did, its star among
    // that, and what is done on its repositories; and no one else's. Of
    // everyone's, no stars; and only someone logged in follows anyone.
    let feed = |query: &str, cookie: Option<&str>| {
        let url = format!("{}?limit=100{query}", xrpc("getFeed"));
        browse("GET", &url, cookie, None, None)
    };
    let events = |answer: &Value| answer["events"].as_array().unwrap().clone();
    let stars = |answer: &Value| {
        let found = events(answer).into_iter();
        let found = found.filter(|e| e["action"]["$type"] == "dev.knotless.getFeed#starred");
        found
            .map(|e| e["action"]["subject"].clone())
            .collect::<Vec<_>>()
    };
    wait_until(|| stars(&feed("&filter=following", me).2).contains(&json!(theirs)));
    let (status, _, followed) = feed("&filter=following", me);
    assert_eq!(status, 200, "{followed}");
    let ours = [&fan.account.did, &owner.account.did, &third.account.did];
    for e in events(&followed) {
        let (who, whose) = (&e["actor"]["did"], &e["repo"]["did"]);
        let known = ours.iter().any(|did| who == did.as_str());
        assert!(known || whose == fan.account.did.as_str(), "{e}");
    }
    let by = |did: &str| events(&followed).iter().any(|e| e["actor"]["did"] == did);
    assert!(by(&owner.account.did), "{followed}");
    // A page of it cut short, its star among what is cut, goes on after.
    let url = format!("{}?limit=1&filter=following", xrpc("getFeed"));
    let (status, _, one) = browse("GET", &url, me, None, None);
    assert_eq!(status, 200, "{one}");
    assert!(
        events(&one).len() <= 1 && one["cursor"].is_string(),
        "{one}"
    );
    let theirs_alone = format!("&actor={}", fan.account.did);
    assert!(stars(&feed(&theirs_alone, None).2).contains(&json!(theirs)));
    assert_eq!(stars(&feed("", me).2), Vec::<Value>::new());
    // Its stars alone, of whom it follows, are stars and nothing else.
    let starred = feed("&filter=following&kind=stars", me).2;
    assert!(stars(&starred).contains(&json!(theirs)), "{starred}");
    assert_eq!(stars(&starred).len(), events(&starred).len(), "{starred}");
    assert_eq!(feed("&filter=following", None).0, 401);

    // Unfollowing takes every follow of theirs naming it back.
    assert_eq!(
        follow("unfollowActor", me, them).2,
        json!({"followersCount": 0})
    );
    assert_eq!(fan.records("dev.knotless.follow").len(), 1);

    // What others did, for the account asking: the one it follows hears of
    // it, until it says it saw it.
    let asked = |nsid: &str, of: &Sandbox, at: &Server, query: &str| {
        let url = format!("{}/xrpc/dev.knotless.{nsid}{query}", at.base);
        let token = token(of, &at.did, &format!("dev.knotless.{nsid}"));
        let res = reqwest::blocking::Client::new()
            .get(url)
            .bearer_auth(token)
            .send()
            .unwrap();
        (res.status().as_u16(), res.json::<Value>().unwrap())
    };
    let (status, heard) = asked("listNotifications", &third, &server, "?limit=10");
    assert_eq!(status, 200, "{heard}");
    let first = &heard["notifications"][0];
    assert_eq!(
        (&first["reason"], &first["isRead"]),
        (&json!("follow"), &json!(false))
    );
    assert_eq!(first["author"]["did"], fan.account.did);
    assert_eq!(heard.get("seenAt"), None);
    let unread = asked("getUnreadCount", &third, &server, "");
    assert_eq!(unread, (200, json!({"count": 1})));
    let at = first["createdAt"].as_str().unwrap().to_string();
    let seen = |at: &str, token: Option<&str>| {
        let body = serde_json::to_vec(&json!({ "seenAt": at })).unwrap();
        post(&xrpc("updateSeen"), token, body, true)
    };
    let saw = token(&third, &server.did, "dev.knotless.updateSeen");
    assert_eq!(seen(&at, Some(&saw)).0, 200);
    // An earlier time leaves the later one.
    let (_, kept) = seen("2000-01-01T00:00:00Z", Some(&saw));
    assert_eq!(
        serde_json::from_str::<Value>(&kept).unwrap()["seenAt"],
        at.as_str()
    );
    assert_eq!(seen("yesterday", Some(&saw)).0, 400);
    let (_, heard) = asked("listNotifications", &third, &server, "");
    assert_eq!(heard["notifications"][0]["isRead"], true);
    assert_eq!(heard["seenAt"], at.as_str());
    assert_eq!(asked("getUnreadCount", &third, &server, "").1["count"], 0);
    // Only as someone, from this site's pages or with a token, and only
    // where there is an index.
    let list = xrpc("listNotifications");
    assert_eq!(browse("GET", &list, None, None, None).0, 401);
    assert_eq!(browse("GET", &list, me, None, None).0, 200);
    let body = Some(json!({ "seenAt": at }));
    assert_eq!(browse("POST", &xrpc("updateSeen"), me, away, body).0, 403);
    assert_eq!(asked("listNotifications", &third, &plain, "").0, 404);

    // Logging out ends the session, and every cookie signed before.
    let logout = format!("{}/oauth/logout", server.base);
    assert_eq!(browse("POST", &logout, me, away, None).0, 403);
    let (status, set, _) = browse("POST", &logout, me, None, None);
    assert_eq!(status, 200);
    assert!(set[0].starts_with("knotless_session=;"), "{set:?}");
    assert_eq!(viewer(me), json!({"login": true}));
    assert_eq!(star("starRepo", me, None, &repo).0, 401);
    assert_eq!(browse("POST", &logout, None, None, None).0, 200);
    // A server that is no gateway has no pages to log in to.
    let site = serve_plain();
    for (method, path) in [
        ("GET", "/oauth/web-client-metadata.json"),
        ("POST", "/oauth/logout"),
        ("GET", "/oauth/web-callback?state=x"),
    ] {
        let url = format!("{site}{path}");
        assert_eq!(browse(method, &url, me, None, None).0, 404, "{path}");
    }
    let start = json!({"handle": fan.account.handle});
    assert_eq!(
        browse(
            "POST",
            &format!("{site}/oauth/login"),
            None,
            None,
            Some(start)
        )
        .0,
        404
    );
    let anyone = browse(
        "GET",
        &format!("{site}/xrpc/dev.knotless.getViewer"),
        me,
        None,
        None,
    )
    .2;
    assert_eq!(anyone, json!({"login": false}));
}

/// From a pull's page, its owner sees whether it can merge and lands it,
/// queues it, retargets it and runs its checks, and names the runners on
/// the repository's settings, through the same code as the command line.
#[test]
fn landing_pulls_from_the_pages() {
    // Started first, so its index sees the repositories made after.
    let server = gateway_with(None, true);
    let mut owner = person(account());
    owner.set("KNOTLESS_APPVIEW", &server.base);
    let ci = person(account());
    let url = owner.url("landed");
    let work = owner.work("w", 1);
    std::fs::create_dir_all(work.join(".knotless/workflows")).unwrap();
    let on_pull = "on: {pull: {}}\nsteps: [{run: 'true'}]\n";
    owner.commit_file(&work, ".knotless/workflows/ci.yml", on_pull);
    owner.git(&work, &["push", "-q", &url, "main"]);
    owner.git(&work, &["push", "-q", &url, "main:next"]);
    owner.git(&work, &["checkout", "-q", "-b", "x"]);
    let head = owner.commit_file(&work, "x", "x\n");
    owner.git(&work, &["push", "-q", &url, "x"]);
    let (code, issue) = owner.cli(&["issue", "create", &url, "Broken"], b"");
    assert_eq!(code, 0, "{issue}");
    let fixes = format!("Fixes {issue}");
    let opened = ["pull", "create", &url, "main", &url, "x", "X", "-m", &fixes];
    let (code, pull) = owner.cli(&opened, b"");
    assert_eq!(code, 0, "{pull}");
    let repo = format!("at://{}/dev.knotless.repo/landed", owner.account.did);
    let xrpc = |nsid: &str| format!("{}/xrpc/dev.knotless.{nsid}", server.base);
    let mine = session_cookie(&server, &owner.account);
    let theirs = session_cookie(&server, &ci.account);
    let (me, them) = (Some(mine.as_str()), Some(theirs.as_str()));
    let ask = |nsid: &str, query: &str, cookie: Option<&str>| {
        browse(
            "GET",
            &format!("{}?{query}", xrpc(nsid)),
            cookie,
            None,
            None,
        )
    };
    let write = |nsid: &str, cookie: Option<&str>, body: Value| {
        browse("POST", &xrpc(nsid), cookie, None, Some(body))
    };
    let of_pull = format!("repo={repo}&pull={pull}");
    let status = |cookie| ask("getMergeStatus", &of_pull, cookie);
    let thread = || ask("getThread", &format!("repo={repo}&uri={pull}"), None).2;

    // Only someone asks whether it can merge. It names no runners, so
    // nothing waits for its checks: it merges with a merge commit, and takes x
    // along.
    assert_eq!(status(None).0, 401);
    // One there is none of, read again from its author's repo, is none.
    let nothing = format!("repo={repo}&pull={pull}x");
    assert_eq!(ask("getMergeStatus", &nothing, me).0, 404);
    let (code, _, said) = status(me);
    assert_eq!(code, 200, "{said}");
    let may = json!({"merge": true, "force": true, "queue": false, "autoMerge": false, "base": true, "head": true, "rerun": false});
    assert_eq!(
        (&said["state"], &said["checks"], &said["method"]),
        (&json!("clean"), &json!("none"), &json!("merge")),
        "{said}"
    );
    assert_eq!(
        (&said["deletes"], &said["head"]),
        (&json!("x"), &json!(head))
    );
    assert_eq!(said["may"], may);
    // Anyone else may do nothing to it.
    let none = json!({"merge": false, "force": false, "queue": false, "autoMerge": false, "base": false, "head": false, "rerun": false});
    let theirs = status(them).2;
    assert_eq!(theirs["may"], none);
    // And says why it would not merge for them.
    let why = theirs["reason"].as_str().unwrap();
    assert!(
        why.starts_with("you may not merge it: only the owner"),
        "{why}"
    );

    // The workflows at a ref, and what runs each there.
    let listed = ask("listWorkflows", &format!("repo={repo}"), None).2;
    let ci_yml = json!({"name": "ci", "push": false, "pull": true, "asked": false});
    assert_eq!(
        listed,
        json!({"ref": "refs/heads/main", "workflows": [ci_yml]})
    );
    let nowhere = ask("listWorkflows", &format!("repo={repo}&ref=nope"), None);
    assert_eq!(nowhere.0, 404, "{:?}", nowhere.2);

    // Its owner names a runner; no one else can.
    let runners = |cookie| ask("listRunners", &format!("repo={repo}"), cookie).2;
    assert_eq!(runners(None), json!({"runners": []}));
    let runner = json!({"repo": repo, "runner": ci.account.handle});
    assert_eq!(write("addRunner", them, runner.clone()).0, 403);
    assert_eq!(write("addRunner", None, runner.clone()).0, 401);
    let (code, _, named) = write("addRunner", me, runner.clone());
    assert_eq!(code, 200, "{named}");
    assert_eq!(named["runners"][0]["did"], ci.account.did);
    assert_eq!(runners(None)["runners"][0]["handle"], ci.account.handle);

    // Now its checks are waited for, and none has run.
    let said = status(me).2;
    assert_eq!(
        (&said["state"], &said["checks"]),
        (&json!("blocked"), &json!("pending"))
    );
    assert!(
        said["reason"]
            .as_str()
            .unwrap()
            .contains("none has run yet"),
        "{said}"
    );
    assert_eq!(
        (&said["may"]["queue"], &said["may"]["rerun"]),
        (&json!(false), &json!(true))
    );

    // Its owner asks the runners to run them again, or one workflow at a
    // ref, with variables.
    let again = json!({"repo": repo, "pull": pull, "workflow": "ci"});
    let (code, _, asked) = write("requestRerun", me, again.clone());
    assert_eq!(code, 200, "{asked}");
    let rkey = asked["uri"].as_str().unwrap().rsplit('/').next().unwrap();
    assert_eq!(
        owner.record("dev.knotless.rerun", rkey)["pull"],
        pull.as_str()
    );
    let at_ref = json!({"repo": repo, "ref": "main", "workflow": "ci", "env": [{"name": "A", "value": "b"}]});
    let asked = write("requestRerun", me, at_ref).2;
    let rkey = asked["uri"].as_str().unwrap().rsplit('/').next().unwrap();
    let rerun = owner.record("dev.knotless.rerun", rkey);
    assert_eq!(
        (&rerun["ref"], &rerun["env"][0]["name"]),
        (&json!("refs/heads/main"), &json!("A"))
    );
    assert_eq!(write("requestRerun", them, again).0, 400);

    // Its runner files a check that passed at its head on main, which the
    // box reads from the index once it hears of it.
    let key = owner.record("dev.knotless.repo", "landed")["key"].clone();
    let base = owner.git(&work, &["rev-parse", "main"]);
    let key_str = key.as_str().unwrap();
    let rkey = knotless::keys::check_rkey(
        &owner.account.did,
        key_str,
        &pull,
        &head,
        Some(base.as_str()),
        "ci",
    );
    let check = json!({
        "$type": "dev.knotless.check", "repo": {"uri": repo, "key": key}, "ref": "refs/heads/x",
        "pull": pull, "commit": head, "baseCommit": base, "workflow": "ci", "status": "success",
        "startedAt": "2026-10-31T00:00:00.000Z",
    });
    ci.put_record("dev.knotless.check", &rkey, check);
    wait_until(|| status(me).2["checks"] == "passed");
    assert_eq!(status(me).2["state"], "clean");

    // Its runner may not push, so lands the queue through this server,
    // which does not write for its owner yet: queueing it would land
    // nothing, and is refused, until they delegate.
    let one = json!({"repo": repo, "pull": pull});
    let rule = json!({"repo": repo, "branch": "main", "merges": true, "queue": true});
    assert_eq!(write("protectBranch", me, rule).0, 200);
    assert_eq!(status(me).2["may"]["queue"], true);
    assert_eq!(status(me).2["queueBlocked"], "undelegated");
    let viewer = || ask("getViewer", "", me).2;
    assert!(viewer().get("delegated").is_none(), "{}", viewer());
    let (code, _, refused) = write("queuePull", me, one.clone());
    assert_eq!(code, 400, "{refused}");
    let why = refused["message"].as_str().unwrap();
    assert!(why.contains("once its owner delegates writes"), "{why}");
    let (code, out) = owner.cli(
        &["account", "delegate", &owner.account.handle, &server.did],
        b"",
    );
    assert_eq!(code, 0, "{out}");
    assert_eq!(viewer()["delegated"], true);
    assert!(status(me).2.get("queueBlocked").is_none());
    // Its record naming this gateway changes nothing: it still lands here.
    let plain = owner.record("dev.knotless.repo", "landed");
    let mut named = plain.clone();
    named["gateway"] = json!(server.did);
    owner.put_record("dev.knotless.repo", "landed", named);
    let settings = || ask("getRepoSettings", &format!("repo={repo}"), me).2;
    wait_until(|| settings()["gateway"] == server.did.as_str());
    assert!(status(me).2.get("queueBlocked").is_none());
    owner.put_record("dev.knotless.repo", "landed", plain);
    wait_until(|| settings().get("gateway").is_none());

    // Queued, and taken out, as the page shows at once.
    let (code, _, place) = write("queuePull", me, one.clone());
    assert_eq!(code, 200, "{place}");
    assert_eq!(place, json!({"base": "main", "position": 1, "of": 1}));
    assert_eq!(thread()["queue"]["state"], "queued");
    assert_eq!(write("queuePull", them, one.clone()).0, 400);
    // Its pages lead to the queue while it waits there, rule or no rule,
    // and it can still leave, once it is gone they do not.
    let queues = || ask("getRepo", &format!("repo={repo}"), None).2["mergeQueue"].clone();
    assert_eq!(queues(), true);
    let main = json!({"repo": repo, "branch": "main"});
    assert_eq!(write("unprotectBranch", me, main).0, 200);
    assert_eq!(queues(), true);
    assert_eq!(
        write("leaveQueue", me, one.clone()),
        (200, Vec::new(), json!({}))
    );
    assert!(thread().get("queue").is_none(), "{}", thread());
    assert_eq!(queues(), Value::Null);
    assert_eq!(write("leaveQueue", me, one.clone()).0, 400);

    // Into a branch that takes pulls only through the queue, the page
    // queues it: a merge from there is refused, saying why.
    let rule = json!({"repo": repo, "branch": "main", "queue": true});
    let (code, _, rules) = write("protectBranch", me, rule);
    assert_eq!(code, 200, "{rules}");
    let held = json!([{"branch": "main", "history": "linear", "queue": true}]);
    assert_eq!(rules["rules"], held);
    assert_eq!(status(me).2["queueOnly"], true);
    let (code, _, refused) = write("mergePull", me, one.clone());
    let why = refused["message"].as_str().unwrap_or_default();
    assert!(
        code == 400 && why.contains("only through the merge queue"),
        "{refused}"
    );
    let main = json!({"repo": repo, "branch": "main"});
    let (code, _, rules) = write("unprotectBranch", me, main);
    assert_eq!((code, rules), (200, json!({"rules": []})));
    assert!(status(me).2.get("queueOnly").is_none());

    // Pointed at another branch, and back.
    let next = json!({"repo": repo, "pull": pull, "base": "next"});
    assert_eq!(write("retargetPull", me, next).2, json!({}));
    assert_eq!(thread()["base"], "next");
    let back = json!({"repo": repo, "pull": pull, "base": "main"});
    assert_eq!(write("retargetPull", me, back).2, json!({}));
    let nowhere = json!({"repo": repo, "pull": pull, "base": "nope"});
    assert_eq!(write("retargetPull", me, nowhere).0, 400);

    // Set to merge when its checks pass, by its owner alone: the page says
    // by whom, until it moves to another branch, and again once back.
    assert_eq!(status(me).2["may"]["autoMerge"], true);
    assert!(status(me).2.get("autoMerge").is_none());
    assert_eq!(write("setAutoMerge", them, one.clone()).0, 400);
    let (code, _, set) = write("setAutoMerge", me, one.clone());
    assert_eq!(code, 200, "{set}");
    assert_eq!(set["commit"], status(me).2["head"], "{set}");
    let auto = || status(me).2["autoMerge"].clone();
    assert_eq!(auto()["state"], "set");
    assert_eq!(auto()["by"]["handle"], owner.account.handle.as_str());
    let next = json!({"repo": repo, "pull": pull, "base": "next"});
    assert_eq!(write("retargetPull", me, next).2, json!({}));
    assert_eq!(auto()["state"], "retargeted");
    let back = json!({"repo": repo, "pull": pull, "base": "main"});
    assert_eq!(write("retargetPull", me, back).2, json!({}));
    assert_eq!(auto()["state"], "set");
    // Cancelled, by its owner: it was not someone else's to cancel.
    let undo = json!({"repo": repo, "pull": pull, "undo": true});
    assert_eq!(write("setAutoMerge", them, undo.clone()).0, 400);
    assert_eq!(
        write("setAutoMerge", me, undo.clone()),
        (200, Vec::new(), json!({}))
    );
    assert_eq!(auto(), Value::Null);
    assert_eq!(write("setAutoMerge", me, undo).0, 400);

    // With no runner named, it merges at once, as the page shows.
    let (code, _, named) = write("removeRunner", me, runner);
    assert_eq!((code, named), (200, json!({"runners": []})));
    assert_eq!(write("mergePull", None, one.clone()).0, 401);
    assert_eq!(write("mergePull", them, one.clone()).0, 400);
    let (code, _, merged) = write("mergePull", me, one.clone());
    assert_eq!(code, 200, "{merged}");
    // A merge commit on main, though it could have fast-forwarded.
    let commit = merged["commit"].clone();
    assert_ne!(commit, json!(head));
    assert_eq!(
        merged,
        json!({"commit": commit, "deleted": "x", "closed": [{"uri": issue}]})
    );
    assert_eq!(thread()["item"]["state"], "merged");
    let branches = ask("listBranches", &format!("repo={repo}&commits=true"), None).2;
    // main shows the merge at once, not once the feed tells of it.
    let mut all = branches["branches"].as_array().unwrap().iter();
    let main = all.find(|b| b["name"] == "main").unwrap();
    let made = commit.as_str().unwrap();
    assert!(main["commit"].to_string().contains(made), "{branches}");
    // Merged, its owner may revert it, which opens a pull the page lands
    // on at once; anyone else may not.
    let as_me = ask("getThread", &format!("repo={repo}&uri={pull}"), me).2;
    assert_eq!(as_me["viewer"]["revert"], true, "{as_me}");
    assert_eq!(write("revertPull", None, one.clone()).0, 401);
    assert_eq!(write("revertPull", them, one.clone()).0, 400);
    let undo = json!({"repo": repo, "pull": pull, "reason": "too soon"});
    let (code, _, reverted) = write("revertPull", me, undo);
    assert_eq!(code, 200, "{reverted}");
    let rkey = pull.rsplit('/').next().unwrap();
    assert_eq!(reverted["branch"], format!("revert/{rkey}"));
    let uri = reverted["uri"].as_str().unwrap();
    let opened = ask("getThread", &format!("repo={repo}&uri={uri}"), None).2;
    assert_eq!(opened["item"]["title"], "Revert \"X\"", "{opened}");
    let (code, _, again) = write("mergePull", me, one);
    assert!(
        code == 400 && again["message"].as_str().unwrap().contains("is merged"),
        "{again}"
    );
    // Nor can it say whether a merged pull merges.
    let (code, _, said) = status(me);
    assert_eq!(code, 400, "{said}");
    assert!(
        said["message"].as_str().unwrap().ends_with("is merged"),
        "{said}"
    );
    // The pull it undoes names it once the revert merges.
    assert!(as_me.get("revertedBy").is_none(), "{as_me}");
    let landing = json!({"repo": repo, "pull": uri});
    assert_eq!(write("mergePull", me, landing).0, 200);
    let undone = || ask("getThread", &format!("repo={repo}&uri={pull}"), me).2;
    wait_until(|| undone().get("revertedBy").is_some());
    let undone = undone();
    assert_eq!(
        undone["revertedBy"],
        json!({"uri": uri, "title": "Revert \"X\""})
    );
}

/// The account logged in to the pages changes its settings there: its
/// profile and picture, the addresses its commits show as its by, whom it
/// blocks, through the same code as the command line, and its preferences:
/// which notifications it hears of, and the theme its pages take at login.
#[test]
fn settings_from_the_pages() {
    const APPEARANCE_PREF: &str = "dev.knotless.getPreferences#appearancePref";
    const NOTIFICATION_PREF: &str = "dev.knotless.getPreferences#notificationPref";
    // Started first, so its index sees the repositories made after.
    let server = gateway_with(None, true);
    let me_at = person(account());
    let work = me_at.work("w", 1);
    me_at.git(&work, &["push", "-q", &me_at.url("mine"), "main"]);
    let other = person(account());
    let elsewhere = other.work("w", 1);
    other.git(&elsewhere, &["push", "-q", &other.url("theirs"), "main"]);
    let set = log_in(&server, &me_at.account, "");
    let session = set.iter().find(|c| c.starts_with("knotless_session="));
    let cookie = pair(session.unwrap()).to_string();
    let me = Some(cookie.as_str());
    let away = Some("https://evil.example");
    let xrpc = |nsid: &str| format!("{}/xrpc/dev.knotless.{nsid}", server.base);
    let get = |nsid: &str, cookie: Option<&str>| browse("GET", &xrpc(nsid), cookie, None, None);
    let send = |nsid: &str, cookie: Option<&str>, body: Value| {
        browse("POST", &xrpc(nsid), cookie, None, Some(body))
    };
    let profile = |of: &str, cookie: Option<&str>| {
        let url = format!("{}?actor={of}", xrpc("getProfile"));
        browse("GET", &url, cookie, None, None).2
    };
    let mine = me_at.account.did.clone();

    // How it shows: its knotless profile alone, then as changed.
    assert_eq!(get("getOwnProfile", None).0, 401);
    assert_eq!(get("getOwnProfile", me).2, json!({}));
    let fields = json!({"displayName": "Me", "description": "Writes code.", "pronouns": "they/them",
        "links": [{"url": "https://me.example", "title": "Home"}]});
    let (status, _, now) = send("updateProfile", me, fields);
    assert_eq!(status, 200, "{now}");
    assert_eq!(now["displayName"], "Me");
    assert_eq!(now["links"][0]["title"], "Home");
    assert_eq!(get("getOwnProfile", me).2, now);
    let record = me_at.record("dev.knotless.profile", "self");
    assert_eq!(record["pronouns"], "they/them");
    wait_until(|| profile(&mine, None)["displayName"] == "Me");
    // Said to be a bot, and then not.
    let (_, _, now) = send("updateProfile", me, json!({"bot": true}));
    assert_eq!(
        (&now["bot"], &now["displayName"]),
        (&json!(true), &json!("Me"))
    );
    assert_eq!(me_at.record("dev.knotless.profile", "self")["bot"], true);
    wait_until(|| profile(&mine, None)["bot"] == true);
    let (_, _, now) = send("updateProfile", me, json!({"bot": false}));
    assert_eq!(now.get("bot"), None);
    assert_eq!(
        me_at.record("dev.knotless.profile", "self").get("bot"),
        None
    );
    // Refused for what it asks, in the words of the code refusing it.
    let long = json!({"pronouns": "x".repeat(21)});
    let (status, _, why) = send("updateProfile", me, long);
    assert_eq!(status, 400);
    assert!(
        why["message"].as_str().unwrap().contains("longer than 20"),
        "{why}"
    );
    let script = json!({"links": [{"url": "javascript:alert(1)"}]});
    assert_eq!(send("updateProfile", me, script).0, 400);
    assert_eq!(send("updateProfile", None, json!({})).0, 401);
    let from_away = browse("POST", &xrpc("updateProfile"), me, away, Some(json!({})));
    assert_eq!(from_away.0, 403);

    // A picture, as the whole of the body.
    let picture = |body: &[u8], cookie: Option<&str>| {
        let req = reqwest::blocking::Client::new().post(xrpc("uploadAvatar"));
        let mut req = req.header("content-type", "image/png").body(body.to_vec());
        if let Some(cookie) = cookie {
            req = req.header("cookie", cookie);
        }
        let res = req.send().unwrap();
        (res.status().as_u16(), res.json::<Value>().unwrap())
    };
    let (status, now) = picture(b"\x89PNG\r\n\x1a\nrest", me);
    assert_eq!(status, 200, "{now}");
    let avatar = now["avatar"].as_str().unwrap().to_string();
    assert!(avatar.contains("dev.knotless.getAvatar"), "{avatar}");
    assert_eq!(now["displayName"], "Me");
    wait_until(|| profile(&mine, None)["avatar"] == avatar.as_str());
    assert_eq!(picture(b"", me).0, 400);
    assert_eq!(picture(b"GIF89a", me).0, 400);
    assert_eq!(picture(b"\x89PNG\r\n\x1a\n", None).0, 401);
    // All of it taken out again, the record goes.
    let cleared = json!({"displayName": "", "description": "", "pronouns": "", "links": [],
        "removeAvatar": true});
    assert_eq!(send("updateProfile", me, cleared).2, json!({}));
    assert_eq!(me_at.record("dev.knotless.profile", "self"), Value::Null);

    // The addresses its commits show as its by: its first push claimed one.
    assert_eq!(get("getEmails", None).0, 401);
    let before = get("getEmails", me).2["count"].as_u64().unwrap();
    let email = |nsid: &str, address: &str| send(nsid, me, json!({ "email": address }));
    let added = email("addEmail", "Me@Example.com").2;
    assert_eq!(added, json!({"changed": true, "count": before + 1}));
    assert_eq!(email("addEmail", " me@example.com ").2["changed"], false);
    assert_eq!(get("getEmails", me).2["count"], before + 1);
    let removed = email("removeEmail", "me@example.com").2;
    assert_eq!(removed, json!({"changed": true, "count": before}));
    assert_eq!(email("removeEmail", "me@example.com").2["changed"], false);
    assert_eq!(email("addEmail", "nowhere").0, 400);
    let anyone = send("addEmail", None, json!({"email": "a@example.com"}));
    assert_eq!(anyone.0, 401);

    // Whom it blocks, from all its repositories or from one.
    assert_eq!(get("listBlocks", None).0, 401);
    assert_eq!(get("listBlocks", me).2, json!({"blocks": []}));
    let them = other.account.did.as_str();
    let (status, _, blocked) = send("blockActor", me, json!({"subject": other.account.handle}));
    assert_eq!(status, 200, "{blocked}");
    assert_eq!(blocked, json!({"subject": them, "blocking": true}));
    let repo = format!("at://{mine}/dev.knotless.repo/mine");
    let one = json!({"subject": them, "repo": repo});
    assert_eq!(send("blockActor", me, one.clone()).2["blocking"], true);
    let listed = get("listBlocks", me).2;
    let blocks = listed["blocks"].as_array().unwrap();
    assert_eq!(blocks.len(), 2, "{listed}");
    assert!(blocks.iter().all(|b| b["subject"]["did"] == them));
    let from_one = blocks.iter().filter(|b| b["repo"]["name"] == "mine");
    assert_eq!(from_one.count(), 1);
    // The profile's menu knows, from the index, at once.
    let blocking = profile(them, me)["viewer"]["blocking"].clone();
    let prefix = format!("at://{mine}/dev.knotless.block/");
    assert!(
        blocking.as_str().unwrap().starts_with(&prefix),
        "{blocking}"
    );
    assert_eq!(profile(them, None).get("viewer"), None);
    // Not itself, no one, nor another's repository, nor what is none.
    assert_eq!(send("blockActor", me, json!({"subject": mine})).0, 400);
    let nobody = send("blockActor", me, json!({"subject": "nobody.invalid"}));
    assert_ne!(nobody.0, 200);
    let foreign = format!("at://{them}/dev.knotless.repo/theirs");
    let theirs = json!({"subject": them, "repo": foreign});
    assert_eq!(send("blockActor", me, theirs).0, 400);
    let nowhere = json!({"subject": them, "repo": "at://nope"});
    assert_eq!(send("blockActor", me, nowhere).0, 400);
    assert_eq!(send("blockActor", None, json!({"subject": them})).0, 401);
    // Unblocked from the one, then from all.
    assert_eq!(send("unblockActor", me, one).2["blocking"], false);
    let unblocked = send("unblockActor", me, json!({"subject": them})).2;
    assert_eq!(unblocked, json!({"subject": them, "blocking": false}));
    assert_eq!(get("listBlocks", me).2, json!({"blocks": []}));
    wait_until(|| profile(them, me)["viewer"].get("blocking").is_none());

    // Its preferences: its notifications, every reason from anyone, until
    // it says, and no appearance.
    assert_eq!(get("getPreferences", None).0, 401);
    let notified = |answer: &Value| {
        let all = answer["preferences"].as_array().unwrap().clone();
        let n = all.iter().find(|p| p["$type"] == NOTIFICATION_PREF);
        n.unwrap().clone()
    };
    let kept = get("getPreferences", me).2;
    assert_eq!(kept["preferences"].as_array().unwrap().len(), 1, "{kept}");
    assert_eq!(notified(&kept)["followedOnly"], false);
    assert_eq!(
        notified(&kept)["reasons"][0],
        json!({"reason": "star", "enabled": true})
    );
    let put = |cookie: Option<&str>, origin: Option<&str>, prefs: Value| {
        let body = json!({ "preferences": prefs });
        browse("POST", &xrpc("putPreferences"), cookie, origin, Some(body))
    };
    let quiet = json!([{"$type": NOTIFICATION_PREF, "reasons": [{"reason": "follow", "enabled": false}], "followedOnly": true}]);
    let (status, _, now) = put(me, None, quiet.clone());
    assert_eq!(status, 200, "{now}");
    assert_eq!(
        notified(&now)["reasons"][1],
        json!({"reason": "follow", "enabled": false})
    );
    assert_eq!(notified(&now)["followedOnly"], true);
    assert_eq!(get("getPreferences", me).2, now);
    // A theme beside them, which leaves them be; then the notifications
    // whole again, a reason not named on and followedOnly off.
    let dracula = json!([{"$type": APPEARANCE_PREF, "theme": "dracula"}]);
    let themed = put(me, None, dracula).2;
    let all = themed["preferences"].as_array().unwrap();
    assert_eq!(
        all[0],
        json!({"$type": APPEARANCE_PREF, "theme": "dracula"})
    );
    assert_eq!(notified(&themed), notified(&now));
    let anew = json!([{"$type": NOTIFICATION_PREF}]);
    let again = notified(&put(me, None, anew).2);
    assert_eq!(again["reasons"][1]["enabled"], true);
    assert_eq!(again["followedOnly"], false);
    // No kind, theme or reason there is none of, nor reasons that are not
    // a list of them; only from the site, as someone.
    let refused = |prefs: Value, why: &str| {
        let (status, _, said) = put(me, None, prefs);
        assert_eq!(status, 400, "{said}");
        assert!(said.to_string().contains(why), "{said}");
    };
    refused(
        json!([{"$type": "app.bsky.actor.defs#adultContentPref"}]),
        "is no kind",
    );
    refused(
        json!([{"$type": APPEARANCE_PREF, "theme": "x; Path=/evil"}]),
        "is no theme",
    );
    refused(
        json!([{"$type": NOTIFICATION_PREF, "reasons": "all"}]),
        "reasons",
    );
    let poke =
        json!([{"$type": NOTIFICATION_PREF, "reasons": [{"reason": "poke", "enabled": false}]}]);
    refused(poke, "is no reason");
    assert_eq!(put(me, away, quiet.clone()).0, 403);
    assert_eq!(put(None, None, quiet.clone()).0, 401);
    // Logging in again, the browser takes the theme it chose.
    let set = log_in(&server, &me_at.account, "");
    let theme = set.iter().find(|c| c.starts_with("theme=")).unwrap();
    assert!(theme.starts_with("theme=dracula;"), "{theme}");
    assert!(!theme.contains("HttpOnly"), "{theme}");
    // A theme of its own: its palette, held to its lexicon, which logging
    // in copies to the browser beside the theme it is drawn over.
    let palette = json!({"colorScheme": "dark", "background": "#101820", "text": "#F2F2F2",
        "primary": "#6ea8fe", "accent": "#8ab4f8", "syntax": {"strings": "#a5d6a7"}});
    let source = format!("at://{mine}/dev.knotless.theme/3lzyx");
    let own = json!([{"$type": APPEARANCE_PREF, "theme": "custom", "palette": palette.clone(), "source": source}]);
    let (status, _, said) = put(me, None, own);
    assert_eq!(status, 200, "{said}");
    assert_eq!(said["preferences"][0]["palette"]["text"], "#f2f2f2");
    assert_eq!(said["preferences"][0]["source"], source.as_str());
    let set = log_in(&server, &me_at.account, "");
    let cookie_of = |name: &str| {
        let found = set.iter().find(|c| c.starts_with(&format!("{name}=")));
        found.unwrap().clone()
    };
    assert!(cookie_of("theme").starts_with("theme=custom-dark;"));
    let drawn =
        "palette=dark.background-101820.text-f2f2f2.primary-6ea8fe.accent-8ab4f8.strings-a5d6a7;";
    assert!(
        cookie_of("palette").starts_with(drawn),
        "{}",
        cookie_of("palette")
    );
    refused(
        json!([{"$type": APPEARANCE_PREF, "theme": "custom"}]),
        "none is given",
    );
    refused(
        json!([{"$type": APPEARANCE_PREF, "theme": "custom", "palette": {"colorScheme": "dark"}}]),
        "a palette gives it",
    );
    refused(
        json!([{"$type": APPEARANCE_PREF, "palette": palette, "source": "https://evil.example"}]),
        "is no dev.knotless.theme",
    );
    // Two themes, one by day and one at night, which logging in copies to
    // the browser beside each other, the night's in cookies of its own.
    let pair = json!([{"$type": APPEARANCE_PREF, "theme": "latte",
        "night": {"theme": "custom", "palette": palette.clone(), "source": source}}]);
    let (status, _, said) = put(me, None, pair);
    assert_eq!(status, 200, "{said}");
    assert_eq!(
        said["preferences"][0]["night"]["palette"]["text"],
        "#f2f2f2"
    );
    let set = log_in(&server, &me_at.account, "");
    let cookie_of = |name: &str| {
        let found = set.iter().find(|c| c.starts_with(&format!("{name}=")));
        found.unwrap().clone()
    };
    assert!(cookie_of("theme").starts_with("theme=latte;"));
    assert!(cookie_of("palette").contains("Max-Age=0"));
    assert!(cookie_of("theme-night").starts_with("theme-night=custom-dark;"));
    let night = cookie_of("palette-night");
    let drawn_at_night = drawn.replacen("palette=", "palette-night=", 1);
    assert!(night.starts_with(&drawn_at_night), "{night}");
    refused(
        json!([{"$type": APPEARANCE_PREF, "theme": "light", "night": {"theme": "custom"}}]),
        "night: theme custom",
    );
    // The themes it added, kept apart from the one it is drawn in: each a
    // copy, held to its lexicon, set whole and left be by a theme chosen.
    const THEMES_PREF: &str = "dev.knotless.getPreferences#themesPref";
    let theirs = format!("at://{}/dev.knotless.theme/3lzyy", other.account.did);
    let one = json!({"uri": theirs, "name": "Sea glass", "creator": other.account.did,
        "palette": palette.clone(), "addedAt": "2026-10-30T12:00:00.000Z"});
    let added = json!([{"$type": THEMES_PREF, "themes": [one.clone(), {"uri": source, "palette": palette.clone()}]}]);
    let (status, _, said) = put(me, None, added);
    assert_eq!(status, 200, "{said}");
    let kept_of = |answer: &Value| {
        let all = answer["preferences"].as_array().unwrap().clone();
        all.into_iter().find(|p| p["$type"] == THEMES_PREF)
    };
    let themes = kept_of(&said).unwrap();
    assert_eq!(themes["themes"][0]["name"], "Sea glass");
    assert_eq!(themes["themes"][0]["addedAt"], "2026-10-30T12:00:00.000Z");
    assert_eq!(themes["themes"][0]["palette"]["text"], "#f2f2f2");
    assert_eq!(themes["themes"][1].get("name"), None);
    let chosen = put(
        me,
        None,
        json!([{"$type": APPEARANCE_PREF, "theme": "mocha"}]),
    )
    .2;
    assert_eq!(kept_of(&chosen), Some(themes));
    let emptied = put(me, None, json!([{"$type": THEMES_PREF, "themes": []}])).2;
    assert_eq!(kept_of(&emptied).unwrap()["themes"], json!([]));
    let many = vec![one.clone(); 51];
    refused(
        json!([{"$type": THEMES_PREF, "themes": many}]),
        "50 at most",
    );
    refused(json!([{"$type": THEMES_PREF}]), "a list is wanted");
    let with = |key: &str, value: Value| {
        let mut t = one.clone();
        t[key] = value;
        json!([{"$type": THEMES_PREF, "themes": [t]}])
    };
    refused(
        with("uri", json!("https://evil.example")),
        "is no dev.knotless.theme",
    );
    refused(
        with("palette", json!({"colorScheme": "dark"})),
        "a palette gives it",
    );
    refused(with("name", json!("x".repeat(65))), "is no name");
    refused(with("creator", json!("alice.test")), "is no DID");
    refused(with("addedAt", json!("yesterday")), "is no time");
    // An appearance with no theme gives the choice back to the system.
    let plain = put(me, None, json!([{"$type": APPEARANCE_PREF}])).2;
    assert_eq!(plain["preferences"][0], json!({"$type": APPEARANCE_PREF}));
}

/// Themes people make for the pages: published as the account logged in
/// to them, listed for anyone, the latest first, and taken back.
#[test]
fn themes_from_the_pages() {
    let server = gateway_with(None, true);
    let maker = person(account());
    let set = log_in(&server, &maker.account, "");
    let session = set.iter().find(|c| c.starts_with("knotless_session="));
    let cookie = pair(session.unwrap()).to_string();
    let me = Some(cookie.as_str());
    let xrpc = |nsid: &str| format!("{}/xrpc/dev.knotless.{nsid}", server.base);
    let send = |nsid: &str, cookie: Option<&str>, body: Value| {
        browse("POST", &xrpc(nsid), cookie, None, Some(body))
    };
    let list = |query: &str| {
        let url = format!("{}{query}", xrpc("listThemes"));
        browse("GET", &url, None, None, None)
    };
    let paper = json!({"colorScheme": "light", "background": "#ffffff", "text": "#1f2328",
        "primary": "#1f883d", "accent": "#0969da"});
    let ink = json!({"colorScheme": "dark", "background": "#0d1117", "text": "#e6edf3",
        "primary": "#2f81f7", "accent": "#58a6ff", "panel": "not a colour"});

    // None yet; then one, as its maker's record, named as written.
    assert_eq!(list("").2, json!({"themes": []}));
    let made = send(
        "createTheme",
        me,
        json!({"name": " Paper ", "palette": paper}),
    );
    let (status, _, made) = made;
    assert_eq!(status, 200, "{made}");
    assert_eq!(made["name"], "Paper");
    assert_eq!(made["palette"], paper);
    assert_eq!(made["creator"]["did"], maker.account.did);
    let uri = made["uri"].as_str().unwrap().to_string();
    let theirs = format!("at://{}/dev.knotless.theme/", maker.account.did);
    assert!(uri.starts_with(&theirs), "{uri}");
    assert_eq!(maker.records("dev.knotless.theme").len(), 1);
    // Not one with no name, a colour not as #rrggbb, or what a palette
    // must give; and only as someone logged in.
    let refused = |body: Value, why: &str| {
        let (status, _, said) = send("createTheme", me, body);
        assert_eq!(status, 400, "{said}");
        assert!(said.to_string().contains(why), "{said}");
    };
    refused(json!({"name": "  ", "palette": paper}), "a theme's name");
    refused(json!({"name": "Ink", "palette": ink}), "panel");
    let bare = json!({"colorScheme": "dark"});
    refused(json!({"name": "Ink", "palette": bare}), "background");
    // Nor one whose text would be hard to read, as the pages check it.
    let mut pale = paper.clone();
    pale["textMuted"] = json!("#eeeeee");
    refused(
        json!({"name": "Pale", "palette": pale}),
        "textMuted on background",
    );
    let anyone = send(
        "createTheme",
        None,
        json!({"name": "Ink", "palette": paper}),
    );
    assert_eq!(anyone.0, 401);
    let mut ink = ink;
    ink["panel"] = json!("#161b22");
    let second = send("createTheme", me, json!({"name": "Ink", "palette": ink}));
    assert_eq!(second.0, 200, "{}", second.2);

    // Listed the latest first, a page at a time, everyone's or its maker's.
    let first = list("?limit=1").2;
    assert_eq!(first["themes"][0]["name"], "Ink");
    let cursor = first["cursor"].as_str().unwrap().replace(' ', "%20");
    let next = list(&format!("?limit=1&cursor={cursor}")).2;
    assert_eq!(next["themes"][0]["uri"], uri.as_str());
    assert_eq!(next.get("cursor"), None);
    let by = list(&format!("?actor={}", maker.account.handle)).2;
    assert_eq!(by["themes"].as_array().unwrap().len(), 2);
    assert_eq!(list("?actor=nobody.invalid").0, 400);
    // Found by how its name starts, in any case, in the order of names.
    let named = |q: &str| {
        let found = list(&format!("?q={q}")).2;
        let names = found["themes"].as_array().unwrap().iter();
        names.map(|t| t["name"].clone()).collect::<Vec<_>>()
    };
    assert_eq!(named("pA"), [json!("Paper")]);
    assert_eq!(named("%20"), [json!("Ink"), json!("Paper")]);
    assert_eq!(named("ox"), Vec::<Value>::new());
    assert_eq!(list("?q=p").2.get("cursor"), None);
    // By URI, as an account's added themes name them: in the order named,
    // and only those there are.
    let ink_uri = second.2["uri"].as_str().unwrap();
    let gone = format!("{theirs}3lzyx");
    let at = list(&format!("?uris={uri}&uris={gone}&uris={ink_uri}")).2;
    let uris = at["themes"].as_array().unwrap().iter();
    let uris = uris.map(|t| t["uri"].clone()).collect::<Vec<_>>();
    assert_eq!(uris, [json!(uri), json!(ink_uri)]);
    assert_eq!(at["themes"][0]["creator"]["handle"], maker.account.handle);
    let many = vec![format!("uris={gone}"); 51].join("&");
    let (status, _, said) = list(&format!("?{many}"));
    assert_eq!(status, 400, "{said}");
    let plain = gateway();
    let unindexed = format!("{}/xrpc/dev.knotless.listThemes", plain.base);
    assert_eq!(browse("GET", &unindexed, None, None, None).0, 404);

    // Taken back, only its maker's own.
    let other = "at://did:plc:someoneelse0000000000000/dev.knotless.theme/3lzyx";
    let (status, _, said) = send("deleteTheme", me, json!({ "uri": other }));
    assert_eq!(status, 400, "{said}");
    let (status, _, said) = send("deleteTheme", me, json!({ "uri": uri }));
    assert_eq!(status, 200, "{said}");
    assert_eq!(maker.records("dev.knotless.theme").len(), 1);
    assert_eq!(list("").2["themes"].as_array().unwrap().len(), 1);
}

/// An operator moderates the appview from its pages, logged in to them, as
/// `knotless admin` does with a service token: reads its reports and how
/// it is used, and takes an account down and puts it back. No one else
/// can, and not from another site's page.
#[test]
fn operators_moderate_from_the_pages() {
    let operator = account();
    let server = gateway_run(None, true, Duration::ZERO, &operator.did);
    let op = person(operator);
    let other = person(account());
    let xrpc = |nsid: &str| format!("{}/xrpc/{nsid}", server.base);
    let get = |nsid: &str, query: &str, cookie: &str| {
        let url = format!("{}{query}", xrpc(nsid));
        browse("GET", &url, Some(cookie), None, None)
    };
    let session = |who: &Account| {
        let set = log_in(&server, who, "");
        let found = set.iter().find(|c| c.starts_with("knotless_session="));
        pair(found.unwrap()).to_string()
    };
    let (mine, theirs) = (session(&op.account), session(&other.account));

    let viewer = |cookie: &str| get("dev.knotless.getViewer", "", cookie).2;
    assert_eq!(viewer(&mine)["admin"], true);
    assert_eq!(viewer(&theirs).get("admin"), None);
    let (status, _, reports) = get("dev.knotless.listReports", "", &mine);
    assert_eq!(
        (status, &reports["reports"]),
        (200, &json!([])),
        "{reports}"
    );
    let (status, _, usage) = get("dev.knotless.getUsage", "?days=2", &mine);
    assert_eq!(status, 200, "{usage}");
    assert_eq!(usage["days"].as_array().unwrap().len(), 2);
    assert_eq!(get("dev.knotless.listReports", "", &theirs).0, 403);
    let list = xrpc("dev.knotless.listReports");
    assert_eq!(browse("GET", &list, None, None, None).0, 401);

    let subject = json!({"$type": "com.atproto.admin.defs#repoRef", "did": other.account.did});
    let update = xrpc("com.atproto.admin.updateSubjectStatus");
    let set = |applied: bool, origin: Option<&str>| {
        let body = json!({"subject": subject, "takedown": {"applied": applied}});
        browse("POST", &update, Some(&mine), origin, Some(body)).0
    };
    let status = || {
        let query = format!("?did={}", other.account.did);
        let said = get("com.atproto.admin.getSubjectStatus", &query, &mine).2;
        said["takedown"]["applied"].clone()
    };
    assert_eq!(set(true, Some(&server.base)), 200);
    assert_eq!(status(), true);
    assert_eq!(set(false, Some("https://evil.example")), 401);
    assert_eq!(status(), true);
    assert_eq!(set(false, None), 200);
    assert_eq!(status(), false);
}

/// People react to issues and comments on the pages, as the command line
/// does: one reaction per account, subject and content, counted by the
/// index for everyone, each asker seeing which are theirs; and none by an
/// account the owner blocked.
#[test]
fn reacting_on_the_pages() {
    // Started first, so its index sees the records made after.
    let server = gateway_with(None, true);
    let owner = person(account());
    let url = owner.url("reacted");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let fan = person(account());
    let (code, issue) = owner.cli(&["issue", "create", &url, "Shiny"], b"");
    assert_eq!(code, 0, "{issue}");
    let (code, said) = owner.cli(&["comment", "add", &url, &issue, "look"], b"");
    assert_eq!(code, 0, "{said}");
    let repo = format!("at://{}/dev.knotless.repo/reacted", owner.account.did);
    let xrpc = |nsid: &str| format!("{}/xrpc/dev.knotless.{nsid}", server.base);
    let thread = |cookie: Option<&str>| {
        let url = format!("{}?repo={repo}&uri={issue}", xrpc("getThread"));
        browse("GET", &url, cookie, None, None).2
    };
    let reactions = |cookie: Option<&str>| {
        let url = format!("{}?repo={repo}&uri={issue}", xrpc("getReactions"));
        browse("GET", &url, cookie, None, None)
    };
    wait_until(|| thread(None)["comments"].as_array().map_or(0, Vec::len) == 1);
    assert_eq!(thread(None)["comments"][0]["uri"], said.as_str());
    assert_eq!(thread(None).get("reactions"), None);
    assert_eq!(reactions(None).2, json!({"subjects": []}));

    let set = log_in(&server, &fan.account, "");
    let session = set.iter().find(|c| c.starts_with("knotless_session="));
    let cookie = pair(session.unwrap()).to_string();
    let me = Some(cookie.as_str());
    let react = |nsid: &str, cookie: Option<&str>, subject: &str, content: &str| {
        let body = json!({"repo": repo, "subject": subject, "content": content});
        browse("POST", &xrpc(nsid), cookie, None, Some(body))
    };
    let (status, _, state) = react("addReaction", me, &issue, "heart");
    assert_eq!(status, 200, "{state}");
    assert_eq!(state["subject"], issue.as_str());
    assert_eq!(state["reactions"][0]["content"], "heart");
    assert_eq!(state["reactions"][0]["count"], 1);
    let heart = state["reactions"][0]["reaction"]
        .as_str()
        .unwrap()
        .to_string();
    assert!(heart.starts_with(&format!("at://{}/dev.knotless.reaction/", fan.account.did)));
    // Again, the reaction there is.
    let again = react("addReaction", me, &issue, "heart").2;
    assert_eq!(again["reactions"][0]["reaction"], heart.as_str());
    let records = fan.records("dev.knotless.reaction");
    assert_eq!(records.len(), 1);
    assert_eq!(records[0].1["subject"]["uri"], issue.as_str());
    assert!(records[0].1["subject"]["cid"].is_string());
    assert_eq!(records[0].1["content"], "heart");
    // On a comment, in GitHub's order however they were made.
    assert_eq!(react("addReaction", me, &said, "eyes").0, 200);
    let both = react("addReaction", me, &said, "+1").2;
    assert_eq!(both["reactions"][0]["content"], "+1");
    assert_eq!(both["reactions"][1]["content"], "eyes");

    // Everyone sees the counts; only the fan which are theirs.
    let mine = thread(me);
    assert_eq!(mine["reactions"][0]["reaction"], heart.as_str());
    assert_eq!(mine["comments"][0]["reactions"][1]["content"], "eyes");
    let anyone = thread(None);
    assert_eq!(
        anyone["reactions"],
        json!([{"content": "heart", "count": 1}])
    );
    assert_eq!(anyone["comments"][0]["reactions"][0]["count"], 1);
    let (status, _, counted) = reactions(None);
    assert_eq!(status, 200, "{counted}");
    assert_eq!(counted["subjects"][0]["subject"], issue.as_str());
    assert_eq!(counted["subjects"][1]["subject"], said.as_str());
    assert!(reactions(me).2["subjects"][1]["reactions"][0]["reaction"].is_string());
    // The command line shows them, from the appview.
    let mut reader = Sandbox::new();
    reader.set("KNOTLESS_APPVIEW", &server.base);
    let (code, shown) = reader.cli(&["issue", "show", &url, &issue], b"");
    assert_eq!(code, 0, "{shown}");
    assert!(shown.contains("reactions: heart 1"), "{shown}");
    assert!(shown.contains("reactions: +1 1  eyes 1"), "{shown}");

    // Taking one back takes every record of it.
    let (status, _, left) = react("removeReaction", me, &said, "eyes");
    assert_eq!(status, 200, "{left}");
    assert_eq!(left["reactions"].as_array().unwrap().len(), 1);
    assert_eq!(left["reactions"][0]["content"], "+1");
    assert_eq!(react("removeReaction", me, &said, "eyes").0, 200);
    assert_eq!(fan.records("dev.knotless.reaction").len(), 2);

    // Only as someone logged in, with one of the eight, to what the
    // repository's pages show.
    assert_eq!(react("addReaction", None, &issue, "heart").0, 401);
    assert_eq!(react("addReaction", me, &issue, "tada").0, 400);
    let elsewhere = format!("at://{}/dev.knotless.issue/3zzzzzzzzzzzz", fan.account.did);
    assert_eq!(react("addReaction", me, &elsewhere, "heart").0, 404);
    let nowhere = format!("at://{}/dev.knotless.repo/nowhere", owner.account.did);
    let body = json!({"repo": nowhere, "subject": issue, "content": "heart"});
    assert_eq!(
        browse("POST", &xrpc("addReaction"), me, None, Some(body)).0,
        404
    );
    let bad = format!("{}?repo={repo}&uri=nope", xrpc("getReactions"));
    assert_eq!(browse("GET", &bad, None, None, None).0, 400);
    let plain = gateway();
    let unindexed = format!(
        "{}/xrpc/dev.knotless.getReactions?repo={repo}&uri={issue}",
        plain.base
    );
    assert_eq!(browse("GET", &unindexed, None, None, None).0, 404);
    if net().spaces {
        let hidden = owner.url("hidden");
        assert_eq!(
            owner.cli(&["repo", "create", &hidden, "--private"], b"").0,
            0
        );
        let private = format!("at://{}/dev.knotless.repo/hidden", owner.account.did);
        let body = json!({"repo": private, "subject": issue, "content": "heart"});
        assert_eq!(
            browse("POST", &xrpc("addReaction"), me, None, Some(body)).0,
            404
        );
    }

    // Blocked from the repository, the fan's reactions count no more.
    let fan_handle = fan.account.handle.clone();
    assert_eq!(owner.cli(&["mod", "block", &fan_handle, &url], b"").0, 0);
    wait_until(|| reactions(None).2 == json!({"subjects": []}));
    assert_eq!(
        owner
            .cli(&["mod", "block", &fan_handle, &url, "--undo"], b"")
            .0,
        0
    );
    wait_until(|| reactions(None).2["subjects"].as_array().map_or(0, Vec::len) == 2);
}

/// A server that is no gateway, nor an appview, until the process ends.
fn serve_plain() -> String {
    let s = Sandbox::new();
    let rt = tokio::runtime::Runtime::new().unwrap();
    let listener = rt
        .block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
        .unwrap();
    let base = format!("http://{}", listener.local_addr().unwrap());
    let app = App::build(ctx(&s), Duration::ZERO, None, None);
    let router = router(app);
    std::thread::spawn(move || {
        let _keep = s;
        rt.block_on(async move { axum::serve(listener, router).await.unwrap() });
    });
    base
}

/// A GET of `url` as a browser holding `cookie`, if any: its status, its
/// `cache-control` and `vary`, and its body.
fn looked(url: &str, cookie: Option<&str>) -> (u16, String, String, String) {
    let mut req = reqwest::blocking::Client::new().get(url);
    if let Some(cookie) = cookie {
        req = req.header("cookie", cookie);
    }
    let res = req.send().unwrap();
    let header = |name: &str| {
        let all = res.headers().get_all(name).iter();
        let all: Vec<&str> = all.map(|v| v.to_str().unwrap()).collect();
        all.join(", ")
    };
    let (cache, vary) = (header("cache-control"), header("vary"));
    (res.status().as_u16(), cache, vary, res.text().unwrap())
}

/// The session cookie `who` logs in to `server`'s pages with.
fn session_cookie(server: &Server, who: &Account) -> String {
    let set = log_in(server, who, "");
    let session = set.iter().find(|c| c.starts_with("knotless_session="));
    pair(session.unwrap()).to_string()
}

/// A private repository shows on the pages to its members, each logged in
/// to them and read as themselves, as a public one does; to anyone else as
/// a repository that is not there; and to no one in a card, or from any
/// cache. Removed from it, or logged out, a member reads it no more.
#[test]
fn private_repositories_on_the_pages() {
    // Started first, so its index sees the repositories made after; its
    // mirrors kept a while, as a viewer's is between their looks.
    let server = gateway_kept(None, true, Duration::from_secs(60));
    let [owner, member, outsider, forker] = people();
    let outsider_in = session_cookie(&server, &outsider.account);
    let xrpc =
        |nsid: &str, query: &str| format!("{}/xrpc/dev.knotless.{nsid}?{query}", server.base);
    let repo = |name: &str| format!("repo=at://{}/dev.knotless.repo/{name}", owner.account.did);
    if !net().spaces {
        // Without spaces, a repository not there is not there to anyone.
        let (status, ..) = looked(&xrpc("getRepo", &repo("none")), Some(&outsider_in));
        assert_eq!(status, 404);
        return;
    }
    let url = owner.url("hidden");
    assert_eq!(owner.cli(&["repo", "create", &url, "--private"], b"").0, 0);
    let work = owner.work("w", 1);
    owner.commit_file(&work, "notes.md", "# Notes\n");
    owner.git(&work, &["push", "-q", &url, "main"]);
    let share = |cmd: &str| owner.cli(&[cmd, &url, &member.account.handle], b"").0;
    assert_eq!(share("share"), 0);
    assert_eq!(
        owner
            .cli(&["issue", "create", &url, "Private trouble"], b"")
            .0,
        0
    );
    let member_in = session_cookie(&server, &member.account);
    let theirs = Some(member_in.as_str());

    // A member sees it as a public one is seen, for them alone.
    let (status, cache, vary, shown) = looked(&xrpc("getRepo", &repo("hidden")), theirs);
    assert_eq!(status, 200, "{shown}");
    assert_eq!(cache, "private, no-store");
    assert!(vary.contains("cookie"), "{vary}");
    let shown: Value = serde_json::from_str(&shown).unwrap();
    assert_eq!(shown["private"], true);
    assert_eq!(shown["openIssues"], 1);
    assert_eq!(shown.get("starCount"), None);
    let tree = looked(&xrpc("getTree", &repo("hidden")), theirs);
    assert_eq!((tree.0, tree.1.as_str()), (200, "private, no-store"));
    assert!(tree.3.contains("notes.md"), "{}", tree.3);
    let issues = xrpc("listItems", &format!("{}&kind=issue", repo("hidden")));
    let issues: Value = serde_json::from_str(&looked(&issues, theirs).3).unwrap();
    assert_eq!(issues["items"][0]["title"], "Private trouble");
    // As is its template, which the new issue page asks for.
    let template = xrpc("getTemplate", &format!("{}&kind=issue", repo("hidden")));
    let template = looked(&template, theirs);
    assert_eq!(
        (template.0, template.1.as_str()),
        (200, "private, no-store")
    );
    // A member reacts: in the space, not their own repo, and counted as
    // it is read from there, theirs named.
    let trouble = issues["items"][0]["uri"].as_str().unwrap().to_string();
    let at = format!("at://{}/dev.knotless.repo/hidden", owner.account.did);
    let react = |nsid: &str, content: &str| {
        let body = json!({"repo": at, "subject": trouble, "content": content});
        browse("POST", &xrpc(nsid, ""), theirs, None, Some(body))
    };
    let (status, _, state) = react("addReaction", "eyes");
    assert_eq!(status, 200, "{state}");
    assert_eq!(state["reactions"][0]["count"], 1);
    let eyes = state["reactions"][0]["reaction"]
        .as_str()
        .unwrap()
        .to_string();
    // Named in full, in the space.
    assert!(
        eyes.starts_with(&format!(
            "at://{}/space/dev.knotless.space/hidden/{}/dev.knotless.reaction/",
            owner.account.did, member.account.did
        )),
        "{eyes}"
    );
    assert!(member.records("dev.knotless.reaction").is_empty());
    let on = format!("{}&uri={trouble}", repo("hidden"));
    let shown: Value = serde_json::from_str(&looked(&xrpc("getThread", &on), theirs).3).unwrap();
    assert_eq!(shown["reactions"][0]["reaction"], eyes.as_str());
    let counted: Value =
        serde_json::from_str(&looked(&xrpc("getReactions", &on), theirs).3).unwrap();
    assert_eq!(counted["subjects"][0]["reactions"][0]["content"], "eyes");
    let (status, _, left) = react("removeReaction", "eyes");
    assert_eq!(status, 200, "{left}");
    assert_eq!(left["reactions"], json!([]));
    let raw = format!(
        "{}/{}/hidden/raw/main/notes.md",
        server.base, owner.account.handle
    );
    let file = looked(&raw, theirs);
    assert_eq!((file.0, file.1.as_str()), (200, "private, no-store"));
    assert!(file.2.contains("cookie"), "{}", file.2);
    assert_eq!(file.3, "# Notes\n");
    // Its archive, as its files: to members alone, kept by no cache.
    let archive = format!(
        "{}/{}/hidden/archive/main.zip",
        server.base, owner.account.handle
    );
    let zip = looked(&archive, theirs);
    assert_eq!((zip.0, zip.1.as_str()), (200, "private, no-store"));
    assert!(zip.2.contains("cookie"), "{}", zip.2);
    assert!(zip.3.contains("hidden-main/notes.md"));
    assert_eq!(looked(&archive, None).0, 404);
    // Its feed, as its files: to members alone, kept by no cache.
    let feed = format!(
        "{}/{}/hidden/commits.atom",
        server.base, owner.account.handle
    );
    let commits = looked(&feed, theirs);
    assert_eq!((commits.0, commits.1.as_str()), (200, "private, no-store"));
    assert!(commits.2.contains("cookie"), "{}", commits.2);
    assert!(commits.3.contains("<entry>"));
    assert_eq!(looked(&feed, None).0, 404);
    // A public repository, to the same member, is theirs alone to cache,
    // but may be kept.
    let open = owner.url("open");
    owner.git(&work, &["push", "-q", &open, "main"]);
    let public = looked(&xrpc("getRepo", &repo("open")), theirs);
    assert_eq!((public.0, public.1.as_str()), (200, "private, no-cache"));
    // Nothing of it reaches the index, which has the public one, nor the
    // feed, which reads only that.
    let did = &owner.account.did;
    let feed = xrpc("getFeed", &format!("actor={did}"));
    wait_until(|| looked(&feed, None).3.contains("\"open\""));
    let said = looked(&feed, theirs).3;
    assert!(!said.contains("hidden"), "{said}");
    // Its pushes are among its owner's activity, from this server's index
    // of its space, to the member, for them alone, and to no one else.
    let activity = xrpc("getActorActivity", &format!("actor={did}"));
    let (_, cache, _, said) = looked(&activity, theirs);
    assert_eq!(cache, "private, no-store");
    let said: Value = serde_json::from_str(&said).unwrap();
    let pushed = said["repos"].as_array().unwrap();
    let hidden = pushed.iter().find(|r| r["name"] == "hidden");
    assert_eq!(hidden.unwrap()["days"][0]["pushes"], 1, "{said}");
    for cookie in [None, Some(outsider_in.as_str())] {
        let said = looked(&activity, cookie).3;
        assert!(!said.contains("hidden"), "{said}");
    }
    // Search finds it for the member, who was shown it, from this
    // server's index of its space, and for no one else.
    let search = xrpc("listRepos", "q=hidden");
    let (_, cache, _, said) = looked(&search, theirs);
    assert_eq!(cache, "private, no-store");
    assert!(said.contains("\"private\":true"), "{said}");
    for cookie in [None, Some(outsider_in.as_str())] {
        let said = looked(&search, cookie).3;
        assert!(!said.contains("hidden"), "{search}: {said}");
    }

    // To anyone else, and in any card, it is not there, as one never made
    // is not.
    let missing = |cookie: Option<&str>| {
        let (status, _, _, said) = looked(&xrpc("getRepo", &repo("hidden")), cookie);
        assert_eq!(status, 404, "{said}");
        let said: Value = serde_json::from_str(&said).unwrap();
        said["message"].as_str().unwrap().replace("hidden", "?")
    };
    let (_, _, _, none) = looked(&xrpc("getRepo", &repo("never")), None);
    let none: Value = serde_json::from_str(&none).unwrap();
    let none = none["message"].as_str().unwrap().replace("never", "?");
    assert_eq!(missing(None), none);
    assert_eq!(missing(Some(&outsider_in)), none);
    assert_eq!(looked(&raw, None).0, 404);
    assert_eq!(looked(&raw, Some(&outsider_in)).0, 404);
    let card = format!("{}/{}/hidden/card.png", server.base, owner.account.handle);
    assert_eq!(looked(&card, theirs).0, 404);

    // Its owner, logged in, has it listed among their repositories, as
    // does the member who read it, and no one else.
    let owner_in = session_cookie(&server, &owner.account);
    let listed = xrpc("getActorRepos", &format!("actor={}", owner.account.did));
    let names = |cookie: Option<&str>| {
        let (_, cache, _, said) = looked(&listed, cookie);
        let said: Value = serde_json::from_str(&said).unwrap();
        let repos = said["repos"].as_array().unwrap().iter();
        let names = repos.map(|r| format!("{}{}", r["name"], r["private"]));
        (cache, names.collect::<Vec<_>>())
    };
    let (cache, mine) = names(Some(&owner_in));
    assert_eq!(cache, "private, no-store");
    assert!(mine.contains(&"\"hidden\"true".to_string()), "{mine:?}");
    let (cache, listed) = names(theirs);
    assert_eq!(cache, "private, no-store");
    assert!(listed.contains(&"\"hidden\"true".to_string()), "{listed:?}");
    // Their own list has it apart, as shared with them, to them alone.
    let own = xrpc("getActorRepos", &format!("actor={}", member.account.did));
    let shared = |cookie: Option<&str>| {
        let said: Value = serde_json::from_str(&looked(&own, cookie).3).unwrap();
        said.get("shared").cloned()
    };
    // Another's fork of it, private as it is, shared with the member and
    // read by them, is listed too, newest first, with what it forked.
    assert_eq!(
        owner
            .cli(&["repo", "share", &url, &forker.account.handle], b"")
            .0,
        0
    );
    let (code, fork) = forker.cli(&["repo", "fork", &url, "copy"], b"");
    assert_eq!(code, 0, "{fork}");
    let with = member.account.handle.as_str();
    assert_eq!(forker.cli(&["repo", "share", fork.trim(), with], b"").0, 0);
    let copy = format!("repo=at://{}/dev.knotless.repo/copy", forker.account.did);
    let (status, _, _, said) = looked(&xrpc("getRepo", &copy), theirs);
    assert_eq!(status, 200, "{said}");
    let theirs_shared = shared(theirs).unwrap();
    assert_eq!(theirs_shared[0]["name"], "copy");
    let by = forker.account.did.as_str();
    assert_eq!(theirs_shared[0]["owner"]["did"], by);
    let forked = &theirs_shared[0]["forkOf"];
    assert_eq!(forked["did"], owner.account.did.as_str());
    assert_eq!(forked["name"], "hidden");
    assert_eq!(theirs_shared[1]["name"], "hidden");
    assert_eq!(theirs_shared[1]["owner"]["did"], owner.account.did.as_str());
    assert_eq!(theirs_shared[1]["private"], true);
    assert_eq!(theirs_shared[1].get("forkOf"), None);
    assert_eq!(shared(Some(&outsider_in)), None);
    assert_eq!(shared(None), None);
    // The index rebuilt, as for a new version of it, who was proven to read
    // what is kept: from that alone the member's next look lists both
    // again, synced as them, before they open either.
    let path = server.app.ctx.env.cache_dir.join("serve/private.sqlite");
    let old = rusqlite::Connection::open(&path).unwrap();
    old.execute_batch("PRAGMA user_version = 0").unwrap();
    drop(old);
    knotless_serve::index::Index::open(&path).unwrap();
    let kept = server.app.spaces.as_ref().unwrap();
    assert!(kept.spaces().unwrap().is_empty());
    let them = member.account.did.as_str();
    assert_eq!(kept.maybe_member(them).unwrap().len(), 2);
    // Asked after again every 10 s, as a look each minute does, not on
    // every look: each asking forgets the member's credentials, which
    // another under way may be using. As after a restart, whether their PDS
    // has spaces is not known, so the pages' first ask, `getViewer`,
    // starts it.
    let viewer = xrpc("getViewer", "");
    let mut asked = std::time::Instant::now() - Duration::from_secs(10);
    wait_until(|| {
        if asked.elapsed() >= Duration::from_secs(10) {
            server.app.discovered.lock().unwrap().clear();
            server.app.with_spaces.lock().unwrap().clear();
            assert_eq!(looked(&viewer, theirs).0, 200);
            asked = std::time::Instant::now();
        }
        let names = shared(theirs).unwrap_or_default();
        let names = names.as_array().cloned().unwrap_or_default();
        let names: Vec<_> = names.iter().map(|r| r["name"].clone()).collect();
        names == [json!("copy"), json!("hidden")]
    });
    for cookie in [None, Some(outsider_in.as_str())] {
        let (_, listed) = names(cookie);
        assert!(listed.iter().all(|n| !n.contains("hidden")), "{listed:?}");
    }
    let (status, ..) = looked(&xrpc("getRepo", &repo("hidden")), Some(&owner_in));
    assert_eq!(status, 200);

    // Removed from it, a member reads it no more, once its space is asked
    // again, nor finds it; shared again, they do.
    let reads = || looked(&xrpc("getRepo", &repo("hidden")), theirs).0;
    let found = || looked(&search, theirs).3.contains("hidden");
    assert_eq!(reads(), 200);
    assert_eq!(share("unshare"), 0);
    server.app.reprove.store(0, Ordering::Relaxed);
    assert_eq!(reads(), 404);
    assert!(!found());
    assert_eq!(share("share"), 0);
    assert_eq!(reads(), 200);
    assert!(found());

    // Logged out, nor are their credentials kept.
    let credentials = format!(
        "config/web/{}/credentials",
        member.account.did.replace(':', "_")
    );
    assert!(server._sandbox.path(&credentials).exists());
    let logout = format!("{}/oauth/logout", server.base);
    assert_eq!(browse("POST", &logout, theirs, None, None).0, 200);
    assert_eq!(reads(), 404);
    assert!(!server._sandbox.path(&credentials).exists());
    // Which spaces they were proven to read is kept, listed to no one
    // while they are out: it alone leads back to a space whose owner never
    // logs in here, and their next login asks each again.
    let index = server.app.spaces.clone().unwrap();
    assert!(!index.read_of(&member.account.did).unwrap().is_empty());
    assert_eq!(shared(None), None);

    // Back, it is listed to them before they open it, and they read it
    // again; its owner's account deleted at its PDS,
    // what they were shown of it goes with the rest, and they read it no
    // more.
    let back = session_cookie(&server, &member.account);
    let names = shared(Some(&back)).unwrap();
    assert!(
        names
            .as_array()
            .unwrap()
            .iter()
            .any(|r| r["name"] == "hidden")
    );
    let reads = || looked(&xrpc("getRepo", &repo("hidden")), Some(&back)).0;
    assert_eq!(reads(), 200);
    assert!(index.repo(&at).unwrap().is_some());
    let deleted = json!({"did": owner.account.did});
    let res = reqwest::blocking::Client::new()
        .post(format!(
            "{}/xrpc/com.atproto.admin.deleteAccount",
            net().pds
        ))
        .header("authorization", format!("Basic {}", admin()))
        .json(&deleted)
        .send()
        .unwrap();
    let status = res.status();
    assert!(status.is_success(), "{}", res.text().unwrap());
    // The index goes first, what read the space for them after. Read
    // again, its record is asked of a PDS that no longer has the account,
    // which passes it on to an appview: on this network there is none, so
    // it can answer 502, not 404.
    wait_until(|| index.repo(&at).unwrap().is_none());
    wait_until(|| matches!(reads(), 404 | 502));
}

/// Spoils `did`'s record at `collection`/`rkey` in `space` where the PDS
/// keeps it, as a fault of a server's might, leaving its CID as it was:
/// its bytes are another record's, which no longer come to it.
fn cut_short(did: &str, space: &str, collection: &str, rkey: &str) {
    use sha2::Digest;
    let actors = net()
        .actors
        .as_ref()
        .expect("a harness that names its store");
    let hash: String = sha2::Sha256::digest(did.as_bytes())
        .iter()
        .map(|b| format!("{b:02x}"))
        .collect();
    let store = std::path::Path::new(actors).join(&hash[..2]).join(did);
    let db = rusqlite::Connection::open(store.join("store.sqlite")).unwrap();
    db.busy_timeout(Duration::from_secs(10)).unwrap();
    let other = knotless::drisl::encode(&json!({"$type": collection, "cut": 1})).unwrap();
    let spoiled = db
        .execute(
            "UPDATE space_record SET value = ?1 WHERE space = ?2 AND collection = ?3 AND rkey = ?4",
            rusqlite::params![other, space, collection, rkey],
        )
        .unwrap();
    assert_eq!(spoiled, 1);
}

/// The reference PDS's administrator, as dev-env makes it, for basic auth.
fn admin() -> String {
    use base64::Engine;
    base64::engine::general_purpose::STANDARD.encode("admin:admin-pass")
}

/// A private repository read here is synced into the server's own index,
/// apart from the appview's, with the session of the member who last read
/// it, and its pulls, issues and checks read from there, to members alone.
/// Its owner's are found as they log in, and listed on their profile from
/// there. That member removed, the index forgets it.
#[test]
fn private_repositories_indexed_here() {
    let server = gateway_with(None, true);
    let every = Duration::from_millis(200);
    server
        ._rt
        .spawn(knotless_serve::spaces::follow(server.app.clone(), every));
    if !net().spaces {
        // Nothing private to sync, so nothing is; nor does an owner have
        // private repositories to list, on a PDS without spaces.
        std::thread::sleep(every * 2);
        assert!(server.app.syncers().is_empty());
        let owner = person(account());
        let owner_in = session_cookie(&server, &owner.account);
        let did = &owner.account.did;
        let listed = format!(
            "{}/xrpc/dev.knotless.getActorRepos?actor={did}",
            server.base
        );
        let (status, _, _, said) = looked(&listed, Some(&owner_in));
        assert_eq!(status, 200, "{said}");
        // Which the pages are told, so as to offer Private as not yet
        // open; and one asked for all the same says why it can't be.
        let viewer = format!("{}/xrpc/dev.knotless.getViewer", server.base);
        let spaces = || browse("GET", &viewer, Some(&owner_in), None, None).2["spaces"].clone();
        wait_until(|| spaces() == false);
        let create = format!("{}/xrpc/dev.knotless.createRepo", server.base);
        let body = json!({"name": "hidden", "private": true});
        let (status, _, said) = browse("POST", &create, Some(&owner_in), None, Some(body));
        assert_eq!(status, 400, "{said}");
        let why = said["message"].as_str().unwrap();
        assert!(why.contains("can't hold private repositories yet"), "{why}");
        return;
    }
    let [owner, member, outsider] = people();
    let run = |s: &Sandbox, args: &[&str]| {
        let (code, out) = s.cli(args, b"");
        assert_eq!(code, 0, "{args:?}: {out}");
        out
    };
    let url = owner.url("kept");
    run(&owner, &["repo", "create", &url, "--private"]);
    // One no one reads here, which its owner's profile lists all the same,
    // and one deleted, which keeps it from none of them.
    run(
        &owner,
        &["repo", "create", &owner.url("found"), "--private"],
    );
    run(&owner, &["repo", "create", &owner.url("gone"), "--private"]);
    run(&owner, &["repo", "delete", &owner.url("gone"), "--yes"]);
    let work = owner.work("w", 1);
    owner.commit_file(&work, "notes.md", "# Notes\n");
    owner.git(&work, &["push", "-q", &url, "main"]);
    run(&owner, &["repo", "share", &url, &member.account.handle]);
    run(&owner, &["runner", "add", &url, &member.account.handle]);
    let issue = run(&owner, &["issue", "create", &url, "Private trouble"]);
    // The member, as its runner, files a check of it into the space.
    let did = owner.account.did.clone();
    let space = knotless::space::uri(&did, "kept");
    let rt = tokio::runtime::Runtime::new().unwrap();
    let parsed = knotless::url::RepoUrl::parse(&url).unwrap();
    let target = rt.block_on(knotless::forge::Target::open(&ctx(&owner), &parsed));
    let at = format!("at://{did}/dev.knotless.repo/kept");
    let reference = json!({"uri": at, "key": target.unwrap().record.key});
    member.put_space_record(
        &space,
        "dev.knotless.check",
        "p1",
        json!({
            "$type": "dev.knotless.check", "repo": reference, "ref": "refs/heads/main",
            "commit": owner.git(&work, &["rev-parse", "HEAD"]), "workflow": "test",
            "status": "success", "startedAt": "2026-10-24T00:00:00.000Z",
        }),
    );
    let member_in = session_cookie(&server, &member.account);
    let theirs = Some(member_in.as_str());
    let xrpc = |nsid: &str, query: &str| {
        let base = &server.base;
        format!("{base}/xrpc/dev.knotless.{nsid}?repo={at}{query}")
    };
    let read = |nsid: &str, query: &str| {
        let (status, cache, _, said) = looked(&xrpc(nsid, query), theirs);
        assert_eq!(
            (status, cache.as_str()),
            (200, "private, no-store"),
            "{said}"
        );
        serde_json::from_str::<Value>(&said).unwrap()
    };
    let index = server.app.spaces.clone().unwrap();
    let followed = || index.spaces().unwrap().iter().any(|(s, _)| *s == space);
    let syncer = || {
        let mut syncers = server.app.syncers().into_iter();
        syncers.find(|(s, _)| *s == space).map(|(_, did)| did)
    };
    assert!(!followed());

    // Read, it is synced at once as the member who read it, and its checks
    // read from this server's own index.
    assert_eq!(read("getRepo", "")["openIssues"], 1);
    assert_eq!(syncer(), Some(member.account.did.clone()));
    assert!(index.space_synced(&space).unwrap());
    wait_until(|| read("getTree", "")["checks"].as_array().unwrap().len() == 1);
    let check = format!("&uri=at://{}/dev.knotless.check/p1", member.account.did);
    assert_eq!(read("getCheck", &check)["check"]["workflow"], "test");
    assert_eq!(read("listRuns", "")["runs"].as_array().unwrap().len(), 1);
    // Its mirror is synced again only once its space's sync finds it
    // written to, as a push does.
    owner.commit_file(&work, "later.md", "# Later\n");
    owner.git(&work, &["push", "-q", &url, "main"]);
    wait_until(|| read("getTree", "").to_string().contains("later.md"));
    let thread = |uri: &str| format!("&uri={uri}");
    let first = read("getThread", &thread(&issue));
    assert_eq!(first["item"]["title"], "Private trouble");
    // What is written since is read on from each member's oplog: what is
    // added, and what is taken back.
    let again = run(&owner, &["issue", "create", &url, "More trouble"]);
    // An issue just filed is no thread until the sync finds it: no comments
    // are listed, not even none.
    let comments = || {
        let (_, _, _, said) = looked(&xrpc("getThread", &thread(&again)), theirs);
        let shown: Value = serde_json::from_str(&said).unwrap();
        shown["comments"].as_array().map(Vec::len)
    };
    let said = run(&owner, &["comment", "add", &url, &again, "Noted"]);
    wait_until(|| comments() == Some(1));
    run(&owner, &["comment", "delete", &url, &said]);
    wait_until(|| comments() == Some(0));
    // A repo whose writes no longer check out against the commit its
    // server vouches for is read whole, and what it took back meanwhile
    // goes too.
    let said = run(&owner, &["comment", "add", &url, &again, "Noted again"]);
    wait_until(|| comments() == Some(1));
    // Past any sync still writing where it got to.
    std::thread::sleep(Duration::from_secs(1));
    let (rev, _) = index.space_writer(&space, &did).unwrap().unwrap();
    let wrong = vec![0; atproto::space::STATE_BYTES];
    index.set_space_writer(&space, &did, &rev, &wrong).unwrap();
    run(&owner, &["comment", "delete", &url, &said]);
    wait_until(|| comments() == Some(0));
    assert_eq!(read("getRepo", "")["openIssues"], 2);
    // A record the member's server keeps cut short, so that it no longer
    // comes to the CID it was written with, is left out, and the rest of
    // their repo read all the same. Read whole, the repo does not come to
    // its commit, so it is read from the start of its oplog instead, which
    // gives each record's CID apart from its bytes.
    let theirs_did = member.account.did.clone();
    let checks = || {
        let records = index.space_records(&space, &theirs_did).unwrap();
        let checks = records
            .into_iter()
            .filter(|(c, _)| c == "dev.knotless.check");
        let mut rkeys: Vec<String> = checks.map(|(_, rkey)| rkey).collect();
        rkeys.sort();
        rkeys
    };
    let file = |rkey: &str| {
        let check = json!({
            "$type": "dev.knotless.check", "repo": reference, "ref": "refs/heads/main",
            "commit": owner.git(&work, &["rev-parse", "HEAD"]), "workflow": rkey,
            "status": "success", "startedAt": "2026-10-24T00:00:00.000Z",
        });
        member.put_space_record(&space, "dev.knotless.check", rkey, check);
    };
    file("p2");
    wait_until(|| checks() == ["p1", "p2"]);
    std::thread::sleep(Duration::from_secs(1));
    cut_short(&theirs_did, &space, "dev.knotless.check", "p2");
    let (rev, _) = index.space_writer(&space, &theirs_did).unwrap().unwrap();
    index
        .set_space_writer(&space, &theirs_did, &rev, &wrong)
        .unwrap();
    file("p3");
    wait_until(|| checks() == ["p1", "p3"]);
    let p3 = format!("&uri=at://{theirs_did}/dev.knotless.check/p3");
    assert_eq!(read("getCheck", &p3)["check"]["workflow"], "p3");

    // A member's reaction is in the space, and counted from here at once.
    let react = format!("{}/xrpc/dev.knotless.addReaction", server.base);
    let body = json!({"repo": at, "subject": issue, "content": "rocket"});
    let (status, _, state) = browse("POST", &react, theirs, None, Some(body));
    assert_eq!(status, 200, "{state}");
    assert_eq!(state["reactions"][0]["count"], 1);
    assert!(state["reactions"][0]["reaction"].is_string());
    assert!(member.records("dev.knotless.reaction").is_empty());
    assert_eq!(
        read("getThread", &thread(&issue))["reactions"][0]["content"],
        "rocket"
    );
    // Taken back, it leaves the space, and the count here at once.
    let unreact = format!("{}/xrpc/dev.knotless.removeReaction", server.base);
    let body = json!({"repo": at, "subject": issue, "content": "rocket"});
    let (status, _, left) = browse("POST", &unreact, theirs, None, Some(body));
    assert_eq!(status, 200, "{left}");
    assert_eq!(left["reactions"], json!([]));
    // Shared for writing with someone who never opened it, it names them
    // a collaborator, so it is in their search as they log in, asked after
    // for them; refused them, it is not. (Shared only to read, nothing
    // here names them until they read it.)
    let newcomer = person(account());
    run(
        &owner,
        &["account", "delegate", &owner.account.handle, &server.did],
    );
    let write = ["repo", "share", &url, &newcomer.account.handle, "--write"];
    run(&owner, &write);
    let named = || {
        let shown = server.app.spaces.as_ref().unwrap().repo(&at).unwrap();
        shown.unwrap().to_string().contains(&newcomer.account.did)
    };
    wait_until(named);
    let newcomer_in = session_cookie(&server, &newcomer.account);
    // Its private one, by owner and name: the test PDS's other accounts make
    // public repositories called kept too, which the search finds as well.
    let search = format!("{}/xrpc/dev.knotless.listRepos?q=kept", server.base);
    let finds = || {
        server.app.discovered.lock().unwrap().clear();
        let said: Value = serde_json::from_str(&looked(&search, Some(&newcomer_in)).3).unwrap();
        let mut repos = said["repos"].as_array().unwrap().iter();
        repos.any(|r| r["did"] == did.as_str() && r["name"] == "kept" && r["private"] == true)
    };
    wait_until(finds);
    run(
        &owner,
        &["repo", "share", &url, &newcomer.account.handle, "--undo"],
    );
    wait_until(|| !finds());

    let owner_in = session_cookie(&server, &owner.account);
    let listed = format!(
        "{}/xrpc/dev.knotless.getActorRepos?actor={did}",
        server.base
    );
    let mine = || {
        let (status, cache, _, said) = looked(&listed, Some(&owner_in));
        assert_eq!((status, cache.as_str()), (200, "private, no-store"));
        let said: Value = serde_json::from_str(&said).unwrap();
        let repos = said["repos"].as_array().unwrap().iter();
        let private = repos.filter(|r| r["private"] == true);
        let mut names: Vec<String> = private.map(|r| r["name"].to_string()).collect();
        names.sort();
        names
    };
    wait_until(|| mine() == ["\"found\"", "\"kept\""]);
    // Asked again, what each was counted as written in shows at once, and
    // is counted again behind it.
    assert_eq!(mine(), ["\"found\"", "\"kept\""]);
    let viewer = format!("{}/xrpc/dev.knotless.getViewer", server.base);
    let (_, _, me) = browse("GET", &viewer, Some(&owner_in), None, None);
    assert_eq!(me["spaces"], true, "{me}");
    let found = knotless::space::uri(&did, "found");
    let theirs_too = (found, did.clone());
    wait_until(|| server.app.syncers().contains(&theirs_too));
    // Nor is it listed to anyone else.
    let (_, _, _, said) = looked(&listed, theirs);
    assert!(!said.contains("found"), "{said}");

    // What is said on it is heard of by those it concerns, from here: the
    // owner of what the member said, the member of what came after.
    let heard = |cookie: &str| {
        let list = format!("{}/xrpc/dev.knotless.listNotifications", server.base);
        let (status, _, said) = browse("GET", &list, Some(cookie), None, None);
        assert_eq!(status, 200, "{said}");
        said["notifications"].as_array().unwrap().clone()
    };
    run(&member, &["comment", "add", &url, &issue, "Seen it"]);
    wait_until(|| heard(&owner_in).len() == 1);
    let note = &heard(&owner_in)[0];
    assert_eq!(note["reason"], "comment");
    assert_eq!(note["repo"]["name"], "kept");
    assert_eq!(note["title"], "Private trouble");
    let unread = format!("{}/xrpc/dev.knotless.getUnreadCount", server.base);
    let (_, _, count) = browse("GET", &unread, Some(&owner_in), None, None);
    assert_eq!(count["count"], 1);
    // An outsider it mentions hears nothing of it, nor that it is there.
    let thanks = format!("Thanks, and @{}", outsider.account.handle);
    run(&owner, &["comment", "add", &url, &issue, &thanks]);
    wait_until(|| heard(&member_in).len() == 1);
    let outsider_in = session_cookie(&server, &outsider.account);
    assert!(heard(&outsider_in).is_empty());

    // Kept or not, it is not there to anyone else, nor in the appview's
    // index.
    for cookie in [None, Some(outsider_in.as_str())] {
        for asked in [
            xrpc("getTree", ""),
            xrpc("listRuns", ""),
            xrpc("getCheck", &check),
            xrpc("getThread", &thread(&issue)),
        ] {
            assert_eq!(looked(&asked, cookie).0, 404, "{asked}");
        }
    }
    assert_eq!(server.app.index.as_ref().unwrap().repo(&at).unwrap(), None);

    // Its syncer logged out, it is not synced as them.
    assert_eq!(looked(&xrpc("getRepo", ""), Some(&owner_in)).0, 200);
    assert_eq!(syncer(), Some(did.clone()));
    let logout = format!("{}/oauth/logout", server.base);
    assert_eq!(browse("POST", &logout, Some(&owner_in), None, None).0, 200);
    assert!(server.app.syncers().is_empty());
    read("getRepo", "");
    assert_eq!(syncer(), Some(member.account.did.clone()));
    // Its syncer removed, its sync fails, and it is forgotten.
    run(
        &owner,
        &["repo", "share", &url, &member.account.handle, "--undo"],
    );
    wait_until(|| !followed());
    assert!(server.app.syncers().is_empty());
    assert!(index.space_records(&space, &did).unwrap().is_empty());
    // Deleted as its owner reads it, it is gone from their next look,
    // though they were shown it within the minute.
    let owner_in = session_cookie(&server, &owner.account);
    let owners = || looked(&xrpc("getRepo", ""), Some(&owner_in)).0;
    assert_eq!(owners(), 200);
    run(&owner, &["repo", "delete", &url, "--yes"]);
    wait_until(|| owners() == 404);
    // Nor does one taken out of it hear of it any more.
    server.app.reprove.store(0, Ordering::Relaxed);
    assert!(heard(&member_in).is_empty());
}

/// A private repository made from the command line is in this server's
/// index without anyone looking, its owner logged in here: the command
/// asks its space's authority to tell this server of each write there,
/// its first the repository's own record. What is written after is heard
/// of the same way, long before the next sweep, and a space deleted is
/// forgotten. Only a space's own authority is heard.
#[test]
fn private_repositories_heard_of() {
    let server = gateway_with(None, false);
    // Swept once, at the start; after that, synced only as told.
    let every = Duration::from_secs(3600);
    server
        ._rt
        .spawn(knotless_serve::spaces::follow(server.app.clone(), every));
    let mut owner = person(account());
    owner.set("KNOTLESS_APPVIEW", &server.base);
    let did = owner.account.did.clone();
    let tell = |lxm: &str, space: &str, auth: Option<&str>| {
        let url = format!("{}/xrpc/{lxm}", server.base);
        let mut req = reqwest::blocking::Client::new().post(url);
        if let Some(auth) = auth {
            req = req.header("authorization", format!("Bearer {auth}"));
        }
        let res = req.json(&json!({ "space": space })).send().unwrap();
        res.status().as_u16()
    };
    let written = knotless_serve::spaces::NOTIFY_WRITE;
    let space = knotless::space::uri(&did, "heard");
    // With no token, or one for this server rather than its appview, it
    // is not heard.
    assert_eq!(tell(written, &space, None), 401);
    let bare = token(&owner, &server.did, written);
    assert_eq!(tell(written, &space, Some(&bare)), 401);
    if !net().spaces {
        return;
    }
    let appview = format!("{}{}", server.did, knotless::space::APPVIEW);
    let told = token(&owner, &appview, written);
    // Nor of a space not its own, or not a private repository's.
    let elsewhere = knotless::space::uri("did:plc:elsewhere", "heard");
    assert_eq!(tell(written, &elsewhere, Some(&told)), 403);
    let other = format!("at://{did}/space/app.example.space/heard");
    assert_eq!(tell(written, &other, Some(&told)), 403);
    // Of an owner not logged in here, nothing is followed.
    assert_eq!(tell(written, &space, Some(&told)), 200);
    assert!(server.app.syncers().is_empty());

    let owner_in = session_cookie(&server, &owner.account);
    let index = server.app.spaces.clone().unwrap();
    let url = owner.url("heard");
    let (code, out) = owner.cli(&["repo", "create", &url, "--private"], b"");
    assert_eq!(code, 0, "{out}");
    wait_until(|| index.space_synced(&space).unwrap());
    let listed = format!(
        "{}/xrpc/dev.knotless.getActorRepos?actor={did}",
        server.base
    );
    let (status, _, _, said) = looked(&listed, Some(&owner_in));
    assert_eq!(status, 200, "{said}");
    assert!(said.contains("\"heard\""), "{said}");
    // An issue filed after is heard of too.
    let issues = || {
        let records = index.space_records(&space, &did).unwrap();
        records
            .iter()
            .filter(|(c, _)| c == "dev.knotless.issue")
            .count()
    };
    let (code, out) = owner.cli(&["issue", "create", &url, "Heard of"], b"");
    assert_eq!(code, 0, "{out}");
    wait_until(|| issues() == 1);
    // Deleted, it is forgotten.
    let (code, out) = owner.cli(&["repo", "delete", &url, "--yes"], b"");
    assert_eq!(code, 0, "{out}");
    let followed = || index.spaces().unwrap().iter().any(|(s, _)| *s == space);
    wait_until(|| !followed());
    let deleted = knotless_serve::spaces::NOTIFY_DELETED;
    let told = token(&owner, &appview, deleted);
    assert_eq!(tell(deleted, &space, Some(&told)), 200);
    // Told of a write there all the same, it is followed again and refused
    // as deleted, so is not followed again as its owner logs in, though
    // their PDS may name it still.
    let told = token(&owner, &appview, written);
    assert_eq!(tell(written, &space, Some(&told)), 200);
    wait_until(|| index.gone_spaces().unwrap().contains(&space) && !followed());
}

/// Members of a private repository who read and write it only from their
/// clients, logged in to nothing here, hear of what is said on it all the
/// same: each client shows this server the repository with a delegation
/// token as it writes there, and the owner names whom they shared it with
/// in its record. Someone it mentions who is not a member hears nothing,
/// and a member taken out of it hears no more.
#[test]
fn private_repositories_heard_of_from_clients() {
    if !net().spaces {
        return;
    }
    let server = gateway_with(None, true);
    let [mut owner, mut member, outsider] = people();
    owner.set("KNOTLESS_APPVIEW", &server.base);
    member.set("KNOTLESS_APPVIEW", &server.base);
    let run = |s: &Sandbox, args: &[&str]| {
        let (code, out) = s.cli(args, b"");
        assert_eq!(code, 0, "{args:?}: {out}");
        out.trim().to_string()
    };
    let heard = |s: &Sandbox| {
        let url = format!("{}/xrpc/dev.knotless.listNotifications", server.base);
        let token = token(s, &server.did, "dev.knotless.listNotifications");
        let res = reqwest::blocking::Client::new()
            .get(url)
            .bearer_auth(token)
            .send()
            .unwrap();
        assert_eq!(res.status().as_u16(), 200);
        let said: Value = res.json().unwrap();
        said["notifications"].as_array().unwrap().clone()
    };
    let url = owner.url("roadmap");
    run(&owner, &["repo", "create", &url, "--private"]);
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    run(&owner, &["repo", "share", &url, &member.account.handle]);
    let title = format!(
        "Next quarter, @{} and @{}",
        member.account.handle, outsider.account.handle
    );
    let issue = run(&owner, &["issue", "create", &url, &title]);
    // No one here has a session to sync it with.
    assert!(server.app.syncers().is_empty());

    let told = heard(&member);
    assert_eq!(told.len(), 1, "{told:?}");
    assert_eq!(told[0]["reason"], "mention");
    assert_eq!(told[0]["repo"]["name"], "roadmap");
    assert_eq!(told[0]["title"], title.as_str());
    assert!(heard(&outsider).is_empty());

    // A reply mentioning the owner, on their own issue, is heard of once,
    // as a comment.
    let reply = format!("Looks right, @{}", owner.account.handle);
    run(&member, &["comment", "add", &url, &issue, &reply]);
    let told = heard(&owner);
    assert_eq!(told.len(), 1, "{told:?}");
    assert_eq!(told[0]["reason"], "comment");
    assert_eq!(told[0]["author"]["did"], member.account.did.as_str());
    assert!(heard(&outsider).is_empty());

    // Taken out of it, the member hears no more of it, once what they read
    // here is to be proven again.
    run(
        &owner,
        &["repo", "share", &url, &member.account.handle, "--undo"],
    );
    server.app.reprove.store(0, Ordering::Relaxed);
    assert!(heard(&member).is_empty());
}

/// A private repository's index stays current when the member it is
/// synced as can no longer sync it, as when their session here lapses or
/// lacks the scope its space needs: a member reading it syncs it with
/// their own credential once it was not synced within a sweep or two,
/// rather than every reader seeing it as it was until that member logs in
/// again. A reader's sync that fails leaves the read to the index as it
/// was, and is not tried again at the next read.
#[test]
fn private_repositories_synced_by_readers_once_their_syncer_lapses() {
    if !net().spaces {
        return;
    }
    let server = gateway_with(None, true);
    let [mut owner, mut member, mut other] = people();
    for s in [&mut owner, &mut member, &mut other] {
        s.set("KNOTLESS_APPVIEW", &server.base);
    }
    let run = |s: &Sandbox, args: &[&str]| {
        let (code, out) = s.cli(args, b"");
        assert_eq!(code, 0, "{args:?}: {out}");
        out.trim().to_string()
    };
    let url = owner.url("ledger");
    run(&owner, &["repo", "create", &url, "--private"]);
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    run(&owner, &["repo", "share", &url, &member.account.handle]);
    run(&owner, &["repo", "share", &url, &other.account.handle]);
    let did = owner.account.did.clone();
    let at = format!("at://{did}/dev.knotless.repo/ledger");
    let client = reqwest::blocking::Client::new();

    // The owner reads it on the pages, and so it is synced as them.
    let cookie = session_cookie(&server, &owner.account);
    let res = client
        .get(format!(
            "{}/xrpc/dev.knotless.getRepo?repo={at}",
            server.base
        ))
        .header("cookie", &cookie)
        .send()
        .unwrap();
    assert_eq!(res.status().as_u16(), 200);
    let syncers = server.app.syncers();
    assert!(syncers.iter().any(|(_, d)| *d == did), "{syncers:?}");

    // Once it was not synced lately, the owner's read is answered from the
    // index as it was, and the space syncs aside, taking in what was
    // written since.
    run(&member, &["issue", "create", &url, "Written while synced"]);
    server.app.last_synced.lock().unwrap().clear();
    let owners = || {
        let res = client
            .get(format!(
                "{}/xrpc/dev.knotless.listItems?repo={at}&kind=issue",
                server.base
            ))
            .header("cookie", &cookie)
            .send()
            .unwrap();
        assert_eq!(res.status().as_u16(), 200);
        res.json::<Value>().unwrap()["items"].to_string()
    };
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
    while !owners().contains("Written while synced") {
        assert!(std::time::Instant::now() < deadline, "never synced aside");
        std::thread::sleep(std::time::Duration::from_millis(100));
    }
    assert!(
        server
            .app
            .last_synced
            .lock()
            .unwrap()
            .contains_key(&knotless::space::uri(&did, "ledger"))
    );

    // Their session here lapses, and they stay its syncer: each sync as
    // them fails, though not as one refused, which would let it go.
    let pages = knotless_serve::login::ctx_for(&server.app, &did);
    knotless::session::remove(&pages, &did);
    run(
        &member,
        &["issue", "create", &url, "Written after the lapse"],
    );

    // The issues a member reads, with a delegation token of theirs, each
    // spent once; once the space counts as not synced lately.
    let rt = tokio::runtime::Runtime::new().unwrap();
    let c = ctx(&member);
    let me = rt.block_on(knotless::session::me(&c)).unwrap();
    let space = knotless::space::uri(&did, "ledger");
    let stale = || server.app.last_synced.lock().unwrap().clear();
    let listed = || {
        let token = rt.block_on(knotless::space::delegation(&c, &space, &me));
        let res = client
            .get(format!(
                "{}/xrpc/dev.knotless.listItems?repo={at}&kind=issue",
                server.base
            ))
            .header(knotless::space::DELEGATION_HEADER, token.unwrap())
            .send()
            .unwrap();
        assert_eq!(res.status().as_u16(), 200);
        let listed: Value = res.json().unwrap();
        let mut titles: Vec<String> = listed["items"]
            .as_array()
            .unwrap()
            .iter()
            .filter_map(|i| i["title"].as_str().map(String::from))
            .collect();
        titles.sort();
        titles
    };

    // Once it was not synced lately, a member's read syncs it as them.
    stale();
    assert_eq!(
        listed(),
        ["Written after the lapse", "Written while synced"]
    );

    // A member whose repo cannot be read fails the sync: one who writes,
    // then is taken down where they live. The read is answered from the
    // index as it was, and the sync that failed counts as one made.
    run(
        &other,
        &["issue", "create", &url, "Written before the takedown"],
    );
    let takedown = |applied: bool| {
        let subject = json!({"$type": "com.atproto.admin.defs#repoRef", "did": other.account.did});
        let res = client
            .post(format!(
                "{}/xrpc/com.atproto.admin.updateSubjectStatus",
                net().pds
            ))
            .header("authorization", format!("Basic {}", admin()))
            .json(&json!({"subject": subject, "takedown": {"applied": applied}}))
            .send()
            .unwrap();
        assert!(res.status().is_success(), "{}", res.text().unwrap());
    };
    takedown(true);
    stale();
    assert_eq!(
        listed(),
        ["Written after the lapse", "Written while synced"]
    );
    assert!(server.app.last_synced.lock().unwrap().contains_key(&space));

    // Restored, it is taken in at the next read that finds it stale.
    takedown(false);
    stale();
    assert_eq!(
        listed(),
        [
            "Written after the lapse",
            "Written before the takedown",
            "Written while synced"
        ]
    );
}

/// From the repository's settings, its owner sees its runners' keys, and
/// leaves, lists and takes back secrets sealed to them in the browser,
/// with the pages' login rather than a service token.
#[test]
fn secrets_from_the_pages() {
    let server = gateway_with(None, true);
    let mut owner = person(account());
    owner.set("KNOTLESS_APPVIEW", &server.base);
    let runner = person(account());
    let url = owner.url("sealing");
    let work = owner.work("w", 1);
    owner.commit_file(&work, "f", "f\n");
    owner.git(&work, &["push", "-q", &url, "main"]);
    let (code, out) = owner.cli(&["runner", "add", &url, &runner.account.handle], b"");
    assert_eq!(code, 0, "{out}");
    let (od, rd) = (owner.account.did.clone(), runner.account.did.clone());
    let key = owner.record("dev.knotless.repo", "sealing")["key"]
        .as_str()
        .unwrap()
        .to_string();
    let repo = format!("at://{od}/dev.knotless.repo/sealing");
    let xrpc = |nsid: &str| format!("{}/xrpc/dev.knotless.{nsid}", server.base);
    let mine = session_cookie(&server, &owner.account);
    let theirs = session_cookie(&server, &runner.account);
    let (me, them) = (Some(mine.as_str()), Some(theirs.as_str()));

    // The key the runner publishes, once indexed, is beside it.
    let dir = tempfile::tempdir().unwrap();
    let keys = knotless::sealed::RunnerKeys::open_or_make(&dir.path().join("k.json")).unwrap();
    let did_key = keys.public().did_key();
    let published = json!({
        "$type": knotless::sealed::KEY_NSID,
        "key": did_key,
        "createdAt": "2026-10-30T00:00:00.000Z",
    });
    runner.put_record(
        knotless::sealed::KEY_NSID,
        knotless::sealed::KEY_RKEY,
        published,
    );
    let listed = || {
        let url = format!("{}?repo={repo}", xrpc("listRunners"));
        browse("GET", &url, None, None, None).2
    };
    wait_until(|| listed()["keys"].is_array());
    assert_eq!(listed()["keys"], json!([{"runner": rd, "key": did_key}]));

    // Its owner leaves one, sealed as the browser seals it, and lists it.
    let bound = knotless::sealed::Bound {
        owner: &od,
        repo: &key,
        runner: &rd,
        name: "TOKEN",
        refs: &[],
    };
    let sealed = knotless::sealed::seal(&keys.public(), &bound, b"s3cret").unwrap();
    use base64::Engine;
    let bytes = base64::engine::general_purpose::STANDARD.encode(&sealed);
    let secret = json!({
        "repo": key, "runner": rd, "name": "TOKEN", "key": did_key,
        "sealed": {"$bytes": bytes},
    });
    let write = |nsid: &str, cookie: Option<&str>, origin: Option<&str>, body: &Value| {
        browse("POST", &xrpc(nsid), cookie, origin, Some(body.clone()))
    };
    assert_eq!(write("putSecret", None, None, &secret).0, 401);
    let elsewhere = Some("https://elsewhere.example");
    assert_eq!(write("putSecret", me, elsewhere, &secret).0, 403);
    let (code, _, said) = write("putSecret", me, None, &secret);
    assert_eq!(code, 200, "{said}");
    let list = |cookie| {
        let url = format!("{}?owner={od}&repo={key}", xrpc("listSecrets"));
        browse("GET", &url, cookie, None, None)
    };
    let (code, _, all) = list(me);
    assert_eq!(code, 200, "{all}");
    assert_eq!(all["secrets"][0]["name"], "TOKEN");
    assert!(all["secrets"][0]["sealed"].is_null(), "{all}");
    // Logged in as the runner, it is listed sealed, to open.
    let (_, _, its) = list(them);
    assert!(its["secrets"][0]["sealed"]["$bytes"].is_string(), "{its}");
    assert_eq!(list(None).0, 401);

    // And takes it back.
    let gone = json!({"repo": key, "name": "TOKEN"});
    let (code, _, said) = write("deleteSecret", me, None, &gone);
    assert_eq!((code, &said["deleted"]), (200, &json!(1)), "{said}");
    assert_eq!(list(me).2, json!({"secrets": []}));
}