chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
//! 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)) \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>",
"<script>alert(1)</script>",
&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
);
}