//! 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::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, 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\n\n[guide](docs/guide.md) [x](javascript:alert(1)) ![logo](logo.png)\n"); write(&work, "docs/README", b"plain \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 [ "

Browsed

", "<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 \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 \nCo-authored-by: Me "; 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 "], ); 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 ", "-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 "]); 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("b")); 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"], "

Thanks

\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 { 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 "); let open = items("/issues"); assert_eq!(titles(&open), ["Broken "]); 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 "); 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 ", "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 "); 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 " ); 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 ".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 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 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 "]); 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 "}), ), ( "commented", json!({"subject": issue, "text": "Seen it", "title": "Broken "}), ), ("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 ")), (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::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::().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 ); }