From 7b1320b8388dfb0b729fd9dd03fc5b21abf22ff3 Mon Sep 17 00:00:00 2001 From: Lewis Date: Tue, 13 Oct 2026 11:06:38 +0300 Subject: [PATCH] knot2/xrpc: exercise the org.tangled read surface and projection in tests Lewis: May this revision serve well! --- knot2/crates/knot-xrpc/tests/projection.rs | 347 ++++ knot2/crates/knot-xrpc/tests/reads.rs | 1874 +++++++++----------- 2 files changed, 1203 insertions(+), 1018 deletions(-) create mode 100644 knot2/crates/knot-xrpc/tests/projection.rs diff --git a/knot2/crates/knot-xrpc/tests/projection.rs b/knot2/crates/knot-xrpc/tests/projection.rs new file mode 100644 index 000000000..34c60fb65 --- /dev/null +++ b/knot2/crates/knot-xrpc/tests/projection.rs @@ -0,0 +1,347 @@ +mod common; + +use std::collections::BTreeMap; + +use common::{ + World, commit_file, create_patch, create_ticket, get_json, http_push, nsid, owner, post_as, + post_authed, serve_pushes, sh_git, +}; +use knot_index::Resolved; +use knot_record::chain; +use knot_record::git_refs; +use knot_record::grant::{self, GrantCollection}; +use knot_types::{AccountDid, MstKey, Oid, RepoDid, ServiceDid, UnixSeconds}; + +async fn assert_projection(world: &World, did: &RepoDid) { + let git = world.layout.open(did).unwrap(); + let leaves = chain::leaves(&git).await.unwrap(); + + world.state.index.refresh_social(did).unwrap(); + let entries = match world.state.index.social_entries(did) { + Resolved::Ready(entries) => entries, + Resolved::Warming => panic!("social projection must be ready"), + }; + let mut desired: BTreeMap = BTreeMap::new(); + for entry in &entries { + if let Some(serving) = entry.state.serving() { + desired.insert(entry.address.mst_key(), serving.cid()); + } + } + + for (key, body) in git_refs::desired_record(&git).unwrap() { + desired.insert(key, body.cid().cid()); + } + + world.state.index.refresh_collaborators(did).unwrap(); + let collaborators = match world.state.index.collaborator_entries(did) { + Resolved::Ready(entries) => entries, + Resolved::Warming => panic!("collaborator projection must be ready"), + }; + for (subject, entry) in &collaborators { + let key = grant::record_key(subject, GrantCollection::Collaborator).unwrap(); + let bodies = grant::bodies([(subject, entry.created_at)], GrantCollection::Collaborator); + desired.insert(key.clone(), bodies[&key].cid().cid()); + } + + assert_eq!(leaves, desired, "MST projection mismatch for {did}"); +} + +async fn create_repo(world: &World, name: &str) -> RepoDid { + let (status, created) = post_authed( + world, + &nsid("org.tangled.repo.createRepo"), + &owner(), + serde_json::json!({ "name": name, "defaultBranch": "main" }), + ) + .await; + assert_eq!(status, http::StatusCode::OK, "create failed: {created}"); + RepoDid::new(created["did"].as_str().unwrap()).unwrap() +} + +async fn push_main(world: &World, did: &RepoDid, work: &std::path::Path) { + let addr = serve_pushes(world).await; + let (ok, detail) = http_push(work, addr, did, &owner(), &["main"]).await; + assert!(ok, "push failed: {detail}"); +} + +#[tokio::test] +async fn the_full_lifecycle_keeps_the_mst_projection_exact() { + let world = World::new(); + world.add_member(&owner(), &owner(), UnixSeconds::new(1_000)); + + let did = create_repo(&world, "anemone").await; + assert_projection(&world, &did).await; + + let work = tempfile::tempdir().unwrap(); + sh_git(work.path(), &["init", "-q", "-b", "main"]); + commit_file( + work.path(), + "README.md", + b"# anemone\n", + "first", + "2026-06-01T12:30:00+02:00", + ); + push_main(&world, &did, work.path()).await; + assert_projection(&world, &did).await; + + let main_sha = Oid::from_hex(&sh_git(work.path(), &["rev-parse", "HEAD"])).unwrap(); + let refs = get_json( + &world, + &format!( + "/xrpc/org.tangled.git.listRefs?repo={}", + common::percent_encode(did.as_str()) + ), + ) + .await; + assert_eq!( + refs["refs"] + .as_array() + .unwrap() + .iter() + .map(|entry| entry["ref"].as_str().unwrap()) + .collect::>(), + ["refs/heads/main"], + "the read surface lists the pushed branch" + ); + + let (ticket, _) = create_ticket(&world, &did, "kelp", "a forest").await; + assert_projection(&world, &did).await; + + sh_git(work.path(), &["checkout", "-q", "-b", "feature"]); + commit_file( + work.path(), + "src/lib.rs", + b"pub fn reef() {}\n", + "add reef", + "2026-06-01T12:31:00+02:00", + ); + let feature_sha = Oid::from_hex(&sh_git(work.path(), &["rev-parse", "HEAD"])).unwrap(); + let addr = serve_pushes(&world).await; + let (ok, detail) = http_push(work.path(), addr, &did, &owner(), &["feature"]).await; + assert!(ok, "feature push failed: {detail}"); + assert_projection(&world, &did).await; + + let (patch, _) = create_patch(&world, &did, &ticket, &main_sha, &feature_sha).await; + assert_projection(&world, &did).await; + + let (status, merged) = post_as( + &world, + &nsid("org.tangled.review.mergePatch"), + &owner(), + &ServiceDid::new(did.as_str()).unwrap(), + serde_json::json!({ "repo": did, "patch": patch }), + ) + .await; + assert_eq!(status, http::StatusCode::OK, "mergePatch failed: {merged}"); + assert_eq!(merged["tip"], feature_sha.to_hex()); + assert_projection(&world, &did).await; + + let bare = world.layout.repo_path(&did).unwrap(); + assert_eq!( + sh_git(&bare, &["rev-parse", "refs/heads/main"]), + feature_sha.to_hex(), + "the merge moved main to the feature tip" + ); + + let (status, deleted) = post_authed( + &world, + &nsid("org.tangled.git.deleteBranch"), + &owner(), + serde_json::json!({ "repo": did, "branch": "feature" }), + ) + .await; + assert_eq!( + status, + http::StatusCode::OK, + "deleteBranch failed: {deleted}" + ); + assert_projection(&world, &did).await; + + let refs = get_json( + &world, + &format!( + "/xrpc/org.tangled.git.listRefs?repo={}", + common::percent_encode(did.as_str()) + ), + ) + .await; + assert_eq!( + refs["refs"] + .as_array() + .unwrap() + .iter() + .map(|entry| entry["ref"].as_str().unwrap()) + .collect::>(), + ["refs/heads/main"], + "the deleted branch is gone from the read surface" + ); + + let collaborator = AccountDid::new("did:plc:limpet").unwrap(); + let (status, offered) = post_as( + &world, + &nsid("com.atproto.repo.createRecord"), + &owner(), + &ServiceDid::new(did.as_str()).unwrap(), + common::grant_body( + &did, + &GrantCollection::Collaborator.records(), + &collaborator, + "2026-09-11T00:00:00Z", + ), + ) + .await; + assert_eq!(status, http::StatusCode::OK, "offer failed: {offered}"); + assert_projection(&world, &did).await; + + let (status, revoked) = post_as( + &world, + &nsid("com.atproto.repo.deleteRecord"), + &owner(), + &ServiceDid::new(did.as_str()).unwrap(), + common::grant_delete_body( + &did, + &GrantCollection::Collaborator.records(), + &collaborator, + ), + ) + .await; + assert_eq!(status, http::StatusCode::OK, "revoke failed: {revoked}"); + assert_projection(&world, &did).await; +} + +#[tokio::test] +async fn branch_churn_keeps_the_refs_record_exact() { + let world = World::new(); + world.add_member(&owner(), &owner(), UnixSeconds::new(1_000)); + + let did = create_repo(&world, "coral").await; + let work = tempfile::tempdir().unwrap(); + sh_git(work.path(), &["init", "-q", "-b", "main"]); + commit_file( + work.path(), + "README.md", + b"# coral\n", + "first", + "2026-06-01T12:30:00+02:00", + ); + push_main(&world, &did, work.path()).await; + assert_projection(&world, &did).await; + + let addr = serve_pushes(&world).await; + for branch in ["one", "two", "three"] { + sh_git(work.path(), &["branch", branch, "main"]); + let (ok, detail) = http_push(work.path(), addr, &did, &owner(), &[branch]).await; + assert!(ok, "push {branch} failed: {detail}"); + assert_projection(&world, &did).await; + } + + sh_git(work.path(), &["tag", "v1", "main"]); + let (ok, detail) = http_push(work.path(), addr, &did, &owner(), &["v1"]).await; + assert!(ok, "tag push failed: {detail}"); + assert_projection(&world, &did).await; + + let (status, deleted) = post_authed( + &world, + &nsid("org.tangled.git.deleteBranch"), + &owner(), + serde_json::json!({ "repo": did, "branch": "two" }), + ) + .await; + assert_eq!( + status, + http::StatusCode::OK, + "deleteBranch failed: {deleted}" + ); + assert_projection(&world, &did).await; + + let refs = get_json( + &world, + &format!( + "/xrpc/org.tangled.git.listRefs?repo={}", + common::percent_encode(did.as_str()) + ), + ) + .await; + let listed = refs["refs"] + .as_array() + .unwrap() + .iter() + .map(|entry| entry["ref"].as_str().unwrap()) + .collect::>(); + assert_eq!( + listed, + [ + "refs/heads/main", + "refs/heads/one", + "refs/heads/three", + "refs/tags/v1" + ], + "the read surface lists exactly the live refs" + ); +} + +#[tokio::test] +async fn keep_refs_stay_out_of_the_read_surface_and_the_projection() { + let world = World::new(); + world.add_member(&owner(), &owner(), UnixSeconds::new(1_000)); + + let did = create_repo(&world, "kelp").await; + let work = tempfile::tempdir().unwrap(); + sh_git(work.path(), &["init", "-q", "-b", "main"]); + commit_file( + work.path(), + "README.md", + b"# kelp\n", + "first", + "2026-06-01T12:30:00+02:00", + ); + push_main(&world, &did, work.path()).await; + + let main_sha = Oid::from_hex(&sh_git(work.path(), &["rev-parse", "HEAD"])).unwrap(); + sh_git(work.path(), &["checkout", "-q", "-b", "feature"]); + commit_file( + work.path(), + "src/lib.rs", + b"pub fn kelp() {}\n", + "add kelp", + "2026-06-01T12:31:00+02:00", + ); + let feature_sha = Oid::from_hex(&sh_git(work.path(), &["rev-parse", "HEAD"])).unwrap(); + let addr = serve_pushes(&world).await; + let (ok, detail) = http_push(work.path(), addr, &did, &owner(), &["feature"]).await; + assert!(ok, "feature push failed: {detail}"); + + let (ticket, _) = create_ticket(&world, &did, "kelp", "a forest").await; + let (_patch, _) = create_patch(&world, &did, &ticket, &main_sha, &feature_sha).await; + + let refs = get_json( + &world, + &format!( + "/xrpc/org.tangled.git.listRefs?repo={}", + common::percent_encode(did.as_str()) + ), + ) + .await; + let listed = refs["refs"] + .as_array() + .unwrap() + .iter() + .map(|entry| entry["ref"].as_str().unwrap()) + .collect::>(); + assert_eq!( + listed, + ["refs/heads/feature", "refs/heads/main"], + "the keep refs the patch wrote stay out of listRefs" + ); + assert_projection(&world, &did).await; + + let bare = world.layout.repo_path(&did).unwrap(); + let keep = sh_git( + &bare, + &["for-each-ref", "--format=%(refname)", "refs/tngl/keep"], + ); + assert!( + !keep.is_empty(), + "the patch wrote keep refs into the bare repo" + ); +} diff --git a/knot2/crates/knot-xrpc/tests/reads.rs b/knot2/crates/knot-xrpc/tests/reads.rs index 0e1b2aeaf..70a200afb 100644 --- a/knot2/crates/knot-xrpc/tests/reads.rs +++ b/knot2/crates/knot-xrpc/tests/reads.rs @@ -9,240 +9,221 @@ use tokio_tungstenite::tungstenite; use knot_capability::Capability; use knot_index::Resolved; -use knot_types::{AccountDid, Nsid, Oid, RepoDid, RepoName, RepoRkey, UnixSeconds}; +use knot_types::{AccountDid, Nsid, Oid, RecordCid, RepoDid, RepoName, UnixSeconds}; + use knot_xrpc::{ArchiveLimit, ResponseLimit}; use common::{ - World, archive_full, assert_immutable_round_trip, assert_post_rejected, assert_warming, column, - commit_file, empty_repo, get, get_error, get_json, get_with_headers, git_run, nsid, owner, - post_authed, post_json, ref_names, repo_dids, seeded, seeded_feature_branch, sh_git, sh_git_at, + World, archive_full, assert_immutable_round_trip, assert_post_rejected, assert_warming, + commit_file, empty_repo, get, get_error, get_json, get_with_headers, nsid, owner, post_as, + post_authed, post_json, ref_list, repo_dids, seeded, seeded_feature_branch, sh_git, sh_git_at, }; #[tokio::test] -async fn the_seeded_read_surface_renders_each_wire_shape_once() { +async fn the_seeded_read_surface_renders_each_lexicon_shape_once() { let world = World::new(); let (did, work) = seeded(&world, &RepoName::new("coral").unwrap()); let head = sh_git(work.path(), &["rev-parse", "HEAD"]); - let parent = sh_git(work.path(), &["rev-parse", "HEAD~1"]); - let tag_object = sh_git(work.path(), &["rev-parse", "v1.0.0"]); + let parent = sh_git(work.path(), &["rev-parse", "HEAD~2"]); let tagged_commit = sh_git(work.path(), &["rev-parse", "v1.0.0^{commit}"]); let tree = get_json( &world, - &format!("/xrpc/sh.tangled.repo.tree?repo={did}&ref=main"), + &format!("/xrpc/org.tangled.git.getTree?repo={did}&ref=main"), ) .await; - assert_eq!(tree["ref"], "main"); - assert!(tree.get("parent").is_none()); - assert!(tree.get("dotdot").is_none()); - let files = tree["files"].as_array().unwrap(); - let names: Vec<&str> = files + let entries = tree["entries"].as_array().unwrap(); + let listed: Vec<&str> = entries .iter() - .map(|file| file["name"].as_str().unwrap()) + .map(|entry| entry["name"].as_str().unwrap()) .collect(); - assert_eq!(names, vec!["README.md", "logo.png", "src"]); - let readme_entry = &files[0]; - assert_eq!(readme_entry["mode"], "0100644"); + assert_eq!(listed, vec!["README.md", "logo.png", "src"]); + let readme = &entries[0]; + assert_eq!(readme["mode"], "0100644"); assert_eq!( - readme_entry["size"].as_i64().unwrap(), + readme["size"].as_i64().unwrap(), b"# coral\n\nhello reef\n".len() as i64 ); assert_eq!( - readme_entry["last_commit"]["hash"].as_str().unwrap(), + readme["lastCommit"]["hash"].as_str().unwrap(), head, "README was last touched by the head commit" ); - assert_eq!(readme_entry["last_commit"]["message"], "update readme"); - assert_eq!(files[2]["mode"], "0040000"); - assert_eq!(tree["readme"]["filename"], "README.md"); - assert_eq!(tree["readme"]["contents"], "# coral\n\nhello reef\n"); - assert_eq!(tree["lastCommit"]["hash"], head.as_str()); + assert_eq!(readme["lastCommit"]["message"], "update readme\n"); + assert_eq!(entries[2]["mode"], "0040000"); + assert_eq!( + tree["lastCommit"]["hash"].as_str().unwrap(), + head, + "the tree's own lastCommit is the newest commit touching any entry" + ); assert_eq!(tree["lastCommit"]["author"]["name"], "nel"); - assert_eq!(tree["lastCommit"]["author"]["when"], ""); + assert_eq!( + tree["lastCommit"]["author"]["when"], + "2026-06-01T12:33:00+02:00" + ); let sub = get_json( &world, - &format!("/xrpc/sh.tangled.repo.tree?repo={did}&ref=main&path=src"), + &format!("/xrpc/org.tangled.git.getTree?repo={did}&ref=main&path=src"), ) .await; - assert_eq!(sub["parent"], "src"); - assert!(sub.get("dotdot").is_none()); - assert_eq!(sub["files"][0]["name"], "main.rs"); + assert_eq!(sub["entries"][0]["name"], "main.rs"); assert_eq!( get_error( &world, - &format!("/xrpc/sh.tangled.repo.tree?repo={did}&ref=main&path=nope"), + &format!("/xrpc/org.tangled.git.getTree?repo={did}&ref=main&path=nope"), ) .await, (StatusCode::NOT_FOUND, "PathNotFound".to_string()) ); + let entry = get_json( + &world, + &format!("/xrpc/org.tangled.git.getEntry?repo={did}&ref=main&path=README.md"), + ) + .await; + assert_eq!(entry["name"], "README.md"); + assert_eq!(entry["mode"], "0100644"); + assert_eq!( + entry["size"].as_i64().unwrap(), + b"# coral\n\nhello reef\n".len() as i64 + ); + assert!( + entry["oid"].as_str().unwrap().len() == 40, + "the entry's blob oid rides as hex" + ); + assert_eq!(entry["lastCommit"]["message"], "update readme\n"); + let log = get_json( &world, - &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref=main"), + &format!("/xrpc/org.tangled.git.listCommits?repo={did}&ref=main"), ) .await; - assert_eq!(log["total"].as_i64(), Some(4)); - assert_eq!(log["page"].as_i64(), Some(1)); - assert_eq!(log["per_page"].as_i64(), Some(50)); - assert_eq!(log["log"], true); - assert_eq!(log["ref"], "main"); let commits = log["commits"].as_array().unwrap(); assert_eq!(commits.len(), 4); + assert!( + log.get("cursor").is_none() || log["cursor"].is_null(), + "a page that reaches the oldest commit hands back no cursor" + ); let first = &commits[0]; - let hash_bytes: Vec = first["hash"] - .as_array() - .unwrap() - .iter() - .map(|byte| byte.as_u64().unwrap() as u8) - .collect(); - assert_eq!( - hash_bytes, - (0..head.len()) - .step_by(2) - .map(|index| u8::from_str_radix(&head[index..index + 2], 16).unwrap()) - .collect::>(), - "commit hash rides as a byte array" - ); - assert_eq!(first["this"], head.as_str()); - assert_eq!(first["parent"], parent.as_str()); - assert_eq!(first["author"]["Name"], "nel"); - assert_eq!(first["author"]["Email"], "nel@oyster.cafe"); - assert_eq!(first["author"]["When"], "2026-06-01T12:33:00+02:00"); + assert_eq!(first["hash"], head.as_str()); + assert_eq!(first["author"]["name"], "nel"); + assert_eq!(first["author"]["email"], "nel@oyster.cafe"); + assert_eq!(first["author"]["when"], "2026-06-01T12:33:00+02:00"); + assert_eq!(first["committer"]["name"], "nel"); assert_eq!(first["message"], "update readme\n"); assert!(first["tree"].as_str().unwrap().len() == 40); - let paged = get_json( + let second_page = get_json( + &world, + &format!("/xrpc/org.tangled.git.listCommits?repo={did}&ref=main&limit=2"), + ) + .await; + let two = second_page["commits"].as_array().unwrap(); + assert_eq!(two.len(), 2); + assert_eq!(two[0]["hash"], head.as_str()); + let cursor = second_page["cursor"].as_str().unwrap().to_string(); + assert_eq!( + cursor, + two[1]["hash"].as_str().unwrap(), + "the cursor is the commit id of the last commit in the page" + ); + let rest = get_json( &world, - &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref=main&limit=2&cursor=2"), + &format!("/xrpc/org.tangled.git.listCommits?repo={did}&ref=main&limit=2&cursor={cursor}"), ) .await; - assert_eq!(paged["commits"].as_array().unwrap().len(), 2); - assert_eq!(paged["page"].as_i64(), Some(2)); - assert_eq!(paged["per_page"].as_i64(), Some(2)); + let older = rest["commits"].as_array().unwrap(); + assert_eq!(older.len(), 2); + assert_eq!(older[0]["hash"], parent.as_str()); let branches = get_json( &world, - &format!("/xrpc/sh.tangled.repo.branches?repo={did}"), + &format!("/xrpc/org.tangled.git.listBranches?repo={did}"), ) .await; let listed = branches["branches"].as_array().unwrap(); assert_eq!(listed.len(), 1); - assert_eq!(listed[0]["reference"]["name"], "main"); - assert_eq!(listed[0]["reference"]["hash"], head.as_str()); - assert_eq!(listed[0]["is_default"], true); - assert_eq!(listed[0]["commit"]["Author"]["Name"], "nel"); - assert!(listed[0]["commit"]["Hash"].is_array()); - assert_eq!(listed[0]["commit"]["ExtraHeaders"], serde_json::Value::Null); - assert_eq!(listed[0]["commit"]["Message"], "update readme"); + assert_eq!(listed[0]["name"], "main"); + assert_eq!(listed[0]["oid"], head.as_str()); + assert_eq!(listed[0]["message"], "update readme\n"); + assert_eq!(listed[0]["author"]["name"], "nel"); + assert_eq!(listed[0]["committer"]["email"], "nel@oyster.cafe"); + assert_eq!(branches["total"].as_i64(), Some(1)); + assert_eq!(branches["defaultBranch"], "main"); let branch = get_json( &world, - &format!("/xrpc/sh.tangled.repo.branch?repo={did}&name=main"), + &format!("/xrpc/org.tangled.git.getBranch?repo={did}&name=main"), ) .await; assert_eq!(branch["name"], "main"); assert_eq!(branch["hash"], head.as_str()); - assert_eq!(branch["shortHash"], head[..7].to_string().as_str()); - assert_eq!(branch["isDefault"], true); + assert_eq!(branch["message"], "update readme\n"); assert_eq!(branch["author"]["name"], "nel"); - assert_eq!(branch["when"], "2026-06-01T12:33:00+02:00"); + assert_eq!(branch["totalCommits"].as_i64(), Some(4)); assert_eq!( get_error( &world, - &format!("/xrpc/sh.tangled.repo.branch?repo={did}&name=mangrove"), + &format!("/xrpc/org.tangled.git.getBranch?repo={did}&name=mangrove"), ) .await, (StatusCode::NOT_FOUND, "BranchNotFound".to_string()) ); - let tags = get_json(&world, &format!("/xrpc/sh.tangled.repo.tags?repo={did}")).await; + let tags = get_json( + &world, + &format!("/xrpc/org.tangled.git.listTags?repo={did}"), + ) + .await; let tag_list = tags["tags"].as_array().unwrap(); assert_eq!(tag_list.len(), 2); + assert_eq!(tags["total"].as_i64(), Some(2)); let annotated = tag_list.iter().find(|tag| tag["name"] == "v1.0.0").unwrap(); - assert_eq!(annotated["hash"], tag_object.as_str()); - assert_eq!(annotated["message"], "release one"); - assert_eq!(annotated["tag"]["TargetType"].as_i64(), Some(4)); - assert_eq!(annotated["tag"]["Tagger"]["Name"], "nel"); - let target_bytes = annotated["tag"]["Target"].as_array().unwrap(); - assert_eq!(target_bytes.len(), 20); assert_eq!( - target_bytes[0].as_u64().unwrap() as u8, - u8::from_str_radix(&tagged_commit[..2], 16).unwrap() + annotated["oid"], + tagged_commit.as_str(), + "an annotated tag reports the commit it peels to" ); + assert_eq!(annotated["message"], "add logo\n"); + assert_eq!(annotated["author"]["name"], "nel"); + assert_eq!( + annotated["annotation"]["message"], "release one", + "the tag's own message rides the annotation" + ); + assert_eq!(annotated["annotation"]["tagger"]["name"], "nel"); let lightweight = tag_list .iter() .find(|tag| tag["name"] == "lightweight") .unwrap(); - assert!(lightweight.get("tag").is_none()); - assert_eq!(lightweight["hash"], tagged_commit.as_str()); - assert_eq!(lightweight["message"], "add logo"); + assert!(lightweight.get("annotation").is_none()); + assert_eq!(lightweight["oid"], tagged_commit.as_str()); let single = get_json( &world, - &format!("/xrpc/sh.tangled.repo.tag?repo={did}&tag=v1.0.0"), + &format!("/xrpc/org.tangled.git.getTag?repo={did}&tag=v1.0.0"), ) .await; - assert_eq!(single["tag"]["name"], "v1.0.0"); + assert_eq!(single["name"], "v1.0.0"); + assert_eq!(single["oid"], tagged_commit.as_str()); + assert_eq!(single["annotation"]["message"], "release one"); assert_eq!( get_error( &world, - &format!("/xrpc/sh.tangled.repo.tag?repo={did}&tag=v9.9.9"), + &format!("/xrpc/org.tangled.git.getTag?repo={did}&tag=v9.9.9"), ) .await, - (StatusCode::BAD_REQUEST, "TagNotFound".to_string()) + (StatusCode::NOT_FOUND, "RefNotFound".to_string()) ); - let text = get_json( - &world, - &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref=main&path=README.md"), - ) - .await; - assert_eq!(text["encoding"], "utf-8"); - assert_eq!(text["isBinary"], false); - assert_eq!(text["content"], "# coral\n\nhello reef\n"); - assert_eq!(text["mimeType"], "text/plain; charset=utf-8"); - assert_eq!(text["lastCommit"]["message"], "update readme"); - - let blame = get_json( - &world, - &format!("/xrpc/sh.tangled.repo.blame?repo={did}&ref=main&path=README.md"), - ) - .await; - assert_eq!(blame["commit"], head); - let commits = blame["commits"].as_array().unwrap(); - let runs = blame["runs"].as_array().unwrap(); - assert_eq!(commits.len(), 2); - assert_eq!(runs.len(), 2); - assert_eq!(runs[0]["start"], 1); - assert_eq!(runs[0]["count"], 2); - let first_sha = runs[0]["commit"].as_str().unwrap(); - let first_commit = commits - .iter() - .find(|commit| commit["sha"] == first_sha) - .unwrap(); - assert_eq!(first_commit["subject"], "first"); - assert_eq!(first_commit["author"]["name"], "nel"); - assert_eq!(runs[1]["start"], 3); - assert_eq!(runs[1]["count"], 1); - assert_eq!(runs[1]["commit"], head); - let head_commit = commits.iter().find(|commit| commit["sha"] == head).unwrap(); - assert_eq!(head_commit["subject"], "update readme"); - assert_eq!(head_commit["authoredAt"], "2026-06-01T12:33:00+02:00"); - - let binary = get_json( - &world, - &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref=main&path=logo.png"), - ) - .await; - assert_eq!(binary["encoding"], "base64"); - assert_eq!(binary["isBinary"], true); - assert_eq!(binary["mimeType"], "image/png"); - let (status, headers, body) = get( &world, - &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref=main&path=logo.png&raw=true"), + &format!("/xrpc/org.tangled.git.getBlob?repo={did}&ref=main&path=README.md"), ) .await; assert_eq!(status, StatusCode::OK); - assert_eq!(headers.get(header::CONTENT_TYPE).unwrap(), "image/png"); + assert_eq!( + headers.get(header::CONTENT_TYPE).unwrap(), + "application/octet-stream", + "the lexicon pins the blob to raw bytes" + ); assert_eq!( headers.get(header::X_CONTENT_TYPE_OPTIONS).unwrap(), "nosniff" @@ -251,13 +232,13 @@ async fn the_seeded_read_surface_renders_each_wire_shape_once() { headers.get(header::CONTENT_SECURITY_POLICY).unwrap(), "default-src 'none'; style-src 'unsafe-inline'; sandbox" ); + assert_eq!(body, "# coral\n\nhello reef\n"); let etag = headers .get(header::ETAG) .unwrap() .to_str() .unwrap() .to_string(); - assert!(body.starts_with(b"\x89PNG")); let mut cached = HeaderMap::new(); cached.insert( @@ -266,7 +247,7 @@ async fn the_seeded_read_surface_renders_each_wire_shape_once() { ); let (status, _, _) = get_with_headers( &world, - &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref=main&path=logo.png&raw=true"), + &format!("/xrpc/org.tangled.git.getBlob?repo={did}&ref=main&path=README.md"), cached, ) .await; @@ -279,7 +260,7 @@ async fn the_seeded_read_surface_renders_each_wire_shape_once() { ); let (status, _, _) = get_with_headers( &world, - &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref=main&path=logo.png&raw=true"), + &format!("/xrpc/org.tangled.git.getBlob?repo={did}&ref=main&path=README.md"), weak, ) .await; @@ -288,10 +269,48 @@ async fn the_seeded_read_surface_renders_each_wire_shape_once() { StatusCode::NOT_MODIFIED, "weak validator must revalidate too" ); + + let logo = get( + &world, + &format!("/xrpc/org.tangled.git.getBlob?repo={did}&ref=main&path=logo.png"), + ) + .await; + assert_eq!(logo.0, StatusCode::OK); + assert!(logo.2.starts_with(b"\x89PNG")); + + let blame = get_json( + &world, + &format!("/xrpc/org.tangled.git.getBlame?repo={did}&ref=main&path=README.md"), + ) + .await; + assert_eq!(blame["commit"], head); + let blame_commits = blame["commits"].as_array().unwrap(); + let runs = blame["runs"].as_array().unwrap(); + assert_eq!(blame_commits.len(), 2); + assert_eq!(runs.len(), 2); + assert_eq!(runs[0]["start"], 1); + assert_eq!(runs[0]["count"], 2); + let first_sha = runs[0]["commit"].as_str().unwrap(); + let first_commit = blame_commits + .iter() + .find(|commit| commit["sha"] == first_sha) + .unwrap(); + assert_eq!(first_commit["subject"], "first"); + assert_eq!(first_commit["author"]["name"], "nel"); + assert_eq!(runs[1]["start"], 3); + assert_eq!(runs[1]["count"], 1); + assert_eq!(runs[1]["commit"], head); + let head_commit = blame_commits + .iter() + .find(|commit| commit["sha"] == head) + .unwrap(); + assert_eq!(head_commit["subject"], "update readme"); + assert_eq!(head_commit["authoredAt"], "2026-06-01T12:33:00+02:00"); + assert_eq!( get_error( &world, - &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref=main&path=ghost.txt"), + &format!("/xrpc/org.tangled.git.getBlob?repo={did}&ref=main&path=ghost.txt"), ) .await, (StatusCode::NOT_FOUND, "FileNotFound".to_string()) @@ -331,15 +350,15 @@ async fn tree_directory_last_commit_is_the_newest_touching_commit() { let value = get_json( &world, - &format!("/xrpc/sh.tangled.repo.tree?repo={did}&ref=main"), + &format!("/xrpc/org.tangled.git.getTree?repo={did}&ref=main"), ) .await; - let files = value["files"].as_array().unwrap(); - let src = files + let entries = value["entries"].as_array().unwrap(); + let src = entries .iter() - .find(|file| file["name"] == "src") + .find(|entry| entry["name"] == "src") .expect("src directory is listed"); - let reported = src["last_commit"]["hash"].as_str().unwrap(); + let reported = src["lastCommit"]["hash"].as_str().unwrap(); assert_eq!( reported, newer, "the newest commit that touches the subtree is reported, not the oldest" @@ -375,200 +394,50 @@ async fn languages_timeout_yields_a_partial_answer_not_an_error() { ); } -#[tokio::test] -async fn compare_format_patch_keeps_a_non_ascii_author_raw() { - let world = World::new(); - let (did, bare, work) = empty_repo(&world, &RepoName::new("mussel").unwrap()); - let work = work.path(); - commit_file( - work, - "README.md", - b"# mussel\n", - "first", - "2026-06-01T12:30:00+02:00", - ); - let base = sh_git(work, &["rev-parse", "HEAD"]); - std::fs::write(work.join("src.rs"), b"fn main() {}\n").unwrap(); - let author = ("Lýna Þórsdóttir", "lyna@nel.pet"); - git_run(work, "2026-06-01T12:31:00+02:00", author, &["add", "-A"]); - git_run( - work, - "2026-06-01T12:31:00+02:00", - author, - &["commit", "-q", "-m", "café changes"], - ); - let head = sh_git(work, &["rev-parse", "HEAD"]); - sh_git(work, &["push", "-q", &bare, "main"]); - - let value = get_json( - &world, - &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={base}&rev2={head}"), - ) - .await; - let entry = &value["format_patch"][0]; - assert_eq!( - entry["Author"]["Name"], "Lýna Þórsdóttir", - "structured author the appview renders keeps the raw unicode" - ); - assert_eq!(entry["Author"]["Email"], "lyna@nel.pet"); - assert_eq!( - entry["Title"], "café changes", - "structured subject the appview renders keeps the raw unicode" - ); - assert_eq!(entry["RawHeaders"]["Subject"][0], "[PATCH] café changes"); - assert_eq!( - entry["RawHeaders"]["From"][0], - "Lýna Þórsdóttir " - ); - let raw = entry["Raw"].as_str().unwrap(); - assert!( - raw.contains("From: Lýna Þórsdóttir "), - "knot emits the raw UTF-8 author instead of RFC2047 Q-encoding real format-patch uses" - ); - assert!( - !raw.contains("=?UTF-8?") && !raw.contains("=?utf-8?"), - "no MIME word-encoding headers" - ); - assert!(raw.contains("Subject: [PATCH] café changes")); - assert!( - raw.ends_with("-- \nknot"), - "knot signs the patch w/ its own trailer" - ); -} - -#[tokio::test] -async fn compare_reports_the_merge_base_of_diverged_branches() { - let world = World::new(); - let (did, work) = seeded(&world, &RepoName::new("woofie").unwrap()); - let work = work.path(); - let bare = world.layout.repo_path(&did).unwrap(); - let merge_base = sh_git(work, &["rev-parse", "HEAD"]); - - sh_git(work, &["checkout", "-q", "-b", "feature"]); - commit_file( - work, - "feature.txt", - b"feature\n", - "feature moved", - "2026-08-11T12:40:00+02:00", - ); - sh_git( - work, - &[ - "push", - "-q", - bare.to_str().unwrap(), - "HEAD:refs/heads/feature", - ], - ); - - sh_git(work, &["checkout", "-q", "main"]); - commit_file( - work, - "main.txt", - b"main\n", - "main moved", - "2026-08-11T12:41:00+02:00", - ); - sh_git(work, &["push", "-q", bare.to_str().unwrap(), "main"]); - - let value = get_json( - &world, - &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1=feature&rev2=main"), - ) - .await; - - assert_eq!(value["merge_base"], merge_base.as_str()); -} - #[tokio::test] async fn diff_reports_structured_fragments_and_stats() { let world = World::new(); let (did, work) = seeded(&world, &RepoName::new("whelk").unwrap()); let head = sh_git(work.path(), &["rev-parse", "HEAD"]); + let base = sh_git(work.path(), &["rev-parse", "HEAD~1"]); let value = get_json( &world, - &format!("/xrpc/sh.tangled.repo.diff?repo={did}&ref={head}"), - ) - .await; - assert_eq!(value["ref"], head.as_str()); - let diff = &value["diff"]; - assert_eq!(diff["stat"]["files_changed"].as_i64(), Some(1)); - assert_eq!(diff["stat"]["insertions"].as_i64(), Some(1)); - assert_eq!(diff["stat"]["deletions"].as_i64(), Some(1)); - let file = &diff["diff"][0]; - assert_eq!(file["name"]["new"], "README.md"); - assert_eq!(file["is_new"], false); - let fragment = &file["text_fragments"][0]; - assert_eq!(fragment["OldPosition"].as_i64(), Some(1)); - assert_eq!(fragment["Comment"], ""); - let lines = fragment["Lines"].as_array().unwrap(); - assert!( - lines - .iter() - .any(|line| line["Op"].as_i64() == Some(1) && line["Line"] == "hello\n") - ); - assert!( - lines - .iter() - .any(|line| line["Op"].as_i64() == Some(2) && line["Line"] == "hello reef\n") - ); - assert_eq!(diff["commit"]["this"], head.as_str()); -} - -#[tokio::test] -async fn compare_produces_format_patches_and_a_combined_patch() { - let world = World::new(); - let (did, work) = seeded(&world, &RepoName::new("conch").unwrap()); - let head = sh_git(work.path(), &["rev-parse", "HEAD"]); - let base = sh_git(work.path(), &["rev-parse", "HEAD~3"]); - - let value = get_json( - &world, - &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={base}&rev2={head}"), - ) - .await; - assert_eq!(value["rev1"], base.as_str()); - assert_eq!(value["rev2"], head.as_str()); - let patches = value["format_patch"].as_array().unwrap(); - assert_eq!(patches.len(), 3, "three commits separate base from head"); - let first = &patches[0]; - assert_eq!(first["Title"], "add main"); - assert_eq!(first["SubjectPrefix"], "[PATCH] "); - assert_eq!(first["Committer"], serde_json::Value::Null); - assert_eq!(first["CommitterDate"], "0001-01-01T00:00:00Z"); - assert_eq!(first["Author"]["Name"], "nel"); - assert_eq!(first["AuthorDate"], "2026-06-01T12:31:00+02:00"); - assert_eq!(first["RawHeaders"]["Subject"][0], "[PATCH] add main"); - let raw = first["Raw"].as_str().unwrap(); - assert!(raw.starts_with(&format!( - "From {} Mon Sep 17 00:00:00 2001\n", - sh_git(work.path(), &["rev-parse", "HEAD~2"]) - ))); - assert!(raw.contains("Subject: [PATCH] add main")); - assert!(raw.contains("diff --git a/src/main.rs b/src/main.rs")); - assert!(raw.contains("new file mode 100644")); - assert!(first["Files"][0]["NewName"] == "src/main.rs"); - assert!(first["Files"][0]["IsNew"] == true); - - assert!(value["patch"].as_str().unwrap().contains("add logo")); - let combined = value["combined_patch"].as_array().unwrap(); - assert!(combined.iter().any(|file| file["NewName"] == "README.md")); - assert!( - value["combined_patch_raw"] - .as_str() - .unwrap() - .contains("diff --git a/README.md b/README.md") + &format!("/xrpc/org.tangled.git.getDiff?repo={did}&base={base}&head={head}"), + ) + .await; + let stats = &value["stats"]; + assert_eq!(stats["filesChanged"].as_i64(), Some(1)); + assert_eq!(stats["insertions"].as_i64(), Some(1)); + assert_eq!(stats["deletions"].as_i64(), Some(1)); + let diff = &value["diffs"][0]; + assert_eq!(diff["lhsSrc"]["path"], "README.md"); + assert_eq!(diff["rhsSrc"]["path"], "README.md"); + assert_eq!(diff["lhsSrc"]["isBinary"], false); + assert_eq!(diff["lhsSrc"]["isSubmodule"], false); + assert!(diff["lhsSrc"]["size"].as_i64().unwrap() > 0); + assert_eq!(diff["hasSyntacticChanges"], true); + let hunk = &diff["hunks"][0]; + assert_eq!(hunk["novelLhs"], serde_json::json!([2])); + assert_eq!(hunk["novelRhs"], serde_json::json!([2])); + assert_eq!( + hunk["lines"], + serde_json::json!([ + { "lhs": 0, "rhs": 0 }, + { "lhs": 1, "rhs": 1 }, + { "lhs": 2 }, + { "rhs": 2 }, + ]), + "0-based line pairs: context lines first, then one deleted hello and one added hello reef" ); assert_eq!( get_error( &world, - &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1=nope&rev2={head}"), + &format!("/xrpc/org.tangled.git.getDiff?repo={did}&base=nope&head={head}"), ) .await, - (StatusCode::BAD_REQUEST, "RevisionNotFound".to_string()) + (StatusCode::NOT_FOUND, "RefNotFound".to_string()) ); } @@ -576,7 +445,7 @@ async fn compare_produces_format_patches_and_a_combined_patch() { async fn archive_conditional_and_range_semantics() { let world = World::new(); let (did, _work) = seeded(&world, &RepoName::new("nautilus").unwrap()); - let path = format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=main"); + let path = format!("/xrpc/org.tangled.git.getArchive?repo={did}&ref=main&prefix=nautilus-main"); let (status, headers, full) = get(&world, &path).await; assert_eq!(status, StatusCode::OK); @@ -594,11 +463,12 @@ async fn archive_conditional_and_range_semantics() { ); let link = headers.get(header::LINK).unwrap().to_str().unwrap(); assert!(link.contains("rel=\"immutable\"")); - assert!(link.contains("/xrpc/sh.tangled.repo.archive?format=tar.gz")); + assert!(link.contains("/xrpc/org.tangled.git.getArchive?")); + assert!(link.contains("prefix=nautilus-main")); assert_eq!(headers.get(header::ACCEPT_RANGES).unwrap(), "bytes"); let last_modified = headers .get(header::LAST_MODIFIED) - .expect("a pinned modification time backs date revalidation") + .expect("the archive file's mtime is the commit's time, backing date revalidation") .to_str() .unwrap() .to_string(); @@ -645,7 +515,7 @@ async fn archive_conditional_and_range_semantics() { let (status, _) = get_error( &world, - &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=main&format=tar.bz2"), + &format!("/xrpc/org.tangled.git.getArchive?repo={did}&ref=main&prefix=p&format=tar.bz2"), ) .await; assert_eq!(status, StatusCode::BAD_REQUEST); @@ -698,7 +568,7 @@ async fn archive_conditional_and_range_semantics() { assert_eq!( status, StatusCode::PARTIAL_CONTENT, - "a date matching the pinned last-modified resumes the range" + "a date matching the commit-time last-modified resumes the range" ); assert_eq!( date_headers @@ -720,7 +590,7 @@ async fn archive_conditional_and_range_semantics() { assert_eq!( status, StatusCode::OK, - "a date that doesn't match the pinned last-modified falls back to the full body" + "a date that doesn't match the commit-time last-modified falls back to the full body" ); assert_eq!(body, full); @@ -728,7 +598,7 @@ async fn archive_conditional_and_range_semantics() { } #[tokio::test] -async fn archive_etag_distinguishes_refs_that_share_a_commit() { +async fn archive_etag_distinguishes_prefixes_that_share_a_commit() { let world = World::new(); let (did, work) = seeded(&world, &RepoName::new("scallop").unwrap()); let bare = world.layout.repo_path(&did).unwrap(); @@ -738,10 +608,11 @@ async fn archive_etag_distinguishes_refs_that_share_a_commit() { &["push", "-q", bare.to_str().unwrap(), "refs/heads/release"], ); - let (main_etag, _main_last_modified, main_body) = archive_full(&world, &did).await; + let (main_etag, _main_last_modified, main_body) = + archive_full(&world, &did, "scallop-main").await; let (status, release_headers, release_body) = get( &world, - &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=release"), + &format!("/xrpc/org.tangled.git.getArchive?repo={did}&ref=release&prefix=scallop-release"), ) .await; assert_eq!(status, StatusCode::OK); @@ -753,7 +624,7 @@ async fn archive_etag_distinguishes_refs_that_share_a_commit() { .to_string(); assert_ne!( main_etag, release_etag, - "two refs at one commit name different archive prefixes, so the strong etag must differ" + "two prefixes over one commit must give different strong etags" ); assert_ne!( main_body, release_body, @@ -764,14 +635,14 @@ async fn archive_etag_distinguishes_refs_that_share_a_commit() { conditional.insert(header::IF_NONE_MATCH, main_etag.parse().unwrap()); let (status, _, _) = get_with_headers( &world, - &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=release"), + &format!("/xrpc/org.tangled.git.getArchive?repo={did}&ref=release&prefix=scallop-release"), conditional, ) .await; assert_eq!( status, StatusCode::OK, - "main's etag mustn't satisfy a conditional request for the release archive" + "the main prefix's etag mustn't satisfy a conditional request for the release archive" ); } @@ -779,10 +650,16 @@ async fn archive_etag_distinguishes_refs_that_share_a_commit() { async fn archive_link_advertises_the_prefix_it_served() { let world = World::new(); let (did, _work) = seeded(&world, &RepoName::new("periwinkle").unwrap()); - let query = |suffix: &str| format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=main{suffix}"); + let query = + |suffix: &str| format!("/xrpc/org.tangled.git.getArchive?repo={did}&ref=main{suffix}"); + + assert_eq!( + get_error(&world, &query("")).await, + (StatusCode::BAD_REQUEST, "InvalidRequest".to_string()), + "the lexicon makes prefix a required parameter" + ); for (suffix, prefix, filename) in [ - ("", "periwinkle-main", "periwinkle-main.tar.gz"), ("&prefix=kelp/uni", "kelp%2Funi", "kelp-uni.tar.gz"), ("&prefix=/kelp//./uni/", "kelp%2Funi", "kelp-uni.tar.gz"), ] { @@ -791,12 +668,12 @@ async fn archive_link_advertises_the_prefix_it_served() { let link = headers[header::LINK].to_str().unwrap(); assert!( link.contains(&format!("prefix={prefix}")), - "the link repeats the prefix that the knot served, percent-encoded, since a stem built from the resolved commit would differ: {suffix} gave {link}" + "the link repeats the cleaned prefix the knot served, percent-encoded: {suffix} gave {link}" ); assert_eq!( headers[header::CONTENT_DISPOSITION], format!("attachment; filename=\"{filename}\""), - "the filename follows the prefix with the separator flattened, and a did-addressed repo's filename gets its rkey" + "the filename follows the prefix with the separator flattened" ); let etag = headers[header::ETAG].to_str().unwrap().to_string(); assert_immutable_round_trip(&world, &headers, &full, &etag).await; @@ -818,7 +695,7 @@ async fn archive_serves_a_sha256_repo_with_a_stable_etag() { let (status, headers, full) = get( &world, - &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=main"), + &format!("/xrpc/org.tangled.git.getArchive?repo={did}&ref=main&prefix=nautilus-main"), ) .await; assert_eq!(status, StatusCode::OK); @@ -840,7 +717,7 @@ async fn archive_serves_a_sha256_repo_with_a_stable_etag() { conditional.insert(header::IF_NONE_MATCH, etag.parse().unwrap()); let (status, _, body) = get_with_headers( &world, - &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=main"), + &format!("/xrpc/org.tangled.git.getArchive?repo={did}&ref=main&prefix=nautilus-main"), conditional, ) .await; @@ -859,9 +736,10 @@ async fn languages_detect_rust_and_markdown_stays_out() { let value = get_json( &world, - &format!("/xrpc/sh.tangled.repo.languages?repo={did}&ref=main"), + &format!("/xrpc/org.tangled.git.listLanguages?repo={did}&ref=main"), ) .await; + assert_eq!(value["ref"], "main"); let languages = value["languages"].as_array().unwrap(); assert_eq!( languages.len(), @@ -872,65 +750,38 @@ async fn languages_detect_rust_and_markdown_stays_out() { assert_eq!(languages[0]["percentage"].as_i64(), Some(100)); assert!(languages[0]["size"].as_i64().unwrap() > 0); assert_eq!(value["totalFiles"].as_i64(), Some(1)); + assert!(value["totalSize"].as_i64().is_some_and(|total| total > 0)); } #[tokio::test] -async fn repo_metadata_resolves_and_fails_closed() { +async fn repo_reads_fail_closed_on_unknown_repos_refs_and_params() { let world = World::new(); let (did, _work) = seeded(&world, &RepoName::new("cuttle").unwrap()); - let value = get_json( - &world, - &format!("/xrpc/sh.tangled.repo.getDefaultBranch?repo={did}"), - ) - .await; - assert_eq!(value["name"], "main"); - assert_eq!(value["hash"], ""); - assert_eq!(value["when"], "1970-01-01T00:00:00Z"); - - let described = get_json( + let refs = get_json( &world, - &format!("/xrpc/sh.tangled.repo.describeRepo?repoDid={did}"), + &format!("/xrpc/org.tangled.git.listRefs?repo={did}"), ) .await; - assert_eq!(described["repoDid"], did.as_str()); - assert_eq!(described["ownerDid"], owner().as_str()); - assert_eq!(described["rkey"], "cuttle"); - assert_eq!( - get_error( - &world, - "/xrpc/sh.tangled.repo.describeRepo?repoDid=did:plc:doesnotexist", - ) - .await, - (StatusCode::NOT_FOUND, "RepoNotFound".to_string()) - ); + assert_eq!(refs["defaultBranch"]["ref"], "refs/heads/main"); - let by_owner = get_json( - &world, - &format!("/xrpc/sh.tangled.repo.getDefaultBranch?repo={}/cuttle", owner()), - ) - .await; - assert_eq!(by_owner["name"], "main"); assert_eq!( get_error( &world, - "/xrpc/sh.tangled.repo.getDefaultBranch?repo=did:plc:unregistered", + "/xrpc/org.tangled.git.getTree?repo=did:plc:unregistered", ) .await, (StatusCode::NOT_FOUND, "RepoNotFound".to_string()) ); - let (status, _) = get_error(&world, "/xrpc/sh.tangled.repo.getDefaultBranch?repo=oyster").await; - assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!( get_error( &world, - &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref=mangrove"), + &format!("/xrpc/org.tangled.git.listCommits?repo={did}&ref=mangrove"), ) .await, (StatusCode::NOT_FOUND, "RefNotFound".to_string()) ); - - let (status, error) = get_error(&world, "/xrpc/sh.tangled.repo.getDefaultBranch").await; + let (status, error) = get_error(&world, "/xrpc/org.tangled.git.getTree").await; assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!( error, "InvalidRequest", @@ -944,9 +795,13 @@ async fn list_refs_reports_paginates_and_drains() { let (did, work) = seeded(&world, &RepoName::new("whelk").unwrap()); let head = sh_git(work.path(), &["rev-parse", "HEAD"]); - let value = get_json(&world, &format!("/xrpc/sh.tangled.git.listRefs?repo={did}")).await; + let value = get_json( + &world, + &format!("/xrpc/org.tangled.git.listRefs?repo={did}"), + ) + .await; assert_eq!( - ref_names(&value, "refs"), + ref_list(&value, "refs"), vec![ "refs/heads/main", "refs/tags/lightweight", @@ -966,40 +821,33 @@ async fn list_refs_reports_paginates_and_drains() { let first = get_json( &world, - &format!("/xrpc/sh.tangled.git.listRefs?repo={did}&limit=2"), + &format!("/xrpc/org.tangled.git.listRefs?repo={did}&limit=2"), ) .await; - assert_eq!(ref_names(&first, "refs").len(), 2); + assert_eq!(ref_list(&first, "refs").len(), 2); let cursor = first["cursor"].as_str().unwrap().to_string(); let second = get_json( &world, - &format!("/xrpc/sh.tangled.git.listRefs?repo={did}&limit=2&cursor={cursor}"), + &format!("/xrpc/org.tangled.git.listRefs?repo={did}&limit=2&cursor={cursor}"), ) .await; - assert_eq!(ref_names(&second, "refs").len(), 1); + assert_eq!(ref_list(&second, "refs").len(), 1); assert!(second["cursor"].is_null()); let refs = get_json( &world, &format!( - "/xrpc/sh.tangled.git.listRefs?repo={did}&cursor={}", + "/xrpc/org.tangled.git.listRefs?repo={did}&cursor={}", usize::MAX ), ) .await; - assert!(ref_names(&refs, "refs").is_empty()); + assert!(ref_list(&refs, "refs").is_empty()); assert!(refs["cursor"].is_null()); - let repos = get_json( - &world, - &format!("/xrpc/sh.tangled.sync.listRepos?cursor={}", usize::MAX), - ) - .await; - assert!(repo_dids(&repos).is_empty()); - assert!(repos["cursor"].is_null()); } #[tokio::test] -async fn a_hidden_staging_ref_resolves_for_fork_comparison_reads() { +async fn a_hidden_staging_ref_resolves_for_log_reads() { let world = World::new(); let (did, work) = seeded(&world, &RepoName::new("limpet").unwrap()); let bare = world.layout.repo_path(&did).unwrap(); @@ -1035,24 +883,20 @@ async fn a_hidden_staging_ref_resolves_for_fork_comparison_reads() { &["push", "-q", bare.to_str().unwrap(), "HEAD:refs/heads/main"], ); - let comparison = get_json( - &world, - &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1=hidden/main/main&rev2=main"), - ) - .await; - assert_eq!(comparison["rev1"].as_str().unwrap(), upstream); - assert!(!comparison["format_patch"].as_array().unwrap().is_empty()); - let log = get_json( &world, - &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref=refs/hidden/main/main"), + &format!("/xrpc/org.tangled.git.listCommits?repo={did}&ref=refs/hidden/main/main"), ) .await; - assert!(!log["commits"].as_array().unwrap().is_empty()); + assert_eq!( + log["commits"].as_array().unwrap()[0]["hash"], + upstream, + "the hidden staging ref resolves for fork comparison reads" + ); let (status, _) = get_error( &world, - &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref={upstream}"), + &format!("/xrpc/org.tangled.git.listCommits?repo={did}&ref={upstream}"), ) .await; assert_eq!( @@ -1060,13 +904,6 @@ async fn a_hidden_staging_ref_resolves_for_fork_comparison_reads() { StatusCode::NOT_FOUND, "a raw oid reachable only through the hidden ref mustn't resolve" ); - let (status, error) = get_error( - &world, - &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={upstream}&rev2=main"), - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(error, "RevisionNotFound"); } #[tokio::test] @@ -1075,12 +912,16 @@ async fn the_cob_ref_namespace_is_invisible_across_every_read() { let (did, work) = seeded(&world, &RepoName::new("anemone").unwrap()); let head = sh_git(work.path(), &["rev-parse", "HEAD"]); let bare = world.layout.repo_path(&did).unwrap(); - let cob = "refs/cobs/sh.tangled.repo.collaborator/x"; + let cob = "refs/cobs/org.tangled.graph.collaborator/x"; sh_git(bare.as_path(), &["update-ref", cob, &head]); - let value = get_json(&world, &format!("/xrpc/sh.tangled.git.listRefs?repo={did}")).await; + let value = get_json( + &world, + &format!("/xrpc/org.tangled.git.listRefs?repo={did}"), + ) + .await; assert!( - ref_names(&value, "refs") + ref_list(&value, "refs") .iter() .all(|name| !name.starts_with("refs/cobs/")), "reserved cob ref leaked into listRefs" @@ -1088,31 +929,33 @@ async fn the_cob_ref_namespace_is_invisible_across_every_read() { let w = &world; let d = &did; - let named_routes: &[(&str, &str)] = &[ - ("log", ""), - ("tree", ""), - ("blob", "&path=README.md"), - ("diff", ""), - ("archive", ""), - ("languages", ""), + let probe_routes: &[(&str, &str)] = &[ + ("org.tangled.git.listCommits", "&ref={cob}"), + ("org.tangled.git.getTree", "&ref={cob}"), + ("org.tangled.git.getBlob", "&ref={cob}&path=README.md"), + ("org.tangled.git.getDiff", "&base={cob}&head={head}"), + ("org.tangled.git.getArchive", "&ref={cob}&prefix=p"), + ("org.tangled.git.listLanguages", "&ref={cob}"), ]; - stream::iter(named_routes) - .for_each(|&(route, suffix)| async move { - let (status, _) = get_error( - w, - &format!("/xrpc/sh.tangled.repo.{route}?repo={d}&ref={cob}{suffix}"), - ) - .await; - assert_eq!( - status, - StatusCode::NOT_FOUND, - "{route} mustn't resolve reserved cobs ref" - ); + stream::iter(probe_routes) + .for_each(|(route, suffix)| { + let head = head.clone(); + async move { + let query = suffix.replace("{cob}", cob).replace("{head}", &head); + let (status, _) = get_error(w, &format!("/xrpc/{route}?repo={d}&{query}")).await; + assert_eq!( + status, + StatusCode::NOT_FOUND, + "{route} mustn't resolve reserved cobs ref" + ); + } }) .await; let (status, _) = get_error( &world, - &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref=cobs/sh.tangled.repo.collaborator/x"), + &format!( + "/xrpc/org.tangled.git.listCommits?repo={did}&ref=cobs/org.tangled.graph.collaborator/x" + ), ) .await; assert_eq!( @@ -1120,12 +963,6 @@ async fn the_cob_ref_namespace_is_invisible_across_every_read() { StatusCode::NOT_FOUND, "reserved namespace shorthand mustn't resolve either" ); - let (status, _) = get_error( - &world, - &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={cob}&rev2={head}"), - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST); commit_file( work.path(), @@ -1141,52 +978,45 @@ async fn the_cob_ref_namespace_is_invisible_across_every_read() { "push", "-q", bare.to_str().unwrap(), - "HEAD:refs/cobs/sh.tangled.repo.collaborator/secret", + "HEAD:refs/cobs/org.tangled.graph.collaborator/secret", ], ); - let hidden_ref = &hidden; let hidden_routes: &[(&str, &str)] = &[ - ("log", ""), - ("tree", "&path=secret.txt"), - ("diff", ""), - ("archive", ""), - ("languages", ""), + ("org.tangled.git.listCommits", "&ref={hidden}"), + ("org.tangled.git.getTree", "&ref={hidden}&path=secret.txt"), + ("org.tangled.git.getDiff", "&base={head}&head={hidden}"), + ("org.tangled.git.getArchive", "&ref={hidden}&prefix=p"), + ("org.tangled.git.listLanguages", "&ref={hidden}"), ]; stream::iter(hidden_routes) - .for_each(|&(route, suffix)| async move { - let (status, _) = get_error( - w, - &format!("/xrpc/sh.tangled.repo.{route}?repo={d}&ref={hidden_ref}{suffix}"), - ) - .await; - assert_eq!( - status, - StatusCode::NOT_FOUND, - "{route} mustn't serve a commit reachable only through cob ref" - ); + .for_each(|(route, suffix)| { + let hidden = hidden.clone(); + let head = head.clone(); + async move { + let query = suffix.replace("{hidden}", &hidden).replace("{head}", &head); + let (status, _) = get_error(w, &format!("/xrpc/{route}?repo={d}&{query}")).await; + assert_eq!( + status, + StatusCode::NOT_FOUND, + "{route} mustn't serve a commit reachable only through cob ref" + ); + } }) .await; let (status, _) = get_error( &world, - &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref={hidden}&path=secret.txt"), + &format!("/xrpc/org.tangled.git.getBlob?repo={did}&ref={hidden}&path=secret.txt"), ) .await; assert_eq!(status, StatusCode::NOT_FOUND); - let (status, error) = get_error( - &world, - &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={head}&rev2={hidden}"), - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(error, "RevisionNotFound"); let still_public = get_json( &world, - &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref={head}"), + &format!("/xrpc/org.tangled.git.listCommits?repo={did}&ref={head}"), ) .await; - assert_eq!(still_public["total"].as_i64(), Some(4)); + assert_eq!(still_public["commits"].as_array().unwrap().len(), 4); } #[tokio::test] @@ -1195,11 +1025,9 @@ async fn the_list_reads_reject_malformed_paging_params() { let (did, _work) = seeded(&world, &RepoName::new("barnacle").unwrap()); let queries: Vec = vec![ - format!("/xrpc/sh.tangled.git.listRefs?repo={did}&limit=abc"), - format!("/xrpc/sh.tangled.git.listRefs?repo={did}&cursor=notanint"), - "/xrpc/sh.tangled.sync.listRepos?limit=abc".to_string(), - "/xrpc/sh.tangled.sync.listRepos?cursor=notanint".to_string(), - "/xrpc/sh.tangled.sync.listRepos?order=sideways".to_string(), + format!("/xrpc/org.tangled.git.listRefs?repo={did}&limit=abc"), + format!("/xrpc/org.tangled.git.listRefs?repo={did}&cursor=notanint"), + format!("/xrpc/org.tangled.git.listBranches?repo={did}&limit=abc"), ]; let w = &world; stream::iter(queries.iter()) @@ -1212,54 +1040,54 @@ async fn the_list_reads_reject_malformed_paging_params() { let clamped = get_json( &world, - &format!("/xrpc/sh.tangled.git.listRefs?repo={did}&limit=5000"), + &format!("/xrpc/org.tangled.git.listRefs?repo={did}&limit=5000"), ) .await; assert_eq!( - ref_names(&clamped, "refs").len(), + ref_list(&clamped, "refs").len(), 3, "an oversize limit clamps to the max instead of erroring" ); } #[tokio::test] -async fn list_repos_lists_hosted_repos_with_order_and_pagination() { +async fn list_repos_lists_hosted_repos_with_pagination() { let world = World::new(); let (mussel, _a) = seeded(&world, &RepoName::new("mussel").unwrap()); let (nautilus, _b) = seeded(&world, &RepoName::new("nautilus").unwrap()); let (scallop, _c) = seeded(&world, &RepoName::new("scallop").unwrap()); + common::enable_chain(&world, &world.state.knot_repo); + common::enable_chain(&world, &mussel); + common::enable_chain(&world, &nautilus); + common::enable_chain(&world, &scallop); - let desc = get_json(&world, "/xrpc/sh.tangled.sync.listRepos").await; - assert_eq!( - repo_dids(&desc), - vec![ - scallop.as_str().to_string(), - nautilus.as_str().to_string(), - mussel.as_str().to_string(), - ] - ); - assert_eq!(desc["repos"][0]["status"], "active"); - assert_eq!(desc["repos"][0]["defaultBranch"]["ref"], "refs/heads/main"); - - let asc = get_json(&world, "/xrpc/sh.tangled.sync.listRepos?order=asc").await; + let desc = get_json(&world, "/xrpc/com.atproto.sync.listRepos").await; + let listed = repo_dids(&desc); assert_eq!( - repo_dids(&asc), + listed[1..], vec![ mussel.as_str().to_string(), nautilus.as_str().to_string(), scallop.as_str().to_string(), - ] + ][..], + "registry order, behind the knot's own account" ); + assert_eq!(desc["repos"][0]["did"], world.state.knot_repo.as_str()); + assert_eq!(desc["repos"][1]["active"], true); - let page = get_json(&world, "/xrpc/sh.tangled.sync.listRepos?order=asc&limit=2").await; - assert_eq!(page["repos"].as_array().unwrap().len(), 2); + let page = get_json(&world, "/xrpc/com.atproto.sync.listRepos?limit=3").await; + assert_eq!(page["repos"].as_array().unwrap().len(), 3); let cursor = page["cursor"].as_str().unwrap().to_string(); let rest = get_json( &world, - &format!("/xrpc/sh.tangled.sync.listRepos?order=asc&limit=2&cursor={cursor}"), + &format!("/xrpc/com.atproto.sync.listRepos?limit=3&cursor={cursor}"), ) .await; - assert_eq!(repo_dids(&rest), vec![scallop.as_str().to_string()]); + assert_eq!( + repo_dids(&rest), + vec![scallop.as_str().to_string()], + "the cursor resumes where the page stopped" + ); assert!(rest["cursor"].is_null()); } @@ -1269,25 +1097,19 @@ async fn every_projection_read_fails_closed_while_warming() { let did = RepoDid::new("did:plc:limpetfixture").unwrap(); let cases: Vec<(String, Option<&str>)> = vec![ ( - "/xrpc/sh.tangled.sync.listRepos".to_string(), + "/xrpc/com.atproto.sync.listRepos".to_string(), Some("ProjectionWarming"), ), ( - format!("/xrpc/sh.tangled.repo.getDefaultBranch?repo={did}"), + format!("/xrpc/com.atproto.repo.describeRepo?repo={did}"), Some("ProjectionWarming"), ), ( - format!("/xrpc/sh.tangled.repo.describeRepo?repoDid={did}"), + format!( + "/xrpc/com.atproto.repo.listRecords?repo={did}&collection=org.tangled.knot.member" + ), Some("ProjectionWarming"), ), - ( - "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet".to_string(), - None, - ), - ( - "/xrpc/sh.tangled.repo.listCollaborators?subject=did:plc:squid".to_string(), - None, - ), ]; let w = &world; stream::iter(cases.iter()) @@ -1319,8 +1141,7 @@ async fn branch_tips_render_edge_shapes() { &["merge", "-q", "--no-ff", "-m", "merge side", "side"], ); sh_git(work.path(), &["push", "-q", &bare_str, "main"]); - let first_parent = sh_git(work.path(), &["rev-parse", "HEAD^1"]); - let second_parent = sh_git(work.path(), &["rev-parse", "HEAD^2"]); + let merge_tip = sh_git(work.path(), &["rev-parse", "HEAD"]); let tag_object = sh_git(work.path(), &["rev-parse", "v1.0.0"]); std::fs::write(bare.join("refs/heads/tagtip"), format!("{tag_object}\n")).unwrap(); @@ -1329,62 +1150,47 @@ async fn branch_tips_render_edge_shapes() { let value = get_json( &world, - &format!("/xrpc/sh.tangled.repo.branches?repo={did}"), + &format!("/xrpc/org.tangled.git.listBranches?repo={did}"), ) .await; let branches = value["branches"].as_array().unwrap(); assert_eq!(branches.len(), 3); + assert_eq!(value["total"].as_i64(), Some(3)); let branch = |name: &str| { branches .iter() - .find(|branch| branch["reference"]["name"] == name) - .unwrap() - }; - let parents = |branch: &serde_json::Value| -> Vec { - branch["commit"]["ParentHashes"] - .as_array() + .find(|branch| branch["name"] == name) .unwrap() - .iter() - .map(|parent| { - parent - .as_array() - .unwrap() - .iter() - .map(|byte| format!("{:02x}", byte.as_u64().unwrap())) - .collect() - }) - .collect() }; let main = branch("main"); assert_eq!( - parents(main), - vec![first_parent, second_parent], - "merge tip must report both parents in order" - ); - assert_eq!(main["commit"]["Author"]["Name"], "nel"); - assert!( - parents(branch("roottip")).is_empty(), - "root tip must report no parents" + main["oid"], merge_tip, + "the merge tip is reported as the branch's oid" ); + assert_eq!(main["message"], "merge side\n"); + assert_eq!(main["author"]["name"], "nel"); + assert_eq!(main["committer"]["name"], "nel"); + + let root = branch("roottip"); + assert_eq!(root["oid"], root_commit); + assert_eq!(root["message"], "first\n"); let tagtip = branch("tagtip"); - assert!( - parents(tagtip).is_empty(), - "a non-commit tip must report no parents" + assert_eq!( + tagtip["oid"], tag_object, + "a non-commit tip still reports the ref's own target" ); - assert_eq!(tagtip["reference"]["hash"], tag_object.as_str()); - assert_eq!(tagtip["commit"]["Author"]["Name"], ""); - assert_eq!(tagtip["commit"]["Author"]["When"], "0001-01-01T00:00:00Z"); - assert_eq!(tagtip["commit"]["Message"], "release one"); - assert!( - tagtip["commit"]["TreeHash"] - .as_array() - .unwrap() - .iter() - .all(|byte| byte.as_u64() == Some(0)), - "opaque tip has the zero tree hash" + assert_eq!(tagtip["message"], "release one"); + assert_eq!( + tagtip["author"], + serde_json::json!({ + "name": "", + "email": "", + "when": "1970-01-01T00:00:00Z", + }), + "a ref pointing past a commit has no identities to cite" ); } @@ -1415,7 +1221,7 @@ async fn a_submodule_path_in_the_tree_is_path_not_found() { assert_eq!( get_error( &world, - &format!("/xrpc/sh.tangled.repo.tree?repo={did}&ref=main&path=vendor/dep"), + &format!("/xrpc/org.tangled.git.getTree?repo={did}&ref=main&path=vendor/dep"), ) .await, (StatusCode::NOT_FOUND, "PathNotFound".to_string()) @@ -1423,13 +1229,13 @@ async fn a_submodule_path_in_the_tree_is_path_not_found() { } #[tokio::test] -async fn a_blob_past_the_derived_serving_limit_is_a_named_error() { - let world = World::with_response_limit(ResponseLimit::new(1024)); - let (did, bare, work) = empty_repo(&world, &RepoName::new("auger").unwrap()); +async fn blame_rejects_a_blob_past_its_work_limit() { + let world = World::new(); + let (did, bare, work) = empty_repo(&world, &RepoName::new("whelk").unwrap()); commit_file( work.path(), "big.txt", - "a".repeat(2_000).as_bytes(), + &vec![b'a'; 1024 * 1024 + 1], "big file", "2026-06-01T12:30:00+02:00", ); @@ -1437,44 +1243,51 @@ async fn a_blob_past_the_derived_serving_limit_is_a_named_error() { let (status, error) = get_error( &world, - &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref=main&path=big.txt"), + &format!("/xrpc/org.tangled.git.getBlame?repo={did}&ref=main&path=big.txt"), ) .await; assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); - assert_eq!( - error, "BlobTooLarge", - "blob limit is reached before the generic response limit" - ); - - let (status, _, body) = get( - &world, - &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref=main&path=big.txt&raw=true"), - ) - .await; - assert_eq!(status, StatusCode::OK, "raw serving keeps full limit"); - assert_eq!(body.len(), 2_000); + assert_eq!(error, "BlameTooLarge"); } #[tokio::test] -async fn blame_refuses_a_blob_past_its_work_limit() { +async fn a_blob_past_the_serving_limit_is_refused_as_blob_too_large() { let world = World::new(); - let (did, bare, work) = empty_repo(&world, &RepoName::new("whelk").unwrap()); + let (did, bare, work) = empty_repo(&world, &RepoName::new("auger").unwrap()); commit_file( work.path(), - "big.txt", - &vec![b'a'; 1024 * 1024 + 1], - "big file", + "small.txt", + b"hello\n", + "small file", "2026-06-01T12:30:00+02:00", ); + commit_file( + work.path(), + "big.bin", + &vec![0u8; 25 * 1024 * 1024 + 1], + "big file", + "2026-06-01T12:31:00+02:00", + ); sh_git(work.path(), &["push", "-q", &bare, "main"]); let (status, error) = get_error( &world, - &format!("/xrpc/sh.tangled.repo.blame?repo={did}&ref=main&path=big.txt"), + &format!("/xrpc/org.tangled.git.getBlob?repo={did}&ref=main&path=big.bin"), ) .await; assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); - assert_eq!(error, "BlameTooLarge"); + assert_eq!( + error, "BlobTooLarge", + "twenty five mebibytes is the limit itself, not a bound derived from the response budget" + ); + + let (status, _, body) = get( + &world, + &format!("/xrpc/org.tangled.git.getBlob?repo={did}&ref=main&path=small.txt"), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, "hello\n", "a blob under the limit still serves"); } #[tokio::test] @@ -1492,7 +1305,7 @@ async fn blame_accepts_many_short_lines_below_the_byte_limit() { let blame = get_json( &world, - &format!("/xrpc/sh.tangled.repo.blame?repo={did}&ref=main&path=many.txt"), + &format!("/xrpc/org.tangled.git.getBlame?repo={did}&ref=main&path=many.txt"), ) .await; assert_eq!(blame["runs"].as_array().unwrap().len(), 1); @@ -1500,7 +1313,7 @@ async fn blame_accepts_many_short_lines_below_the_byte_limit() { } #[tokio::test] -async fn blame_refuses_a_large_historical_blob_behind_a_small_target() { +async fn blame_rejects_a_large_historical_blob_behind_a_small_target() { let world = World::new(); let (did, bare, work) = empty_repo(&world, &RepoName::new("barnacle").unwrap()); let large = "x\n".repeat(524_289); @@ -1522,66 +1335,12 @@ async fn blame_refuses_a_large_historical_blob_behind_a_small_target() { let (status, error) = get_error( &world, - &format!("/xrpc/sh.tangled.repo.blame?repo={did}&ref=main&path=history.txt"), + &format!("/xrpc/org.tangled.git.getBlame?repo={did}&ref=main&path=history.txt"), ) .await; assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); assert_eq!(error, "BlameTooLarge"); } -#[tokio::test] -async fn an_oversized_readme_is_omitted_from_the_tree() { - let world = World::with_response_limit(ResponseLimit::new(2_048)); - let (did, bare, work) = empty_repo(&world, &RepoName::new("cowrie").unwrap()); - commit_file( - work.path(), - "README.md", - format!("# reef\n\n{}\n", "r".repeat(1_000)).as_bytes(), - "huge readme", - "2026-06-01T12:30:00+02:00", - ); - sh_git(work.path(), &["push", "-q", &bare, "main"]); - - let value = get_json( - &world, - &format!("/xrpc/sh.tangled.repo.tree?repo={did}&ref=main"), - ) - .await; - assert_eq!(value["files"][0]["name"], "README.md"); - assert_eq!( - value["readme"]["contents"], "", - "readme past the serving limit is omitted instead of failing the whole tree" - ); -} - -#[tokio::test] -async fn a_comparison_spanning_too_many_commits_is_refused() { - let world = World::new(); - let (did, work) = seeded(&world, &RepoName::new("abalone").unwrap()); - let bare = world - .layout - .repo_path(&did) - .unwrap() - .to_str() - .unwrap() - .to_string(); - let base = sh_git(work.path(), &["rev-parse", "HEAD"]); - (0..501).for_each(|index| { - sh_git( - work.path(), - &["commit", "-q", "--allow-empty", "-m", &format!("c{index}")], - ); - }); - sh_git(work.path(), &["push", "-q", &bare, "main"]); - let head = sh_git(work.path(), &["rev-parse", "HEAD"]); - - let (status, error) = get_error( - &world, - &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={base}&rev2={head}"), - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(error, "CompareError"); -} #[tokio::test] async fn archive_rejects_traversal_prefixes_and_sanitizes_the_filename() { @@ -1597,7 +1356,7 @@ async fn archive_rejects_traversal_prefixes_and_sanitizes_the_filename() { let (status, error) = get_error( &world, - &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=main&prefix=../evil"), + &format!("/xrpc/org.tangled.git.getArchive?repo={did}&ref=main&prefix=../evil"), ) .await; assert_eq!(status, StatusCode::BAD_REQUEST); @@ -1607,18 +1366,22 @@ async fn archive_rejects_traversal_prefixes_and_sanitizes_the_filename() { sh_git(work.path(), &["push", "-q", &bare, "refs/heads/a\"b"]); let (status, headers, _) = get( &world, - &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=a%22b"), + &format!("/xrpc/org.tangled.git.getArchive?repo={did}&ref=a%22b&prefix=cockle"), ) .await; - assert_eq!(status, StatusCode::OK); + assert_eq!( + status, + StatusCode::OK, + "a quote in the ref name mustn't break the headers" + ); let disposition = headers .get(header::CONTENT_DISPOSITION) .unwrap() .to_str() .unwrap(); assert_eq!( - disposition, "attachment; filename=\"cockle-a-b.tar.gz\"", - "quote in the ref name mustn't break the header quoting" + disposition, "attachment; filename=\"cockle.tar.gz\"", + "the filename comes from the caller's prefix, so header quoting stays sound" ); } @@ -1629,11 +1392,11 @@ async fn an_archive_larger_than_the_configured_limit_is_refused() { let (status, error) = get_error( &world, - &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=main"), + &format!("/xrpc/org.tangled.git.getArchive?repo={did}&ref=main&prefix=murex"), ) .await; assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); - assert_eq!(error, "RequestTooLarge"); + assert_eq!(error, "ArchiveTooLarge"); } #[tokio::test] @@ -1643,7 +1406,7 @@ async fn an_oversized_read_response_is_refused() { let (status, error) = get_error( &world, - &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref=main"), + &format!("/xrpc/org.tangled.git.listCommits?repo={did}&ref=main"), ) .await; assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); @@ -1651,7 +1414,7 @@ async fn an_oversized_read_response_is_refused() { let (status, error) = get_error( &world, - &format!("/xrpc/sh.tangled.repo.blame?repo={did}&ref=main&path=README.md"), + &format!("/xrpc/org.tangled.git.getBlame?repo={did}&ref=main&path=README.md"), ) .await; assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); @@ -1659,7 +1422,7 @@ async fn an_oversized_read_response_is_refused() { let small = get_json( &world, - &format!("/xrpc/sh.tangled.repo.getDefaultBranch?repo={did}"), + &format!("/xrpc/org.tangled.git.getBranch?repo={did}&name=main"), ) .await; assert_eq!(small["name"], "main"); @@ -1680,11 +1443,12 @@ async fn languages_omit_files_with_zero_size() { let value = get_json( &world, - &format!("/xrpc/sh.tangled.repo.languages?repo={did}&ref=main"), + &format!("/xrpc/org.tangled.git.listLanguages?repo={did}&ref=main"), ) .await; - assert!( - value["languages"].is_null(), + assert_eq!( + value["languages"], + serde_json::json!([]), "zero-byte file mustn't surface as a language with a NaN percentage" ); assert!(value.get("totalSize").is_none()); @@ -1692,389 +1456,464 @@ async fn languages_omit_files_with_zero_size() { } #[tokio::test] -async fn a_compare_patch_round_trips_through_merge_check() { - compare_round_trip(World::new()).await; -} - -#[tokio::test] -async fn a_sha256_compare_patch_round_trips_through_merge_check() { - compare_round_trip(World::sha256()).await; -} - -async fn compare_round_trip(world: World) { +async fn merge_check_reports_clean_and_conflicted_against_a_branch() { + let world = World::new(); let (did, main_sha, feature_sha) = seeded_feature_branch(&world, &RepoName::new("periwinkle").unwrap()); + common::enable_chain(&world, &did); + let (ticket, _) = common::create_ticket(&world, &did, "kelp", "a forest").await; + let (patch, _) = common::create_patch(&world, &did, &ticket, &main_sha, &feature_sha).await; - let compared = get_json( + let clean = post_json( &world, - &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={main_sha}&rev2={feature_sha}"), + "/xrpc/org.tangled.git.mergeCheck", + serde_json::json!({ "repo": did, "patch": patch, "branch": "main" }), ) .await; - let patch = compared["patch"].as_str().unwrap(); - let combined = compared["combined_patch_raw"].as_str().unwrap(); - - let checks = stream::iter([("main", patch), ("main", combined), ("feature", patch)]) - .then(|(branch, candidate)| { - post_json( - &world, - "/xrpc/sh.tangled.repo.mergeCheck", - serde_json::json!({ - "repo": did, - "branch": branch, - "patch": candidate, - }), - ) - }) - .collect::>() - .await; - assert_eq!( - checks - .iter() - .map(|(status, check)| (*status, check["is_conflicted"].clone())) - .collect::>(), - [false, false, true] - .map(|conflicted| (StatusCode::OK, serde_json::Value::Bool(conflicted))) - .to_vec(), - "main takes both patches and feature already has them: {checks:?}" - ); - - [ - "GIT binary patch", - " shell.bin | Bin 4096 -> 4096 bytes\n", - "deleted file mode 100644\n", - " delete mode 100644 anchor.bin\n", - "diff --git a/deep water.bin b/deep water.bin\n", - "old mode 100644\nnew mode 100755\n", - " mode change 100644 => 100755 hull.bin\n", - " hull.bin | Bin\n", - ] - .into_iter() - .for_each(|needle| { - assert!( - patch.contains(needle), - "the format patch is missing {needle:?}: {patch}" - ) - }); - assert!( - combined.contains("GIT binary patch"), - "the combined patch is missing its binary payloads: {combined}" - ); - assert!( - !patch.contains("new mode 100755\nindex "), - "a mode change leaves both sides at the same oid, so git prints no index line: {patch}" - ); - assert_eq!( - compared.get("binary_omitted"), - None, - "binary_omitted is absent when every payload was embedded: {compared}" - ); + assert_eq!(clean.0, StatusCode::OK, "mergeCheck failed: {}", clean.1); + assert_eq!(clean.1["isConflicted"], false); + assert_eq!(clean.1["conflicts"], serde_json::json!([])); + // a side branch that also edits reef.txt conflicts with the patch's own edit let applied = tempfile::tempdir().unwrap(); let bare = world.layout.repo_path(&did).unwrap(); sh_git( applied.path(), &["clone", "-q", bare.to_str().unwrap(), "."], ); - sh_git(applied.path(), &["checkout", "-q", "main"]); - std::fs::write(applied.path().join("knot.patch"), patch).unwrap(); - sh_git(applied.path(), &["am", "knot.patch"]); - assert_eq!( - sh_git(applied.path(), &["rev-parse", "HEAD^{tree}"]), - sh_git(applied.path(), &["rev-parse", "origin/feature^{tree}"]), - "git am of the knot's own patch rebuilds the tree the branch already has" - ); - assert!( - !applied.path().join("anchor.bin").exists() - && applied.path().join("deep water.bin").exists() - && sh_git(applied.path(), &["ls-files", "-s", "hull.bin"]).starts_with("100755 "), - "git am dropped anchor.bin, wrote deep water.bin and kept the exec bit on hull.bin" - ); -} - -#[tokio::test] -async fn list_members_pages_in_the_wire_shape() { - let world = World::new(); - world.add_member( - &AccountDid::new("did:plc:limpet").unwrap(), - &owner(), - UnixSeconds::new(1_000), + sh_git( + applied.path(), + &["checkout", "-q", "-b", "side", &main_sha.to_hex()], ); - world.add_member( - &AccountDid::new("did:plc:scallop").unwrap(), - &owner(), - UnixSeconds::new(2_000), + commit_file( + applied.path(), + "reef.txt", + b"someone else got here first\n", + "side edit", + "2026-06-01T12:40:00+02:00", ); - world.add_member( - &AccountDid::new("did:plc:whelk").unwrap(), - &owner(), - UnixSeconds::new(3_000), + sh_git( + applied.path(), + &["push", "-q", bare.to_str().unwrap(), "refs/heads/side"], ); - let page = get_json( + let conflicted = post_json( &world, - "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&limit=2", + "/xrpc/org.tangled.git.mergeCheck", + serde_json::json!({ "repo": did, "patch": patch, "branch": "side" }), ) .await; - let items = page["items"].as_array().unwrap(); - assert_eq!(items.len(), 2, "default order is createdAt descending"); - assert_eq!(items[0]["subject"], "did:plc:whelk"); - assert_eq!(items[0]["addedBy"], owner().as_str()); - assert_eq!(items[0]["createdAt"], "1970-01-01T00:50:00Z"); - assert!( - items[0].get("uri").is_none() && items[0].get("cid").is_none(), - "knot-owned member has no backing record, so uri and cid are omitted" + assert_eq!( + conflicted.0, + StatusCode::OK, + "mergeCheck reports a conflict, it doesn't error: {}", + conflicted.1 ); - assert_eq!(items[1]["subject"], "did:plc:scallop"); + assert_eq!(conflicted.1["isConflicted"], true); + assert_eq!(conflicted.1["conflicts"][0]["filename"], "reef.txt"); + assert_eq!(conflicted.1["message"], "patch can't be applied cleanly"); +} - let cursor = page["cursor"].as_str().unwrap(); - let next = get_json( +#[tokio::test] +async fn a_binary_patch_round_trips_through_merge_check_and_merge_patch() { + let world = World::new(); + let (did, main_sha, feature_sha) = + seeded_feature_branch(&world, &RepoName::new("periwinkle").unwrap()); + common::enable_chain(&world, &did); + let (ticket, _) = common::create_ticket(&world, &did, "kelp", "a forest").await; + let (patch, _) = common::create_patch(&world, &did, &ticket, &main_sha, &feature_sha).await; + + let diff = get_json( &world, - &format!( - "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&limit=2&cursor={cursor}" - ), + &format!("/xrpc/org.tangled.git.getDiff?repo={did}&base={main_sha}&head={feature_sha}"), ) .await; - let items = next["items"].as_array().unwrap(); - assert_eq!(items.len(), 1); - assert_eq!(items[0]["subject"], "did:plc:limpet"); - assert!(next.get("cursor").is_none(), "drained list has no cursor"); + let binaries = diff["diffs"] + .as_array() + .unwrap() + .iter() + .filter(|entry| !entry["hasByteChanges"].is_null()) + .map(|entry| { + [ + entry["lhsSrc"]["path"].as_str().unwrap(), + entry["rhsSrc"]["path"].as_str().unwrap(), + ] + .into_iter() + .find(|path| !path.is_empty()) + .unwrap() + .to_string() + }) + .collect::>(); + assert_eq!( + binaries, + ["anchor.bin", "deep water.bin", "pearl.bin", "shell.bin"], + "the diff lists every entry whose payload is binary" + ); - let ascending = get_json( + let (status, check) = post_json( &world, - "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&order=asc&limit=1", + "/xrpc/org.tangled.git.mergeCheck", + serde_json::json!({ "repo": did, "patch": patch, "branch": "main" }), ) .await; - assert_eq!(ascending["items"][0]["subject"], "did:plc:limpet"); -} - -#[tokio::test] -async fn a_members_page_orders_on_when_each_member_was_added() { - let world = World::new(); - world.add_member( - &AccountDid::new("did:plc:cowrie").unwrap(), - &owner(), - UnixSeconds::new(500), - ); - world.add_member( - &AccountDid::new("did:plc:limpet").unwrap(), - &owner(), - UnixSeconds::new(1_000), - ); - world.add_member( - &AccountDid::new("did:plc:whelk").unwrap(), - &owner(), - UnixSeconds::new(2_000), - ); + assert_eq!(status, StatusCode::OK, "mergeCheck failed: {check}"); + assert_eq!(check["isConflicted"], false); - let page = get_json( + let (status, merged) = post_as( &world, - "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&order=asc", + &nsid("org.tangled.review.mergePatch"), + &owner(), + &knot_types::ServiceDid::new(did.as_str()).unwrap(), + serde_json::json!({ "repo": did, "patch": patch }), ) .await; + assert_eq!(status, StatusCode::OK, "mergePatch failed: {merged}"); + assert_eq!(merged["tip"], feature_sha.to_hex()); + + let bare = world.layout.repo_path(&did).unwrap(); assert_eq!( - column(&page, "subject"), - ["did:plc:cowrie", "did:plc:limpet", "did:plc:whelk"], - "the listMembers page returns every grant list entry, ordered by when each member was added" + sh_git(&bare, &["rev-parse", "refs/heads/main^{tree}"]), + sh_git(&bare, &["rev-parse", &format!("{feature_sha}^{{tree}}")]), + "landing the patch rebuilds the feature tree byte for byte" ); - assert_eq!( - column(&page, "createdAt"), - [ - "1970-01-01T00:08:20Z", - "1970-01-01T00:16:40Z", - "1970-01-01T00:33:20Z" - ], - "createdAt is the add time, and a later change never moves a row that a reader is paging through" + let tree = sh_git(&bare, &["ls-tree", "refs/heads/main"]); + assert!( + tree.lines() + .any(|line| line.starts_with("100755") && line.ends_with("hull.bin")), + "the exec bit survived the round trip: {tree}" ); - assert_eq!(page["items"][0]["addedBy"], owner().as_str()); assert!( - page.get("cursor").is_none(), - "three rows fit inside the default limit, so the page doesn't end with a cursor" + !tree.contains("anchor.bin") && tree.contains("deep water.bin"), + "the patch's delete and add both reached the tree: {tree}" ); } #[tokio::test] -async fn a_banned_member_keeps_its_entry_on_the_members_grant_list() { - let world = World::new(); - world.add_member( - &AccountDid::new("did:plc:whelk").unwrap(), +async fn list_records_serves_the_grant_collections() { + let world = World::administered(); + let member = AccountDid::new("did:plc:limpet").unwrap(); + let knot_repo = RepoDid::new(world.state.knot_did.as_str()).unwrap(); + let (status, body) = post_authed( + &world, + &nsid("com.atproto.repo.createRecord"), &owner(), - UnixSeconds::new(1_000), - ); - world.ban(&AccountDid::new("did:plc:whelk").unwrap(), UnixSeconds::new(4_000)); - assert_eq!( - world - .state - .index - .effective_member(&AccountDid::new("did:plc:whelk").unwrap()), - Resolved::Ready(true), - "the blocklist runs its own grant list, and a ban doesn't remove whelk's entry on the members grant list" - ); -} + common::grant_body( + &knot_repo, + &common::member_collection(), + &member, + "1970-01-01T00:16:40Z", + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "the member offer failed: {body}"); -#[tokio::test] -async fn the_member_listings_reject_a_subject_that_names_another_knot() { - let world = World::new(); - let (status, error) = get_error( + let members = get_json( &world, - "/xrpc/sh.tangled.knot.listMembers?subject=did:web:oyster.cafe", + &format!( + "/xrpc/com.atproto.repo.listRecords?repo={}&collection=org.tangled.knot.member", + common::percent_encode(knot_repo.as_str()), + ), ) .await; - assert_eq!(status, StatusCode::BAD_REQUEST); + let records = members["records"].as_array().unwrap(); + assert_eq!(records.len(), 1); assert_eq!( - error, "InvalidSubject", - "subject must identify this knot, whatever grant list the method reads" + records[0]["uri"], + format!("at://{knot_repo}/org.tangled.knot.member/{member}"), + "the member record is keyed by the offered member's DID" ); -} + assert_eq!(records[0]["value"]["$type"], "org.tangled.knot.member"); + assert!(records[0]["value"]["createdAt"].as_str().is_some()); -#[tokio::test] -async fn list_members_rejects_malformed_params_and_clamps_the_limit() { - let world = World::new(); - world.add_member( - &AccountDid::new("did:plc:limpet").unwrap(), + let (repo, _bare, _work) = empty_repo(&world, &RepoName::new("squid").unwrap()); + let collaborator = AccountDid::new("did:plc:whelk").unwrap(); + let (status, body) = post_as( + &world, + &nsid("com.atproto.repo.createRecord"), &owner(), - UnixSeconds::new(1_000), + &knot_types::ServiceDid::new(repo.as_str()).unwrap(), + common::grant_body( + &repo, + &common::collaborator_collection(), + &collaborator, + "1970-01-01T00:33:20Z", + ), + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "the collaborator offer failed: {body}" ); - - let queries: &[&str] = &[ - "limit=abc&subject=did:web:knot.nel.pet", - "cursor=notanint&subject=did:web:knot.nel.pet", - "order=ascending&subject=did:web:knot.nel.pet", - "limit=2", - "subject=knot.nel.pet", - ]; - let w = &world; - stream::iter(queries.iter().copied()) - .for_each(|query| async move { - let (status, _) = - get_error(w, &format!("/xrpc/sh.tangled.knot.listMembers?{query}")).await; - assert_eq!(status, StatusCode::BAD_REQUEST, "query {query}"); - }) - .await; - - let clamped = get_json( + let collaborators = get_json( &world, - "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&limit=5000", + &format!( + "/xrpc/com.atproto.repo.listRecords?repo={}&collection=org.tangled.graph.collaborator", + common::percent_encode(repo.as_str()), + ), ) .await; - assert_eq!(clamped["items"].as_array().unwrap().len(), 1); + let records = collaborators["records"].as_array().unwrap(); + assert_eq!(records.len(), 1); + assert_eq!( + records[0]["uri"], + format!("at://{repo}/org.tangled.graph.collaborator/{collaborator}"), + "collaborator records are scoped to their repository" + ); } -#[tokio::test] -async fn list_collaborators_is_scoped_to_the_repo() { - let world = World::new(); - let squid = RepoDid::new("did:plc:squid").unwrap(); - let clam = RepoDid::new("did:plc:clam").unwrap(); - world.layout.create(&squid).unwrap(); - world.layout.create(&clam).unwrap(); - world.register( - &squid, - &RepoName::new("squid").unwrap(), - &RepoRkey::new("squid").unwrap(), - ); - world.register( - &clam, - &RepoName::new("clam").unwrap(), - &RepoRkey::new("clam").unwrap(), - ); - world.add_collaborator( - &squid, - &AccountDid::new("did:plc:lyna").unwrap(), - &owner(), - UnixSeconds::new(1_000), - ); - world.add_collaborator( - &clam, - &AccountDid::new("did:plc:bailey").unwrap(), +async fn offer_member(world: &World, member: &AccountDid) { + let knot_repo = RepoDid::new(world.state.knot_did.as_str()).unwrap(); + let (status, body) = post_authed( + world, + &nsid("com.atproto.repo.createRecord"), &owner(), - UnixSeconds::new(2_000), - ); - - let page = get_json( - &world, - "/xrpc/sh.tangled.repo.listCollaborators?subject=did:plc:squid", + common::grant_body( + &knot_repo, + &common::member_collection(), + member, + "2026-09-11T00:00:00Z", + ), ) .await; - let items = page["items"].as_array().unwrap(); - assert_eq!(items.len(), 1); - assert_eq!(items[0]["subject"], "did:plc:lyna"); - assert_eq!(items[0]["addedBy"], owner().as_str()); - assert_eq!(items[0]["createdAt"], "1970-01-01T00:16:40Z"); - assert!(items[0].get("uri").is_none() && items[0].get("cid").is_none()); + assert_eq!(status, StatusCode::OK, "the member offer failed: {body}"); +} - let unknown = get_json( - &world, - "/xrpc/sh.tangled.repo.listCollaborators?subject=did:plc:unhosted", +async fn offer_collaborator(world: &World, repo: &RepoDid, collaborator: &AccountDid) { + let (status, body) = post_as( + world, + &nsid("com.atproto.repo.createRecord"), + &owner(), + &knot_types::ServiceDid::new(repo.as_str()).unwrap(), + common::grant_body( + repo, + &common::collaborator_collection(), + collaborator, + "2026-09-11T00:00:00Z", + ), ) .await; - assert!( - unknown["items"].as_array().unwrap().is_empty(), - "unhosted repo has no collaborators, the answer is an empty list" + assert_eq!( + status, + StatusCode::OK, + "the collaborator offer failed: {body}" ); +} - let (status, _) = get_error( - &world, - "/xrpc/sh.tangled.repo.listCollaborators?subject=notadid", +async fn listing(world: &World, repo: &RepoDid, query: &str) -> serde_json::Value { + get_json( + world, + &format!( + "/xrpc/com.atproto.repo.listRecords?repo={}&{query}", + common::percent_encode(repo.as_str()), + ), ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST); + .await +} + +fn subjects(page: &serde_json::Value) -> Vec { + page["records"] + .as_array() + .unwrap() + .iter() + .map(|record| { + record["uri"] + .as_str() + .unwrap() + .rsplit('/') + .next() + .unwrap() + .to_string() + }) + .collect() } #[tokio::test] -async fn the_subject_tie_break_stays_ascending_in_both_directions() { - let world = World::new(); - world.add_member( - &AccountDid::new("did:plc:whelk").unwrap(), - &owner(), - UnixSeconds::new(1_000), +async fn the_member_listing_walks_its_cursor_in_rkey_order() { + let world = World::administered(); + let knot_repo = RepoDid::new(world.state.knot_did.as_str()).unwrap(); + let whelk = AccountDid::new("did:plc:whelk").unwrap(); + let limpet = AccountDid::new("did:plc:limpet").unwrap(); + let scallop = AccountDid::new("did:plc:scallop").unwrap(); + offer_member(&world, &whelk).await; + offer_member(&world, &limpet).await; + offer_member(&world, &scallop).await; + + let whole = listing(&world, &knot_repo, "collection=org.tangled.knot.member").await; + assert_eq!( + subjects(&whole), + [limpet.as_str(), scallop.as_str(), whelk.as_str()], + "records are keyed by the member's DID, so the page orders on that key, not on when each grant was added" ); - world.add_member( - &AccountDid::new("did:plc:limpet").unwrap(), - &owner(), - UnixSeconds::new(1_000), + assert!( + whole["cursor"].is_null(), + "a page holding every record ends without a cursor" ); - let asc = get_json( + let page = listing( &world, - "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&order=asc", + &knot_repo, + "collection=org.tangled.knot.member&limit=2", ) .await; - let desc = get_json( + assert_eq!(subjects(&page), [limpet.as_str(), scallop.as_str()]); + let cursor = page["cursor"].as_str().unwrap(); + let rest = listing( + &world, + &knot_repo, + &format!( + "collection=org.tangled.knot.member&limit=2&cursor={}", + common::percent_encode(cursor), + ), + ) + .await; + assert_eq!( + subjects(&rest), + [whelk.as_str()], + "the cursor resumes strictly past the last key it served" + ); + assert!(rest["cursor"].is_null(), "a drained listing has no cursor"); + + let flipped = listing( &world, - "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&order=desc", + &knot_repo, + "collection=org.tangled.knot.member&reverse=true&limit=2", ) .await; assert_eq!( - [column(&asc, "subject"), column(&desc, "subject")], - [["did:plc:limpet", "did:plc:whelk"]; 2], - "equal-createdAt entries keep an ascending subject tie-break regardless of sort direction" + subjects(&flipped), + [whelk.as_str(), scallop.as_str()], + "reverse turns the page around without breaking the order" ); } #[tokio::test] -async fn repo_error_outranks_paging_in_any_param_order() { - let world = World::new(); - let orders: &[&str] = &[ - "/xrpc/sh.tangled.repo.listCollaborators?subject=notadid&limit=abc", - "/xrpc/sh.tangled.repo.listCollaborators?limit=abc&subject=notadid", +async fn the_grant_listings_reject_malformed_params_and_clamp_the_limit() { + let world = World::administered(); + let knot_repo = RepoDid::new(world.state.knot_did.as_str()).unwrap(); + offer_member(&world, &AccountDid::new("did:plc:limpet").unwrap()).await; + let member_query = format!( + "/xrpc/com.atproto.repo.listRecords?repo={}&collection=org.tangled.knot.member", + common::percent_encode(knot_repo.as_str()), + ); + + let malformed = [ + format!("{member_query}&limit=abc"), + format!("{member_query}&cursor=not%40a%20rkey"), + format!("{member_query}&reverse=maybe"), + "/xrpc/com.atproto.repo.listRecords?repo=did:plc:not%20a%20did&collection=org.tangled.knot.member" + .to_string(), + format!( + "/xrpc/com.atproto.repo.listRecords?repo={}&collection=nope", + common::percent_encode(knot_repo.as_str()), + ), ]; let w = &world; - stream::iter(orders.iter().copied()) + stream::iter(malformed.iter()) .for_each(|query| async move { - let (_, error) = get_error(w, query).await; - assert_eq!( - error, "InvalidRepo", - "the repo extractor runs before paging by signature position for {query}" - ); + let (status, error) = get_error(w, query).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "query {query}"); + assert_eq!(error, "InvalidRequest", "query {query}"); }) .await; + + let clamped = get_json(&world, &format!("{member_query}&limit=5000")).await; + assert_eq!( + subjects(&clamped).len(), + 1, + "an oversize limit clamps to the max instead of erroring" + ); + let floor = get_json(&world, &format!("{member_query}&limit=0")).await; + assert_eq!( + subjects(&floor).len(), + 1, + "a zero limit clamps up to one record" + ); +} + +#[tokio::test] +async fn grant_listings_stay_scoped_to_their_own_account() { + let world = World::administered(); + let knot_repo = RepoDid::new(world.state.knot_did.as_str()).unwrap(); + let member = AccountDid::new("did:plc:limpet").unwrap(); + offer_member(&world, &member).await; + let (squid, _squid_bare, _squid_work) = empty_repo(&world, &RepoName::new("squid").unwrap()); + let (clam, _clam_bare, _clam_work) = empty_repo(&world, &RepoName::new("clam").unwrap()); + let lyna = AccountDid::new("did:plc:lyna").unwrap(); + let bailey = AccountDid::new("did:plc:bailey").unwrap(); + offer_collaborator(&world, &squid, &lyna).await; + offer_collaborator(&world, &clam, &bailey).await; + + let members = listing(&world, &knot_repo, "collection=org.tangled.knot.member").await; + assert_eq!(subjects(&members), [member.as_str()]); + assert_eq!( + members["records"][0]["uri"], + format!("at://{knot_repo}/org.tangled.knot.member/{member}"), + "member records serve under the knot's own did" + ); + assert!( + members["records"][0]["value"]["createdAt"] + .as_str() + .is_some(), + "the grant record keeps its createdAt" + ); + + assert_eq!( + subjects(&listing(&world, &squid, "collection=org.tangled.graph.collaborator").await), + [lyna.as_str()] + ); + assert_eq!( + subjects(&listing(&world, &clam, "collection=org.tangled.graph.collaborator").await), + [bailey.as_str()], + "collaborator records never cross repositories" + ); + assert_eq!( + subjects( + &listing( + &world, + &knot_repo, + "collection=org.tangled.graph.collaborator" + ) + .await + ), + Vec::::new(), + "the knot account holds no collaborator records" + ); + assert_eq!( + subjects(&listing(&world, &squid, "collection=org.tangled.knot.member").await), + Vec::::new(), + "a repository holds no member records" + ); +} + +#[tokio::test] +async fn a_banned_member_keeps_its_entry_on_the_members_grant_list() { + let world = World::new(); + world.add_member( + &AccountDid::new("did:plc:whelk").unwrap(), + &owner(), + UnixSeconds::new(1_000), + ); + world.ban( + &AccountDid::new("did:plc:whelk").unwrap(), + UnixSeconds::new(4_000), + ); + assert_eq!( + world + .state + .index + .effective_member(&AccountDid::new("did:plc:whelk").unwrap()), + Resolved::Ready(true), + "the blocklist runs its own grant list, and a ban doesn't remove whelk's entry on the members grant list" + ); } #[tokio::test] async fn service_metadata_endpoints_answer() { let world = World::new(); - let wire = get_json(&world, "/xrpc/org.tangled.knot.describeKnot").await; - assert_eq!(wire["version"], "v1.15.0"); - assert_eq!(wire["did"], world.state.knot_did.as_str()); + let described = get_json(&world, "/xrpc/org.tangled.knot.describeKnot").await; + assert_eq!(described["version"], "v1.15.0"); + assert_eq!(described["did"], world.state.knot_did.as_str()); let served = [ Capability::Tangled1, Capability::KnotAcl, @@ -2084,10 +1923,7 @@ async fn service_metadata_endpoints_answer() { Capability::SocialWrites, ] .map(Capability::token); - assert_eq!(wire["capabilities"], serde_json::json!(served)); - - let owner = get_json(&world, "/xrpc/sh.tangled.owner").await; - assert_eq!(owner["owner"], common::owner().as_str()); + assert_eq!(described["capabilities"], serde_json::json!(served)); } async fn serve_router(world: &World) -> std::net::SocketAddr { @@ -2110,7 +1946,7 @@ fn refused(result: Result) { Err(tungstenite::Error::Http(response)) => { assert_eq!(response.status().as_u16(), 503); } - Err(other) => panic!("expected an http refusal: {other}"), + Err(other) => panic!("expected an http rejection: {other}"), Ok(_) => panic!("a subscriber past the limit connected"), } } @@ -2143,7 +1979,7 @@ async fn a_subscriber_beyond_the_firehose_limit_is_refused() { async fn one_peer_opens_four_subscriptions_and_fifth_is_refused() { let world = World::new(); let addr = serve_router(&world).await; - let held: Vec<_> = stream::iter(0..4) + let subscriptions: Vec<_> = stream::iter(0..4) .then(|_| async { tokio_tungstenite::connect_async(format!( "ws://{addr}/xrpc/com.atproto.sync.subscribeRepos" @@ -2160,37 +1996,97 @@ async fn one_peer_opens_four_subscriptions_and_fifth_is_refused() { )) .await, ); - drop(held); + drop(subscriptions); +} + +async fn manifest_of(world: &World, did: &RepoDid) -> (serde_json::Value, RecordCid) { + let record = get_json( + world, + &format!( + "/xrpc/com.atproto.repo.getRecord?repo={}&collection=org.tangled.repo.manifest&rkey=self", + common::percent_encode(did.as_str()), + ), + ) + .await; + ( + record["value"].clone(), + common::record_cid_of(&record["cid"]), + ) +} + +async fn put_manifest( + world: &World, + did: &RepoDid, + record: serde_json::Value, + from: &RecordCid, +) -> (StatusCode, serde_json::Value) { + post_as( + world, + &nsid("com.atproto.repo.putRecord"), + &owner(), + &knot_types::ServiceDid::new(did.as_str()).unwrap(), + serde_json::json!({ + "repo": did, + "collection": "org.tangled.repo.manifest", + "rkey": "self", + "swapRecord": from.to_string(), + "record": record, + }), + ) + .await } #[tokio::test] -async fn set_default_branch_resolves_a_repo_did_and_an_existing_branch() { +async fn the_manifest_moves_the_default_branch_and_rejects_a_missing_one() { let world = World::new(); - let (did, work) = seeded(&world, &RepoName::new("coral").unwrap()); + world.add_member(&owner(), &owner(), UnixSeconds::new(1_000)); + let (status, created) = post_authed( + &world, + &nsid("org.tangled.repo.createRepo"), + &owner(), + serde_json::json!({ "name": "coral", "defaultBranch": "main" }), + ) + .await; + assert_eq!(status, StatusCode::OK, "create failed: {created}"); + let did = RepoDid::new(created["did"].as_str().unwrap()).unwrap(); + + let work = tempfile::tempdir().unwrap(); + sh_git(work.path(), &["init", "-q", "-b", "main"]); + commit_file( + work.path(), + "README.md", + b"# coral\n", + "first", + "2026-06-01T12:30:00+02:00", + ); let bare = world.layout.repo_path(&did).unwrap(); + sh_git(work.path(), &["push", "-q", bare.to_str().unwrap(), "main"]); sh_git(work.path(), &["branch", "release", "main"]); sh_git( work.path(), &["push", "-q", bare.to_str().unwrap(), "refs/heads/release"], ); - let (status, _) = post_authed( - &world, - &nsid("sh.tangled.repo.setDefaultBranch"), - &owner(), - serde_json::json!({ - "repo": did, - "defaultBranch": "release", - }), - ) - .await; - assert_eq!(status, StatusCode::OK); + let (mut record, cid) = manifest_of(&world, &did).await; + assert_eq!(record["defaultBranch"], "main"); + record["defaultBranch"] = serde_json::json!("release"); + let (status, body) = put_manifest(&world, &did, record.clone(), &cid).await; + assert_eq!(status, StatusCode::OK, "the manifest write failed: {body}"); let repo = world.layout.open(&did).unwrap(); assert_eq!( repo.default_branch().unwrap().as_str(), "refs/heads/release", - "the default head moved to the requested branch" + "the default head moved to the branch the manifest declared" + ); + + let (mut record, cid) = manifest_of(&world, &did).await; + record["defaultBranch"] = serde_json::json!("ghost"); + let (status, body) = put_manifest(&world, &did, record, &cid).await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "a defaultBranch pointing at a branch the repo lacks: {body}" ); } @@ -2207,7 +2103,7 @@ async fn delete_branch_removes_a_non_default_branch_then_reports_it_gone() { let (status, _) = post_authed( &world, - &nsid("sh.tangled.repo.deleteBranch"), + &nsid("org.tangled.git.deleteBranch"), &owner(), serde_json::json!({ "repo": did, "branch": "feature" }), ) @@ -2216,7 +2112,7 @@ async fn delete_branch_removes_a_non_default_branch_then_reports_it_gone() { let (status, body) = post_authed( &world, - &nsid("sh.tangled.repo.deleteBranch"), + &nsid("org.tangled.git.deleteBranch"), &owner(), serde_json::json!({ "repo": did, "branch": "feature" }), ) @@ -2232,24 +2128,16 @@ async fn bad_post_bodies_are_invalid_request() { let cases: &[(Nsid, serde_json::Value)] = &[ ( - nsid("sh.tangled.repo.setDefaultBranch"), - serde_json::json!({ "repo": "not-a-repo-did", "defaultBranch": "main" }), - ), - ( - nsid("sh.tangled.repo.deleteBranch"), + nsid("org.tangled.git.deleteBranch"), serde_json::json!({ "repo": "did:plc:kelpfixture", "branch": "bad branch", }), ), ( - nsid("sh.tangled.repo.forkSync"), + nsid("org.tangled.repo.forkSync"), serde_json::json!({ "repo": "did:plc:barnaclefixture", "branch": "bad branch" }), ), - ( - nsid("sh.tangled.repo.hiddenRef"), - serde_json::json!({ "repo": "nope", "forkRef": "feature", "remoteRef": "main" }), - ), ]; let w = &world; stream::iter(cases) @@ -2259,56 +2147,6 @@ async fn bad_post_bodies_are_invalid_request() { .await; } -#[tokio::test] -#[ignore = "sh.tangled.repo.merge is unrouted since web calls gitmirror for git.mergeCommit"] -async fn merge_applies_a_patch_under_the_supplied_author() { - let world = World::new(); - let (_did, main_sha, feature_sha) = - seeded_feature_branch(&world, &RepoName::new("mussel").unwrap()); - let registered = RepoDid::new("did:plc:musselfixture").unwrap(); - - let compared = get_json( - &world, - &format!( - "/xrpc/sh.tangled.repo.compare?repo={registered}&rev1={main_sha}&rev2={feature_sha}" - ), - ) - .await; - let patch = compared["combined_patch_raw"].as_str().unwrap().to_string(); - - let (status, body) = post_authed( - &world, - &nsid("sh.tangled.repo.merge"), - &owner(), - serde_json::json!({ - "repo": registered, - "branch": "main", - "patch": patch, - "authorName": "Teq", - "authorEmail": "teq@nel.pet", - "commitMessage": "merged kelp", - }), - ) - .await; - assert_eq!(status, StatusCode::OK, "merge failed: {body}"); - - let log = get_json( - &world, - &format!("/xrpc/sh.tangled.repo.log?repo={registered}&ref=main"), - ) - .await; - let top = &log["commits"][0]; - assert_eq!( - top["author"]["Name"], "Teq", - "the supplied author rode through" - ); - assert!( - top["message"].as_str().unwrap().contains("merged kelp"), - "the supplied commit message rode through: {}", - top["message"] - ); -} - #[tokio::test] async fn create_mints_a_did_plc_repo_with_the_requested_default_branch() { let world = World::new(); -- 2.54.0