chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs the knotless git helper. set it up

1131 lines · 40.5 KB Raw History
//! What the web pages show, as knotless-serve answers it under `/_data`
//! and `dev.knotless.getFeed`, over a reference PDS with an index following
//! its firehose; and the pages themselves, handed to the app that draws them.

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

use knotless_serve::index::Index;
use knotless_serve::{App, router};
use reqwest::blocking::{Client, Response};
use serde_json::Value;
use std::sync::Arc;
use std::time::Duration;

struct Site {
    base: String,
    _rt: tokio::runtime::Runtime,
    _sandbox: Sandbox,
}

fn serve(indexed: bool) -> Site {
    serve_as(Sandbox::new(), indexed, false)
}

/// A server reading as `s`, private repositories too if `private`.
fn serve_as(s: Sandbox, indexed: bool, private: bool) -> Site {
    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 index = indexed.then(|| Index::open(&s.path("index.db")).unwrap());
    let app: Arc<App> = App::build(ctx(&s), Duration::ZERO, None, index, private);
    let router = router(app.clone());
    rt.spawn(async move { axum::serve(listener, router).await.unwrap() });
    if indexed {
        rt.spawn(knotless_serve::firehose::watch(
            app,
            net().pds.replace("http://", "ws://"),
        ));
    }
    Site {
        base,
        _rt: rt,
        _sandbox: s,
    }
}

impl Site {
    fn get(&self, path: &str) -> Response {
        Client::new()
            .get(format!("{}{path}", self.base))
            .send()
            .unwrap()
    }

    /// The answer at `path`, which must be there.
    fn json(&self, path: &str) -> Value {
        let res = self.get(path);
        assert_eq!(res.status().as_u16(), 200, "{path}");
        res.json().unwrap()
    }

    /// What the page at `path` shows.
    fn data(&self, path: &str) -> Value {
        self.json(&format!("/_data{path}"))
    }

    /// The feed back to `since`, page by page.
    fn feed(&self, since: &str) -> Value {
        let mut events = Vec::new();
        let mut cursor = String::new();
        loop {
            let page = self.json(&format!(
                "/xrpc/dev.knotless.getFeed?limit=100&cursor={}",
                cursor.replace(' ', "%20")
            ));
            events.extend(page["events"].as_array().unwrap().iter().cloned());
            let last = events.last().map(|e| e["createdAt"].as_str().unwrap());
            match page["cursor"].as_str() {
                Some(next) if last.is_some_and(|at| at >= since) => cursor = next.into(),
                _ => return Value::Array(events),
            }
        }
    }

    fn status(&self, path: &str) -> (u16, String) {
        let res = self.get(path);
        (res.status().as_u16(), res.text().unwrap())
    }
}

/// A card's PNG, which must be there, with its cache control.
fn card(site: &Site, path: &str) -> (Vec<u8>, String) {
    let res = site.get(path);
    assert_eq!(res.status().as_u16(), 200, "{path}");
    assert_eq!(res.headers()["content-type"], "image/png");
    let cache = res.headers()["cache-control"].to_str().unwrap().to_string();
    let png = res.bytes().unwrap().to_vec();
    assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");
    // The width and height, from the header chunk.
    assert_eq!(png[16..24], [0, 0, 4, 176, 0, 0, 2, 118]);
    (png, cache)
}

fn person(account: Account, site: &Site) -> Sandbox {
    let mut s = Sandbox::with(account);
    s.set("KNOTLESS_PASSWORD", "");
    s.set("KNOTLESS_APPVIEW", &site.base);
    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
}

fn ok(s: &Sandbox, args: &[&str]) -> String {
    let (code, out) = s.cli(args, b"");
    assert_eq!(code, 0, "{args:?}: {out}");
    out
}

fn write(dir: &std::path::Path, path: &str, bytes: &[u8]) {
    let path = dir.join(path);
    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    std::fs::write(path, bytes).unwrap();
}

/// The entry named `name` among `entries`.
fn named<'a>(entries: &'a Value, name: &str) -> &'a Value {
    entries
        .as_array()
        .unwrap()
        .iter()
        .find(|e| e["name"] == name)
        .unwrap_or_else(|| panic!("no {name} in {entries}"))
}

fn names(entries: &Value) -> Vec<&str> {
    entries
        .as_array()
        .unwrap()
        .iter()
        .map(|e| e["name"].as_str().unwrap())
        .collect()
}

#[test]
fn browsing_a_repository() {
    let site = serve(true);
    let s = person(account(), &site);
    let url = s.url("browsed");
    let work = s.work("w", 51);
    write(&work, "README.md", b"# Browsed\n\n<script>alert(1)</script>\n\n[guide](docs/guide.md) [x](javascript:alert(1)) ![logo](logo.png)\n");
    write(&work, "docs/README", b"plain <words>\n");
    write(&work, "docs/guide.md", b"# Guide\n");
    write(
        &work,
        "src/main.rs",
        "// héllo\nfn main() {\n    println!(\"hi\");\n}\n".as_bytes(),
    );
    write(&work, "logo.png", b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR");
    write(&work, "data.bin", &[0, 1, 2, 3]);
    write(&work, "big.txt", &vec![b'a'; 1100 * 1024]);
    write(&work, "notes.unknown-kind", b"no grammar\n");
    s.git(&work, &["add", "-A"]);
    s.git(
        &work,
        &[
            "commit",
            "-q",
            "-m",
            "Lay out the tree\n\nWith a body that says why.",
        ],
    );
    let tip = s.git(&work, &["rev-parse", "HEAD"]);
    s.git(&work, &["push", "-q", &url, "main", "main:feature/x"]);
    let h = &s.account.handle;
    let repo = format!("/{h}/browsed");

    // The search finds it once the index has seen it.
    let found = |q: &str| site.json(&format!("/_data/search?q={q}"));
    wait_until(|| names(&found("")["repos"]).contains(&"browsed"));
    let upper = found("BROWS");
    assert_eq!(upper["query"], "BROWS");
    assert!(names(&upper["repos"]).contains(&"browsed"));
    assert_eq!(upper["repos"][0]["owner"], h.as_str());
    assert_eq!(found("zzzz")["repos"], serde_json::json!([]));

    let res = site.get(&format!("/_data{repo}"));
    assert_eq!(res.headers()["cache-control"], "no-cache");
    let top: Value = res.json().unwrap();
    assert_eq!(top["empty"], false);
    assert_eq!(top["rev"], "main");
    assert_eq!(top["repo"]["head"], "main");
    assert_eq!(top["repo"]["authority"], h.as_str());
    assert!(
        top["last"]["message"]
            .as_str()
            .unwrap()
            .starts_with("Lay out the tree")
    );
    // Folders first, then files, each by name.
    let listed = names(&top["entries"]);
    assert!(
        listed.iter().position(|n| *n == "src") < listed.iter().position(|n| *n == "README.md")
    );
    assert_eq!(named(&top["entries"], "docs")["kind"], "tree");
    assert_eq!(named(&top["entries"], "data.bin")["size"], 4);
    let readme = top["readme"]["html"].as_str().unwrap();
    for want in [
        "<h1>Browsed</h1>",
        "&lt;script&gt;alert(1)&lt;/script&gt;",
        &format!(r#"href="{repo}/blob/main/docs/guide.md""#),
        r##"href="#""##,
        &format!(r#"src="{repo}/raw/main/logo.png""#),
    ] {
        assert!(readme.contains(want), "{want} not in\n{readme}");
    }

    // Each page says what it is to whatever unfurls a link to it, and
    // names a card: cached for good under the version it names, checked
    // again under any other.
    assert_eq!(
        top["repo"]["about"],
        format!("A git repository in {h}'s atmosphere account.")
    );
    let named_card = top["repo"]["card"].as_str().unwrap().to_string();
    let unversioned = format!("{repo}/card.png");
    assert!(
        named_card.starts_with(&format!("{unversioned}?v=")),
        "{named_card}"
    );
    let (png, cache) = card(&site, &named_card);
    assert_eq!(cache, "public, max-age=31536000, immutable");
    assert_eq!(card(&site, &unversioned), (png, "no-cache".to_string()));
    assert_eq!(
        site.data(&format!("{repo}/commits/main"))["repo"]["card"],
        named_card.as_str()
    );
    let tag = site.get(&named_card).headers()["etag"].clone();
    let again = Client::new()
        .get(format!("{}{named_card}", site.base))
        .header("if-none-match", tag.clone())
        .send()
        .unwrap();
    assert_eq!(again.status().as_u16(), 304);
    assert_eq!(again.headers()["etag"], tag);
    let (_, cache) = card(&site, "/card.png");
    assert_eq!(cache, "no-cache");

    // Compressed for those that ask.
    let res = Client::builder()
        .no_gzip()
        .no_brotli()
        .build()
        .unwrap()
        .get(format!("{}/_data{repo}", site.base))
        .header("accept-encoding", "br")
        .send()
        .unwrap();
    assert_eq!(res.headers()["content-encoding"], "br");

    // A branch with a slash in its name, and a pinned commit, whose answer is
    // still checked again: it shows the repository as it is now.
    let sub = site.data(&format!("{repo}/tree/feature/x/src"));
    assert_eq!(
        (sub["rev"].as_str(), sub["path"].as_str()),
        (Some("feature/x"), Some("src"))
    );
    assert_eq!(names(&sub["entries"]), ["main.rs"]);
    let res = site.get(&format!("/_data{repo}/tree/{tip}/docs"));
    assert_eq!(res.headers()["cache-control"], "no-cache");
    let docs: Value = res.json().unwrap();
    assert_eq!(docs["readme"]["text"], "plain <words>\n");
    assert_eq!(docs["readme"]["html"], Value::Null);

    let blob = |path: &str| site.data(&format!("{repo}/blob/main/{path}"));
    let file = blob("src/main.rs");
    assert_eq!(file["shown"], "text");
    assert_eq!(file["lines"], 4);
    assert!(
        file["ranges"].as_str().unwrap().starts_with("comment:0,8;"),
        "{file}"
    );
    assert!(
        file["text"]
            .as_str()
            .unwrap()
            .starts_with("// héllo\nfn main()")
    );
    assert_eq!(blob("notes.unknown-kind")["ranges"], "");
    assert_eq!(blob("logo.png")["shown"], "image");
    assert_eq!(blob("data.bin")["shown"], "binary");
    let big = blob("big.txt");
    assert_eq!(big["shown"], "large");
    assert_eq!(big["text"], Value::Null);
    assert_eq!(big["size"], 1100 * 1024);

    for (path, kind) in [
        ("logo.png", "image/png"),
        ("data.bin", "application/octet-stream"),
        ("src/main.rs", "text/plain; charset=utf-8"),
    ] {
        let res = site.get(&format!("{repo}/raw/main/{path}"));
        assert_eq!(res.headers()["content-type"], kind);
        assert!(
            res.headers()["content-security-policy"]
                .to_str()
                .unwrap()
                .starts_with("sandbox")
        );
    }

    let log = site.data(&format!("{repo}/commits/main"));
    assert_eq!(log["commits"].as_array().unwrap().len(), 50);
    assert_eq!(
        (log["page"].as_u64(), log["more"].as_bool()),
        (Some(0), Some(true))
    );
    let older = site.data(&format!("{repo}/commits/main?page=1"));
    assert_eq!(older["commits"].as_array().unwrap().len(), 2);
    assert_eq!(older["more"], false);
    let src = site.data(&format!("{repo}/commits/main/src"));
    assert_eq!(src["commits"].as_array().unwrap().len(), 1);
    assert_eq!(src["path"], "src");
    assert_eq!(
        site.data(&format!("{repo}/commits/main/nowhere"))["commits"],
        serde_json::json!([])
    );

    let commit = site.data(&format!("{repo}/commit/{tip}"));
    assert_eq!(commit["commit"]["sha"], tip.as_str());
    assert_eq!(
        commit["commit"]["message"],
        "Lay out the tree\n\nWith a body that says why."
    );
    assert_eq!(commit["commit"]["parents"].as_array().unwrap().len(), 1);
    let files = commit["files"].as_array().unwrap();
    let main = files.iter().find(|f| f["new"] == "src/main.rs").unwrap();
    assert_eq!(
        (main["status"].as_str(), main["added"].as_u64()),
        (Some("added"), Some(4))
    );
    assert!(main["ranges"].as_str().unwrap().starts_with("comment:0,8;"));
    let last = main["rows"].as_array().unwrap().last().unwrap();
    assert_eq!(
        (
            last["kind"].as_str(),
            last["new"].as_u64(),
            last["text"].as_str()
        ),
        (Some("a"), Some(4), Some("}"))
    );
    // A commit shows as its author's account when its address is one the
    // repository's own people claim: the first push claimed git's. One
    // knotless made names its account; one by any other address is no
    // one's. So with co-authors, whom its trailers name.
    assert_eq!(commit["commit"]["author"]["account"], h.as_str());
    assert_eq!(top["last"]["author"]["account"], h.as_str());
    let made = |message: &str, author: &[&str]| {
        let args = [
            &[
                "commit", &url, "-b", "made", "-m", message, "--put", "made", "-",
            ],
            author,
        ]
        .concat();
        let (code, sha) = s.cli(&args, message.as_bytes());
        assert_eq!(code, 0, "{sha}");
        let at = format!("{repo}/commit/{}", sha.trim());
        wait_until(|| site.status(&format!("/_data{at}")).0 == 200);
        site.data(&at)["commit"].clone()
    };
    let ours = made("Made", &[]);
    assert_eq!(ours["author"]["account"], h.as_str());
    assert_eq!(ours["coauthors"], serde_json::json!([]));
    let together = "Together\n\nCo-authored-by: Claude <noreply@anthropic.com>\nCo-authored-by: Me <t@example.com>";
    let theirs = made(together, &["--author", "Someone", "someone@example.org"]);
    let (author, helped) = (&theirs["author"], &theirs["coauthors"]);
    assert_eq!(
        (&author["name"], &author["account"], &author["avatar"]),
        (&Value::from("Someone"), &Value::Null, &Value::Null)
    );
    assert_eq!(
        (&helped[0]["name"], &helped[0]["account"]),
        (&Value::from("Claude"), &Value::Null)
    );
    assert_eq!(
        (&helped[1]["name"], &helped[1]["account"]),
        (&Value::from("Me"), &Value::from(h.as_str()))
    );

    let first = s.git(&work, &["rev-list", "--max-parents=0", "HEAD"]);
    assert_eq!(
        site.data(&format!("{repo}/commit/{first}"))["commit"]["parents"],
        serde_json::json!([])
    );

    for missing in [
        format!("/_data{repo}/commit/0000000000000000000000000000000000000000"),
        format!("/_data{repo}/tree/main/nope"),
        format!("/_data{repo}/blob/main/nope"),
        format!("{repo}/raw/main/nope"),
        format!("/_data{repo}/commits/nope"),
        format!("/_data{repo}/tree/-x"),
        format!("/_data{repo}/commit/-x"),
        format!("/_data{repo}/wiki/did:plc:x/y"),
        format!("/_data/{h}/missing"),
    ] {
        let res = site.get(&missing);
        let status = res.status().as_u16();
        assert!(status == 404 || status == 502, "{missing}: {status}");
        assert_eq!(res.headers()["cache-control"], "no-store", "{missing}");
        let failure: Value = res.json().unwrap();
        assert!(failure["message"].is_string(), "{missing}");
    }

    // A repository with nothing in it yet.
    ok(&s, &["create", &s.url("empty")]);
    let empty = site.data(&format!("/{h}/empty"));
    assert_eq!(empty["empty"], true);
    assert_eq!(empty["entries"], serde_json::json!([]));
}

#[test]
fn pulls_and_issues() {
    let site = serve(true);
    let owner = person(account(), &site);
    let other = person(account(), &site);
    let url = owner.url("tracked");
    ok(
        &owner,
        &["create", &url, "--description", "Tracks <things>"],
    );
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let repo = format!("/{}/tracked", owner.account.handle);

    let fork = ok(&other, &["fork", &url]);
    let clone = other.path("clone");
    other.git(other.dir.path(), &["clone", "-q", &fork, "clone"]);
    ok(&other, &["email", "add", "other@example.com"]);
    write(&clone, "b", b"b\n");
    other.git(&clone, &["add", "b"]);
    let as_other = [
        "commit",
        "-q",
        "--author",
        "Other <other@example.com>",
        "-m",
        "b",
    ];
    other.git(&clone, &as_other);
    let brought = other.git(&clone, &["rev-parse", "HEAD"]);
    other.git(&clone, &["push", "-q", "origin", "main:topic"]);
    let pull = ok(
        &other,
        &[
            "pull",
            &url,
            "main",
            &fork,
            "topic",
            "Add b",
            "-m",
            "Adds **b**.",
        ],
    );
    let issue = ok(&other, &["issue", &url, "Broken <thing>"]);
    let quiet = ok(&other, &["issue", &url, "Quiet"]);
    ok(&owner, &["comment", &url, &pull, "Not yet", "--changes"]);
    ok(&other, &["comment", &url, &pull, "Fixed", "--approve"]);
    ok(&owner, &["comment", &url, &pull, "Thanks"]);
    ok(&owner, &["close", &url, &quiet]);
    let merged = ok(&owner, &["merge", &url, &pull]);

    let rkey = |uri: &str| uri.rsplit('/').next().unwrap().to_string();
    let other_did = &other.account.did;
    let thread = format!("{repo}/pull/{other_did}/{}", rkey(&pull));
    wait_until(|| site.data(&thread)["merged"].is_string());

    // What the pull brought in shows as its author's: the merge vouched for
    // it. Their address anywhere else is no one's.
    let theirs = site.data(&format!("{repo}/commit/{brought}"));
    assert_eq!(
        theirs["commit"]["author"]["account"],
        other.account.handle.as_str()
    );
    let args = [
        "commit",
        &url,
        "-b",
        "aside",
        "-m",
        "Not theirs",
        "--put",
        "c",
        "-",
    ];
    let author = ["--author", "Other", "other@example.com"];
    let (code, sha) = owner.cli(&[&args[..], &author].concat(), b"c\n");
    assert_eq!(code, 0, "{sha}");
    let aside = format!("/_data{repo}/commit/{}", sha.trim());
    wait_until(|| site.status(&aside).0 == 200);
    assert_eq!(
        site.json(&aside)["commit"]["author"]["account"],
        Value::Null
    );
    let d = site.data(&thread);
    assert_eq!(
        (d["kind"].as_str(), d["title"].as_str()),
        (Some("pull"), Some("Add b"))
    );
    assert_eq!(d["state"], "merged");
    assert_eq!(d["author"], other.account.handle.as_str());
    assert!(d["body"].as_str().unwrap().contains("<strong>b</strong>"));
    let verdicts: Vec<&Value> = d["comments"]
        .as_array()
        .unwrap()
        .iter()
        .map(|c| &c["verdict"])
        .collect();
    assert_eq!(
        verdicts,
        [
            &Value::from("changes"),
            &Value::from("approve"),
            &Value::Null
        ]
    );
    assert_eq!(d["comments"][2]["html"], "<p>Thanks</p>\n");
    assert_eq!(d["comments"][2]["author"], owner.account.handle.as_str());
    assert!(
        merged
            .trim()
            .starts_with(&d["merged"].as_str().unwrap()[..7]),
        "{merged}"
    );
    assert_eq!(
        (d["base"].as_str(), d["from"].as_str(), d["branch"].as_str()),
        (
            Some("main"),
            Some(format!("{}/tracked", other.account.handle).as_str()),
            Some("topic")
        )
    );

    let items = |tail: &str| site.data(&format!("{repo}{tail}"));
    let titles = |v: &Value| -> Vec<String> {
        v["items"]
            .as_array()
            .unwrap()
            .iter()
            .map(|i| i["title"].as_str().unwrap().to_string())
            .collect()
    };
    wait_until(|| titles(&items("/issues?state=closed")).contains(&"Quiet".to_string()));
    assert_eq!(items("")["repo"]["description"], "Tracks <things>");
    let open = items("/issues");
    assert_eq!(titles(&open), ["Broken <thing>"]);
    assert_eq!(
        (open["opened"].as_u64(), open["closed"].as_u64()),
        (Some(1), Some(1))
    );
    assert_eq!(
        (open["kind"].as_str(), open["open"].as_bool()),
        (Some("issue"), Some(true))
    );
    assert_eq!(
        (
            open["repo"]["issues"].as_u64(),
            open["repo"]["pulls"].as_u64()
        ),
        (Some(1), Some(0))
    );
    assert_eq!(
        open["items"][0]["href"],
        format!("{repo}/issue/{other_did}/{}", rkey(&issue))
    );
    assert_eq!(items("/pulls")["items"], serde_json::json!([]));
    let closed = items("/pulls?state=closed");
    assert_eq!(closed["open"], false);
    assert_eq!(
        (
            closed["items"][0]["state"].as_str(),
            closed["items"][0]["comments"].as_u64()
        ),
        (Some("merged"), Some(3))
    );
    let page = site.data(&format!("{repo}/issue/{other_did}/{}", rkey(&issue)));
    assert_eq!(page["title"], "Broken <thing>");
    assert_eq!(
        (&page["body"], &page["base"], &page["merged"]),
        (&Value::Null, &Value::Null, &Value::Null)
    );
    assert_eq!(page["comments"], serde_json::json!([]));
    // A pull's card and an issue's, and what each says of itself. Said
    // more about, the issue names a new card.
    let issue_page = format!("{repo}/issue/{other_did}/{}", rkey(&issue));
    assert_eq!(
        page["about"],
        format!(
            "An issue on {}/tracked by {}.",
            owner.account.handle, other.account.handle
        )
    );
    let before = page["card"].as_str().unwrap().to_string();
    assert!(before.starts_with(&format!("{issue_page}/card.png?v=")));
    let (png, _) = card(&site, &before);
    let pulled = site.data(&thread);
    assert_eq!(pulled["about"], "Adds **b**.");
    assert_ne!(card(&site, pulled["card"].as_str().unwrap()).0, png);
    ok(&owner, &["comment", &url, &issue, "Seen"]);
    wait_until(|| site.data(&issue_page)["card"] != before.as_str());
    let after = site.data(&issue_page)["card"].as_str().unwrap().to_string();
    assert_ne!(card(&site, &after).0, png);
    let (_, cache) = card(&site, &before);
    assert_eq!(cache, "no-cache");
    for missing in [
        format!("{repo}/issue/{other_did}/nothing/card.png"),
        format!("{repo}/wiki/{other_did}/{}/card.png", rkey(&issue)),
        format!("/{}/nothing/card.png", owner.account.handle),
    ] {
        assert_eq!(site.status(&missing).0, 404, "{missing}");
    }
    assert_eq!(
        site.status(&format!("/_data{repo}/issue/{other_did}/nothing"))
            .0,
        404
    );

    // The fork says where it came from.
    let source = serde_json::json!({
        "shown": format!("{}/tracked", owner.account.handle),
        "href": repo,
    });
    let forked = site.data(&format!("/{}/tracked", other.account.handle));
    assert_eq!(forked["repo"]["forkOf"], source);

    // Each account has a page: its public repositories, newest first, and
    // who it is, as its Bluesky profile says.
    let theirs = site.data(&format!("/{}", other.account.handle));
    assert_eq!(theirs["did"], other_did.as_str());
    assert_eq!(theirs["repos"][0]["forkOf"], source);
    assert_eq!(
        (&theirs["name"], &theirs["bio"], &theirs["avatar"]),
        (&Value::Null, &Value::Null, &Value::Null)
    );
    let session: Value = Client::new()
        .post(format!(
            "{}/xrpc/com.atproto.server.createSession",
            net().pds
        ))
        .json(&serde_json::json!({
            "identifier": owner.account.handle,
            "password": owner.account.password,
        }))
        .send()
        .unwrap()
        .json()
        .unwrap();
    let picture: Value = Client::new()
        .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", net().pds))
        .bearer_auth(session["accessJwt"].as_str().unwrap())
        .header("content-type", "image/png")
        .body(b"\x89PNG\r\n\x1a\n".to_vec())
        .send()
        .unwrap()
        .json()
        .unwrap();
    let put = Client::new()
        .post(format!("{}/xrpc/com.atproto.repo.putRecord", net().pds))
        .bearer_auth(session["accessJwt"].as_str().unwrap())
        .json(&serde_json::json!({
            "repo": owner.account.did,
            "collection": "app.bsky.actor.profile",
            "rkey": "self",
            "record": {
                "$type": "app.bsky.actor.profile",
                "displayName": "The <Owner>",
                "description": "Keeps things tracked.",
                "avatar": picture["blob"],
            },
        }))
        .send()
        .unwrap();
    assert!(put.status().is_success());
    ok(&owner, &["create", &owner.url("later")]);
    let mine = site.data(&format!("/{}", owner.account.did));
    assert_eq!(names(&mine["repos"]), ["later", "tracked"]);
    assert_eq!(mine["name"], "The <Owner>");
    assert_eq!(mine["handle"], owner.account.handle.as_str());
    assert_eq!(mine["bio"], "Keeps things tracked.");
    assert_eq!(mine["about"], "Keeps things tracked.");
    // Its picture, as Bluesky's CDN serves it, wherever the account is
    // shown: over its repositories, beside them, and beside what it said.
    let cid = picture["blob"]["ref"]["$link"].as_str().unwrap();
    let pictured = format!(
        "https://cdn.bsky.app/img/avatar/plain/{}/{cid}@jpeg",
        owner.account.did
    );
    assert_eq!(mine["avatar"], pictured.as_str());
    assert_eq!(mine["repos"][0]["avatar"], pictured.as_str());
    let discussed = site.data(&issue_page);
    assert_eq!(discussed["repo"]["avatar"], pictured.as_str());
    assert_eq!(discussed["avatar"], Value::Null);
    let seen = discussed["comments"].as_array().unwrap().iter();
    let seen: Vec<_> = seen
        .filter(|c| c["author"] == owner.account.handle.as_str())
        .collect();
    assert_eq!(seen[0]["avatar"], pictured.as_str());
    assert_eq!(
        named(&mine["repos"], "tracked")["description"],
        "Tracks <things>"
    );
    assert_eq!(
        named(&mine["repos"], "tracked")["owner"],
        owner.account.handle.as_str()
    );
    let nobody = person(account(), &site);
    assert_eq!(
        site.data(&format!("/{}", nobody.account.handle))["repos"],
        serde_json::json!([])
    );
    assert_eq!(site.status("/_data/nobody.invalid").0, 404);

    // Renamed, it keeps its pulls and issues, even through the old name.
    // An answer asked for by the old name redirects to the same one under
    // the new, as does a raw file; and the fork names its source as it is
    // called now.
    ok(&owner, &["rename", &url, "tracked2"]);
    let moved = format!("/{}/tracked2", owner.account.handle);
    wait_until(|| {
        titles(&site.data(&format!("{moved}/issues"))).contains(&"Broken <thing>".to_string())
    });
    assert!(ok(&owner, &["list", &url]).contains(&format!("pull\tmerged\t{pull}")));
    let direct = Client::builder()
        .redirect(reqwest::redirect::Policy::none())
        .build()
        .unwrap();
    let old = |path: &str| direct.get(format!("{}{path}", site.base)).send().unwrap();
    let closed = format!("/_data{repo}/pulls?state=closed");
    // Once the firehose has told the server of the rename.
    wait_until(|| old(&closed).status().as_u16() == 307);
    assert_eq!(
        old(&closed).headers()["location"],
        format!("/_data{moved}/pulls?state=closed")
    );
    let file = site.data(&moved)["entries"][0]["name"]
        .as_str()
        .unwrap()
        .to_string();
    assert_eq!(
        old(&format!("{repo}/raw/main/{file}")).headers()["location"],
        format!("{moved}/raw/main/{file}")
    );
    assert!(site.data(&thread)["merged"].is_string());
    wait_until(|| {
        site.data(&format!("/{}/tracked", other.account.handle))["repo"]["forkOf"]["shown"]
            == format!("{}/tracked2", owner.account.handle).as_str()
    });

    // Without an index, there is no knowing.
    let bare = serve(false);
    for path in [
        "/xrpc/dev.knotless.getFeed".to_string(),
        "/_data/search?q=x".to_string(),
        format!("/_data{moved}/pulls"),
    ] {
        let (status, body) = bare.status(&path);
        assert_eq!(status, 404, "{path}");
        assert!(body.contains("keeps no index"), "{path}: {body}");
    }
}

/// The events about `handle`'s repository `name`, newest first.
fn about<'a>(feed: &'a Value, handle: &str, name: &str) -> Vec<&'a Value> {
    feed.as_array()
        .unwrap()
        .iter()
        .filter(|e| e["repo"]["handle"] == handle && e["repo"]["name"] == name)
        .collect()
}

/// Whether one of `events` did `kind`, with each field as `fields` has it.
fn did(events: &[&Value], kind: &str, fields: Value) -> bool {
    events.iter().any(|e| {
        let a = &e["action"];
        a["$type"] == format!("dev.knotless.getFeed#{kind}")
            && fields.as_object().unwrap().iter().all(|(k, v)| &a[k] == v)
    })
}

#[test]
fn the_feed() {
    use serde_json::json;
    let site = serve(true);
    let owner = person(account(), &site);
    let other = person(account(), &site);
    let (o, t) = (&owner.account.handle, &other.account.handle);
    // Other tests' pushes and pulls share the feed, so it is read back to
    // where it was as this began: once it shows `last`, it must show each
    // of `wants`.
    let newest = site.json("/xrpc/dev.knotless.getFeed?limit=1");
    let since = newest["events"][0]["createdAt"].as_str().unwrap_or("");
    let shows = |handle: &str, last: (&str, Value), wants: &[(&str, Value)]| {
        wait_until(|| {
            did(
                &about(&site.feed(since), handle, "fed"),
                last.0,
                last.1.clone(),
            )
        });
        let feed = site.feed(since);
        let events = about(&feed, handle, "fed");
        for (kind, fields) in wants {
            assert!(
                did(&events, kind, fields.clone()),
                "no {kind} {fields} in {events:#?}"
            );
        }
        feed
    };

    let url = owner.url("fed");
    ok(
        &owner,
        &["create", &url, "--description", "Fed <to> the feed"],
    );
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    // A push of several commits lists them, newest first; one back to
    // where it was brings nothing.
    for i in 0..6 {
        owner.commit_file(&work, "f", &format!("{i}\n"));
    }
    owner.git(&work, &["push", "-q", &url, "main"]);
    owner.git(&work, &["push", "-q", "-f", &url, "HEAD~2:main"]);
    owner.git(&work, &["checkout", "-q", "-b", "side"]);
    owner.commit_file(&work, "s", "s\n");
    owner.git(&work, &["push", "-q", &url, "side"]);
    owner.git(&work, &["tag", "v1"]);
    owner.git(&work, &["push", "-q", &url, "v1"]);
    owner.git(&work, &["push", "-q", &url, ":side"]);
    let feed = shows(
        o,
        ("deleted", json!({"ref": "refs/heads/side"})),
        &[
            ("created", json!({"description": "Fed <to> the feed"})),
            (
                "pushed",
                json!({"ref": "refs/heads/main", "created": false, "count": 6}),
            ),
            (
                "pushed",
                json!({"ref": "refs/heads/main", "created": false, "count": 0, "commits": []}),
            ),
            (
                "pushed",
                json!({"ref": "refs/heads/side", "created": true, "count": 3}),
            ),
            ("pushed", json!({"ref": "refs/tags/v1", "created": true})),
        ],
    );
    let six = about(&feed, o, "fed")
        .into_iter()
        .find(|e| e["action"]["count"] == 6)
        .unwrap();
    assert_eq!(six["action"]["commits"].as_array().unwrap().len(), 6);
    assert_eq!(six["actor"]["handle"], o.as_str());
    assert_eq!(six["actor"]["did"], owner.account.did.as_str());

    let fork = ok(&other, &["fork", &url]);
    let clone = other.path("clone");
    other.git(other.dir.path(), &["clone", "-q", &fork, "clone"]);
    other.commit_file(&clone, "b", "b\n");
    other.git(&clone, &["push", "-q", "origin", "main:topic"]);
    let feed = shows(
        t,
        (
            "pushed",
            json!({"ref": "refs/heads/topic", "created": true, "count": 1}),
        ),
        &[(
            "created",
            json!({"forkOf": {"did": owner.account.did, "handle": o, "name": "fed"}}),
        )],
    );
    // A fork's copy of its source's branches is the fork itself.
    assert!(!did(
        &about(&feed, t, "fed"),
        "pushed",
        json!({"ref": "refs/heads/main"})
    ));

    let pull = ok(&other, &["pull", &url, "main", &fork, "topic", "Add b"]);
    let issue = ok(&other, &["issue", &url, "Broken <thing>"]);
    ok(&owner, &["comment", &url, &pull, "Not *yet*", "--changes"]);
    ok(&other, &["comment", &url, &pull, "Fixed", "--approve"]);
    ok(&owner, &["comment", &url, &issue, "Seen it"]);
    ok(&owner, &["close", &url, &issue]);
    ok(&owner, &["reopen", &url, &issue]);
    let (pull, issue) = (pull.trim(), issue.trim());
    wait_until(|| {
        did(
            &about(&site.feed(since), o, "fed"),
            "changed",
            json!({"subject": issue, "state": "open"}),
        )
    });
    ok(&owner, &["merge", &url, pull]);
    let feed = shows(
        o,
        ("changed", json!({"subject": pull, "state": "merged"})),
        &[
            ("opened", json!({"subject": pull, "title": "Add b"})),
            (
                "commented",
                json!({"subject": pull, "verdict": "changes", "text": "Not *yet*"}),
            ),
            (
                "commented",
                json!({"subject": pull, "verdict": "approve", "title": "Add b"}),
            ),
            (
                "opened",
                json!({"subject": issue, "title": "Broken <thing>"}),
            ),
            (
                "commented",
                json!({"subject": issue, "text": "Seen it", "title": "Broken <thing>"}),
            ),
            ("changed", json!({"subject": issue, "state": "closed"})),
        ],
    );
    // Opened, said and decided about, by title.
    let titled = |title: &str| {
        about(&feed, o, "fed")
            .iter()
            .filter(|e| e["action"]["title"] == title)
            .count()
    };
    assert_eq!((titled("Add b"), titled("Broken <thing>")), (4, 4));
    // A comment without a verdict has none to say.
    assert!(
        about(&feed, o, "fed")
            .iter()
            .any(|e| e["action"]["text"] == "Seen it" && e["action"].get("verdict").is_none())
    );
    // At most forty, unless asked for more or fewer; at least one.
    let events = |q: &str| {
        site.json(&format!("/xrpc/dev.knotless.getFeed{q}"))["events"]
            .as_array()
            .unwrap()
            .len()
    };
    assert!(events("") <= 40);
    assert_eq!((events("?limit=1"), events("?limit=0")), (1, 1));
    // Page by page, each starting strictly after the last.
    let first = site.json("/xrpc/dev.knotless.getFeed?limit=2");
    let cursor = first["cursor"].as_str().unwrap().replace(' ', "%20");
    let next = site.json(&format!(
        "/xrpc/dev.knotless.getFeed?limit=1&cursor={cursor}"
    ));
    let (shown, after) = (first["events"].as_array().unwrap(), &next["events"][0]);
    assert!(after["createdAt"].as_str() <= shown.last().unwrap()["createdAt"].as_str());
    assert!(!shown.contains(after));
    let found = site.json("/_data/search?q=FED");
    assert_eq!(found["query"], "FED");
    assert!(names(&found["repos"]).contains(&"fed"));
}

/// The pages: anything knotless-serve does not answer itself is handed to
/// the app at `--web`, as asked, with the host it was asked of; and a
/// failure to reach it is said.
#[test]
fn pages_drawn_elsewhere() {
    use axum::http::HeaderMap;
    use axum::response::IntoResponse;
    let rt = tokio::runtime::Runtime::new().unwrap();
    let listen = || {
        let listener = rt
            .block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
            .unwrap();
        let at = format!("http://{}", listener.local_addr().unwrap());
        (listener, at)
    };
    // An app that says what it was asked.
    let (app, at) = listen();
    let echo = axum::Router::new().fallback(
        |method: axum::http::Method, uri: axum::http::Uri, headers: HeaderMap, body: String| async move {
            let header = |name: &str| headers.get(name).map(|v| v.to_str().unwrap().to_string());
            let said = serde_json::json!({
                "method": method.as_str(),
                "at": uri.to_string(),
                "host": header("x-forwarded-host"),
                "proto": header("x-forwarded-proto"),
                "body": body,
            });
            let policy = if uri.path() == "/own" { "no-referrer" } else { "" };
            let mut res = axum::Json(said).into_response();
            if !policy.is_empty() {
                res.headers_mut()
                    .insert("referrer-policy", policy.parse().unwrap());
            }
            res
        },
    );
    rt.spawn(async move { axum::serve(app, echo).await.unwrap() });

    let s = Sandbox::new();
    let front = |web: String| {
        let (listener, base) = listen();
        let app: Arc<App> = App::build(ctx(&s), Duration::ZERO, None, None, false);
        let routes = router(app).merge(knotless_serve::web::pages(web));
        rt.spawn(async move { axum::serve(listener, routes).await.unwrap() });
        base
    };
    let base = front(format!("{at}/"));
    let client = Client::new();
    let res = client
        .post(format!("{base}/alice.test/tally?x=1"))
        .header("host", "pages.example")
        .header("x-forwarded-proto", "https")
        .body("hi")
        .send()
        .unwrap();
    assert_eq!(res.status().as_u16(), 200);
    assert_eq!(res.headers()["x-content-type-options"], "nosniff");
    assert_eq!(res.headers()["referrer-policy"], "same-origin");
    let said: Value = res.json().unwrap();
    assert_eq!(
        said,
        serde_json::json!({
            "method": "POST",
            "at": "/alice.test/tally?x=1",
            "host": "pages.example",
            "proto": "https",
            "body": "hi",
        })
    );
    // What the app says of itself stands.
    let own = client.get(format!("{base}/own")).send().unwrap();
    assert_eq!(own.headers()["referrer-policy"], "no-referrer");
    assert_eq!(own.json::<Value>().unwrap()["proto"], "http");
    // What knotless-serve answers itself is not handed on.
    let ours = client
        .get(format!("{base}/xrpc/dev.knotless.getFeed"))
        .send()
        .unwrap();
    assert!(ours.text().unwrap().contains("keeps no index"));
    // Too much to hand on, or no app to hand it to.
    // The answer can come before all of it is sent, which closes the
    // connection under the rest.
    let big = client
        .post(format!("{base}/x"))
        .body(vec![b'x'; 2 << 20])
        .send();
    match big {
        Ok(res) => assert_eq!(res.status().as_u16(), 502),
        Err(e) => assert!(e.is_request(), "{e}"),
    }
    let (gone, nowhere) = listen();
    drop(gone);
    let res = client.get(front(nowhere)).send().unwrap();
    assert_eq!(res.status().as_u16(), 502);
    assert!(
        res.text()
            .unwrap()
            .starts_with("The pages are not being drawn")
    );
}

#[test]
fn private_repositories_on_this_machine() {
    let public = serve(false);
    if !net().spaces {
        // A PDS without spaces holds no private repositories to list.
        let s = person(account(), &public);
        let profile = format!("/{}", s.account.handle);
        let listed = serve_as(s, false, true).data(&profile);
        assert_eq!(listed["repos"], serde_json::json!([]));
        return;
    }
    let owner = person(account(), &public);
    let reader = person(account(), &public);
    let url = owner.url("hidden");
    ok(&owner, &["create", &url, "--private"]);
    let work = owner.work("w", 1);
    owner.commit_file(&work, "notes.md", "# Notes\n");
    owner.git(&work, &["push", "-q", &url, "main"]);
    ok(&owner, &["share", &url, &reader.account.handle]);
    let issue = ok(&reader, &["issue", &url, "Private trouble"]);
    let repo = format!("/{}/hidden", owner.account.handle);

    // A member's own server shows it, pulls and issues read from the space.
    let local = serve_as(reader, false, true);
    assert!(names(&local.data(&repo)["entries"]).contains(&"notes.md"));
    let issues = local.data(&format!("{repo}/issues"));
    assert_eq!(issues["items"][0]["title"], "Private trouble");
    assert_eq!(issues["repo"]["issues"], 1);
    let (did, _, rkey) = knotless::forge::parse_uri(&issue).unwrap();
    assert_eq!(
        local.data(&format!("{repo}/issue/{did}/{rkey}"))["title"],
        "Private trouble"
    );

    // The owner's own server lists it on their profile; others' do not.
    let profile = format!("/{}", owner.account.handle);
    let mine = serve_as(owner, false, true);
    let listed = mine.data(&profile);
    assert_eq!(named(&listed["repos"], "hidden")["private"], true);
    assert!(!names(&local.data(&profile)["repos"]).contains(&"hidden"));
    assert!(!names(&public.data(&profile)["repos"]).contains(&"hidden"));

    // A server that reads public repositories only, or reads as an outsider,
    // finds nothing there.
    assert_ne!(public.status(&format!("/_data{repo}")).0, 200);
    let outsider = person(account(), &public);
    assert_ne!(
        serve_as(outsider, false, true)
            .status(&format!("/_data{repo}"))
            .0,
        200
    );
}