From 89472beb88a5504072e52b2e3701dc2ca5148b14 Mon Sep 17 00:00:00 2001 From: Lewis Date: Sat, 16 May 2026 13:12:33 +0300 Subject: [PATCH] feat(xrpc): lookup by repo did Lewis: May this revision serve well! --- crates/resolver/src/lib.rs | 97 +++++++++++++++++++---- crates/xrpc/src/lib.rs | 76 +++++++++++++++--- crates/xrpc/tests/cold_start.rs | 123 ++++++++++++++++++++++++----- crates/xrpc/tests/knot_proxy.rs | 133 +++++++++++++++++++++++++++++--- 4 files changed, 374 insertions(+), 55 deletions(-) diff --git a/crates/resolver/src/lib.rs b/crates/resolver/src/lib.rs index 8b10a9c..2f77003 100644 --- a/crates/resolver/src/lib.rs +++ b/crates/resolver/src/lib.rs @@ -218,6 +218,13 @@ impl RepoIdResolver { Some(entry.get().clone().into_resolution()) } + pub async fn lookup_by_repo_did(&self, repo_did: &Did) -> Option { + self.by_repo_did + .get_async(repo_did) + .await + .map(|e| e.get().clone()) + } + pub async fn observe( &self, owner: Did, @@ -442,7 +449,7 @@ mod tests { .observe( did("did:plc:nel"), rkey("3liuighjy2h22"), - Some(did("did:plc:abalone")), + Some(did("did:plc:clam")), ) .await; assert!(prior.is_none(), "first observation has no prior"); @@ -451,7 +458,7 @@ mod tests { .observe( did("did:plc:nel"), rkey("core"), - Some(did("did:plc:abalone")), + Some(did("did:plc:clam")), ) .await; assert_eq!( @@ -464,7 +471,7 @@ mod tests { .observe( did("did:plc:nel"), rkey("core"), - Some(did("did:plc:abalone")), + Some(did("did:plc:clam")), ) .await; assert!(prior.is_none(), "re-observing the same ident is a no-op"); @@ -486,14 +493,14 @@ mod tests { .observe( did("did:plc:nel"), rkey("3liuighjy2h22"), - Some(did("did:plc:abalone")), + Some(did("did:plc:clam")), ) .await; resolver .observe( did("did:plc:nel"), rkey("core"), - Some(did("did:plc:abalone")), + Some(did("did:plc:clam")), ) .await; @@ -505,13 +512,13 @@ mod tests { .observe( did("did:plc:nel"), rkey("core-renamed"), - Some(did("did:plc:abalone")), + Some(did("did:plc:clam")), ) .await; assert_eq!( prior, Some(RepoIdent::new(did("did:plc:nel"), rkey("core"))), - "stale at-uri's forget must not displace the live owner of did:plc:abalone", + "stale at-uri's forget must not displace the live owner of did:plc:clam", ); } @@ -522,13 +529,13 @@ mod tests { .observe( did("did:plc:nel"), rkey("abcabcabcabcz"), - Some(did("did:plc:abalone")), + Some(did("did:plc:clam")), ) .await; let got = resolver .resolve(&did("did:plc:nel"), &rkey("abcabcabcabcz")) .await; - assert_eq!(got, Resolution::Mapped(did("did:plc:abalone"))); + assert_eq!(got, Resolution::Mapped(did("did:plc:clam"))); } #[tokio::test] @@ -556,6 +563,64 @@ mod tests { assert_eq!(got, Resolution::Unresolvable); } + #[tokio::test] + async fn lookup_by_repo_did_finds_observed_ident() { + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); + resolver + .observe( + did("did:plc:nel"), + rkey("abcabcabcabcz"), + Some(did("did:plc:limpet")), + ) + .await; + let got = resolver.lookup_by_repo_did(&did("did:plc:limpet")).await; + assert_eq!( + got, + Some(RepoIdent::new(did("did:plc:nel"), rkey("abcabcabcabcz"))), + ); + } + + #[tokio::test] + async fn lookup_by_repo_did_misses_when_unobserved() { + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); + let got = resolver.lookup_by_repo_did(&did("did:plc:limpet")).await; + assert_eq!(got, None); + } + + #[tokio::test] + async fn lookup_by_repo_did_misses_when_repo_did_was_none() { + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); + resolver + .observe(did("did:plc:nel"), rkey("abcabcabcabcz"), None) + .await; + let got = resolver.lookup_by_repo_did(&did("did:plc:limpet")).await; + assert_eq!(got, None); + } + + #[tokio::test] + async fn lookup_by_repo_did_follows_move_to_new_ident() { + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); + resolver + .observe( + did("did:plc:nel"), + rkey("abcabcabcabcz"), + Some(did("did:plc:limpet")), + ) + .await; + resolver + .observe( + did("did:plc:olaren"), + rkey("xyzxyzxyzxyzx"), + Some(did("did:plc:limpet")), + ) + .await; + let got = resolver.lookup_by_repo_did(&did("did:plc:limpet")).await; + assert_eq!( + got, + Some(RepoIdent::new(did("did:plc:olaren"), rkey("xyzxyzxyzxyzx"))), + ); + } + #[tokio::test] async fn observation_overwrites_prior_value() { let resolver = RepoIdResolver::detached(RuntimeHasher::default()); @@ -563,7 +628,7 @@ mod tests { .observe( did("did:plc:nel"), rkey("abcabcabcabcz"), - Some(did("did:plc:abalone")), + Some(did("did:plc:clam")), ) .await; resolver @@ -585,7 +650,7 @@ mod tests { let owner = did("did:plc:nel"); let key = rkey("abcabcabcabcz"); resolver - .observe(owner.clone(), key.clone(), Some(did("did:plc:abalone"))) + .observe(owner.clone(), key.clone(), Some(did("did:plc:clam"))) .await; resolver .fill_provisional( @@ -596,7 +661,7 @@ mod tests { let got = resolver.resolve(&owner, &key).await; assert_eq!( got, - Resolution::Mapped(did("did:plc:abalone")), + Resolution::Mapped(did("did:plc:clam")), "firehose-observed mapping must outrank provisional slingshot info", ); } @@ -610,7 +675,7 @@ mod tests { resolver .fill_provisional( RepoIdent::new(owner.clone(), key.clone()), - Resolution::Mapped(did("did:plc:abalone")), + Resolution::Mapped(did("did:plc:clam")), ) .await; let got = resolver.resolve(&owner, &key).await; @@ -907,7 +972,7 @@ mod tests { resolver .fill_provisional( RepoIdent::new(owner.clone(), key.clone()), - Resolution::Mapped(did("did:plc:abalone")), + Resolution::Mapped(did("did:plc:clam")), ) .await; resolver.observe(owner.clone(), key.clone(), None).await; @@ -925,11 +990,11 @@ mod tests { let owner = did("did:plc:nel"); let key = rkey("abcabcabcabcz"); resolver - .observe(owner.clone(), key.clone(), Some(did("did:plc:abalone"))) + .observe(owner.clone(), key.clone(), Some(did("did:plc:clam"))) .await; assert_eq!( resolver.cached_resolution(&owner, &key).await, - Some(Resolution::Mapped(did("did:plc:abalone"))), + Some(Resolution::Mapped(did("did:plc:clam"))), ); resolver.forget(&owner, &key).await; assert_eq!( diff --git a/crates/xrpc/src/lib.rs b/crates/xrpc/src/lib.rs index e5089ca..5344a34 100644 --- a/crates/xrpc/src/lib.rs +++ b/crates/xrpc/src/lib.rs @@ -130,6 +130,10 @@ pub fn router(state: AppState) -> Router { Router::new() .route("/xrpc/sh.tangled.repo.getRepo", get(get_repo)) .route("/xrpc/sh.tangled.repo.getRepos", get(get_repos)) + .route( + "/xrpc/sh.tangled.repo.getRepoByRepoDid", + get(get_repo_by_repo_did), + ) .route("/xrpc/sh.tangled.actor.getProfile", get(get_profile)) .route("/xrpc/sh.tangled.actor.getProfiles", get(get_profiles)) .route("/xrpc/sh.tangled.repo.getIssue", get(get_issue)) @@ -506,6 +510,12 @@ struct GetRepoQuery { repo: RawAtUriParam, } +#[derive(Debug, Deserialize)] +struct GetRepoByRepoDidQuery { + #[serde(rename = "repoDid")] + repo_did: String, +} + #[derive(Debug, Deserialize)] struct GetProfileQuery { actor: RawAtUriParam, @@ -1061,24 +1071,35 @@ where } } -async fn fetch( +async fn fetch_from_uri( state: &AppState, - uri: &RawAtUriParam, + uri: AtUri, ) -> Result<(Arc, V), XrpcError> where R: XrpcResp, V: serde::de::DeserializeOwned + NormalizeRepoRefs, { - let parsed = parse_uri(uri.as_str())?; - let (body, _did) = resolve(state, ExpectedNsid::new(R::NSID), parsed).await?; + let raw = uri.as_str().to_owned(); + let (body, _did) = resolve(state, ExpectedNsid::new(R::NSID), uri).await?; let value: V = deserialize_or_upgrade(state, R::NSID, &body.value).await?; let value = value .normalize(&state.resolver) .await - .ok_or_else(|| XrpcError::UpstreamGone(uri.as_str().to_owned()))?; + .ok_or(XrpcError::UpstreamGone(raw))?; Ok((body, value)) } +async fn fetch( + state: &AppState, + uri: &RawAtUriParam, +) -> Result<(Arc, V), XrpcError> +where + R: XrpcResp, + V: serde::de::DeserializeOwned + NormalizeRepoRefs, +{ + fetch_from_uri::(state, parse_uri(uri.as_str())?).await +} + async fn get_repo( State(state): State, XrpcQuery(q): XrpcQuery, @@ -1091,6 +1112,31 @@ async fn get_repo( })) } +async fn get_repo_by_repo_did( + State(state): State, + XrpcQuery(q): XrpcQuery, +) -> Result>, XrpcError> { + let repo_did = Did::::new_owned(&q.repo_did) + .map_err(|e| XrpcError::InvalidParams(format!("repoDid: {e}")))?; + let ident = state + .resolver + .lookup_by_repo_did(&repo_did) + .await + .ok_or(XrpcError::NotFound)?; + let uri = AtUri::::from_parts_owned( + ident.owner.as_str(), + RepoRecord::NSID, + ident.rkey.as_str(), + ) + .expect("Did and Rkey newtypes already validated, at-uri assembly cannot fail"); + let (body, value) = fetch_from_uri::>(&state, uri).await?; + Ok(Json(RepoGetRecordOutput { + cid: Some(body.cid.clone()), + uri: body.uri.clone(), + value, + })) +} + async fn get_profile( State(state): State, XrpcQuery(q): XrpcQuery, @@ -2120,20 +2166,30 @@ async fn resolve_knot_target( repo_uri_raw: &str, ) -> Result<(KnotHost, RepoSlug), XrpcError> { let repo_uri = parse_uri(repo_uri_raw)?; + let rkey: Option = repo_uri.rkey().map(|r| r.as_str().to_owned()); let (body, did) = resolve(state, ExpectedNsid::new(RepoRecord::NSID), repo_uri).await?; let value: Repo = serde_json::from_slice(&body.value) .map_err(|e| XrpcError::InvalidRecord(format!("decode repo record: {e}")))?; let host = KnotHost::parse(value.knot.as_ref()) .map_err(|e| XrpcError::InvalidRecord(format!("knot field: {e}")))?; - let name = value - .name - .as_deref() - .ok_or_else(|| XrpcError::InvalidRecord("repo record missing name".to_string()))?; - let slug = RepoSlug::new(did.as_ref(), name) + let name = pick_human_slug(rkey.as_deref(), value.name.as_deref()).ok_or_else(|| { + XrpcError::InvalidRecord("at-uri missing rkey and record missing name".to_string()) + })?; + let slug = RepoSlug::new(did.as_ref(), &name) .map_err(|e| XrpcError::InvalidRecord(format!("repo slug: {e}")))?; Ok((host, slug)) } +fn pick_human_slug(rkey: Option<&str>, name: Option<&str>) -> Option { + match rkey { + Some(r) if jacquard_common::types::tid::Tid::new(r).is_ok() => { + Some(name.unwrap_or(r).to_owned()) + } + Some(r) => Some(r.to_owned()), + None => name.map(str::to_owned), + } +} + fn filter_request_headers(client: &HeaderMap) -> HeaderMap { FORWARDED_REQUEST_HEADERS .iter() diff --git a/crates/xrpc/tests/cold_start.rs b/crates/xrpc/tests/cold_start.rs index 1b62af5..2d18cc7 100644 --- a/crates/xrpc/tests/cold_start.rs +++ b/crates/xrpc/tests/cold_start.rs @@ -9,6 +9,9 @@ use bobbin_runtime::{RuntimeHasher, SystemClock}; use bobbin_search::{DEFAULT_WRITER_HEAP_BYTES, SearchIndex, SearchReader}; use bobbin_slingshot_client::SlingshotClient; use bobbin_xrpc::{AppState, router}; +use jacquard_common::DefaultStr; +use jacquard_common::types::did::Did; +use jacquard_common::types::recordkey::Rkey; use futures::stream::{self, StreamExt}; use http::{Request, StatusCode}; use serde_json::{Value, json}; @@ -72,7 +75,7 @@ async fn json_response(resp: axum::response::Response) -> (StatusCode, Value) { #[tokio::test] async fn cold_start_serves_all_four_point_lookups() { let server = MockServer::start().await; - let did = "did:plc:abalone"; + let did = "did:plc:clam"; mount_record( &server, @@ -81,7 +84,7 @@ async fn cold_start_serves_all_four_point_lookups() { "r1", json!({ "$type": "sh.tangled.repo", - "name": "abalone", + "name": "clam", "knot": "oyster.cafe", "createdAt": "2026-05-01T00:00:00Z" }), @@ -96,7 +99,7 @@ async fn cold_start_serves_all_four_point_lookups() { json!({ "$type": "sh.tangled.actor.profile", "bluesky": false, - "description": "abalone shell" + "description": "clam shell" }), ) .await; @@ -146,7 +149,7 @@ async fn cold_start_serves_all_four_point_lookups() { "actor", format!("at://{did}/sh.tangled.actor.profile/self"), "description", - json!("abalone shell"), + json!("clam shell"), ), ( "sh.tangled.repo.getIssue", @@ -198,7 +201,7 @@ async fn second_call_is_served_from_lru() { "value": { "$type": "sh.tangled.repo", "name": "uni", - "knot": "nel.pet", + "knot": "witchcraft.systems", "createdAt": "2026-05-01T00:00:00Z" } }))) @@ -236,7 +239,7 @@ async fn collection_mismatch_is_400() { .oneshot(xrpc_request( "sh.tangled.repo.getRepo", "repo", - "at://did:plc:abalone/sh.tangled.actor.profile/self", + "at://did:plc:clam/sh.tangled.actor.profile/self", )) .await .unwrap(); @@ -252,7 +255,7 @@ async fn handle_authority_is_400() { .oneshot(xrpc_request( "sh.tangled.repo.getRepo", "repo", - "at://nel.pet/sh.tangled.repo/r1", + "at://witchcraft.systems/sh.tangled.repo/r1", )) .await .unwrap(); @@ -273,7 +276,7 @@ async fn slingshot_404_propagates_as_404() { .oneshot(xrpc_request( "sh.tangled.repo.getRepo", "repo", - "at://did:plc:abalone/sh.tangled.repo/missing", + "at://did:plc:clam/sh.tangled.repo/missing", )) .await .unwrap(); @@ -285,7 +288,7 @@ async fn wrong_record_type_is_502() { let server = MockServer::start().await; mount_record( &server, - "did:plc:abalone", + "did:plc:clam", "sh.tangled.repo", "r1", json!({ @@ -301,7 +304,7 @@ async fn wrong_record_type_is_502() { .oneshot(xrpc_request( "sh.tangled.repo.getRepo", "repo", - "at://did:plc:abalone/sh.tangled.repo/r1", + "at://did:plc:clam/sh.tangled.repo/r1", )) .await .unwrap(); @@ -315,9 +318,9 @@ async fn wrong_type_does_not_poison_cache() { let server = MockServer::start().await; let mock = Mock::given(method("GET")) .and(path("/xrpc/com.atproto.repo.getRecord")) - .and(query_param("repo", "did:plc:abalone")) + .and(query_param("repo", "did:plc:clam")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "uri": "at://did:plc:abalone/sh.tangled.repo/r1", + "uri": "at://did:plc:clam/sh.tangled.repo/r1", "cid": CID, "value": { "$type": "sh.tangled.knot", @@ -334,7 +337,7 @@ async fn wrong_type_does_not_poison_cache() { xrpc_request( "sh.tangled.repo.getRepo", "repo", - "at://did:plc:abalone/sh.tangled.repo/r1", + "at://did:plc:clam/sh.tangled.repo/r1", ) }; let first = app.clone().oneshot(req()).await.unwrap(); @@ -383,7 +386,7 @@ async fn upstream_uri_mismatch_routes_to_invalid_record() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/xrpc/com.atproto.repo.getRecord")) - .and(query_param("repo", "did:plc:abalone")) + .and(query_param("repo", "did:plc:clam")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "uri": "at://did:plc:limpet/sh.tangled.repo/elsewhere", "cid": CID, @@ -401,7 +404,7 @@ async fn upstream_uri_mismatch_routes_to_invalid_record() { .oneshot(xrpc_request( "sh.tangled.repo.getRepo", "repo", - "at://did:plc:abalone/sh.tangled.repo/r1", + "at://did:plc:clam/sh.tangled.repo/r1", )) .await .unwrap(); @@ -416,7 +419,7 @@ async fn upstream_garbage_cid_routes_to_invalid_record() { Mock::given(method("GET")) .and(path("/xrpc/com.atproto.repo.getRecord")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "uri": "at://did:plc:abalone/sh.tangled.repo/r1", + "uri": "at://did:plc:clam/sh.tangled.repo/r1", "cid": "not-a-real-cid", "value": { "$type": "sh.tangled.repo", @@ -432,7 +435,7 @@ async fn upstream_garbage_cid_routes_to_invalid_record() { .oneshot(xrpc_request( "sh.tangled.repo.getRepo", "repo", - "at://did:plc:abalone/sh.tangled.repo/r1", + "at://did:plc:clam/sh.tangled.repo/r1", )) .await .unwrap(); @@ -460,7 +463,7 @@ async fn oversize_upstream_body_routes_to_upstream_failed() { .oneshot(xrpc_request( "sh.tangled.repo.getRepo", "repo", - "at://did:plc:abalone/sh.tangled.repo/r1", + "at://did:plc:clam/sh.tangled.repo/r1", )) .await .unwrap(); @@ -483,7 +486,7 @@ async fn upstream_503_routes_to_upstream_failed() { .oneshot(xrpc_request( "sh.tangled.repo.getRepo", "repo", - "at://did:plc:abalone/sh.tangled.repo/r1", + "at://did:plc:clam/sh.tangled.repo/r1", )) .await .unwrap(); @@ -491,3 +494,85 @@ async fn upstream_503_routes_to_upstream_failed() { assert_eq!(status, StatusCode::BAD_GATEWAY); assert_eq!(body["error"], "UpstreamFailed"); } + +#[tokio::test] +async fn get_repo_by_repo_did_returns_observed_record() { + let server = MockServer::start().await; + let owner_did = "did:plc:scallop"; + let rkey = "r1"; + let repo_did = "did:plc:limpet"; + mount_record( + &server, + owner_did, + "sh.tangled.repo", + rkey, + json!({ + "$type": "sh.tangled.repo", + "name": "scallop", + "knot": "oyster.cafe", + "createdAt": "2026-05-01T00:00:00Z", + "repoDid": repo_did, + }), + ) + .await; + + let state = fresh_app(&server.uri()).await; + state + .resolver + .observe( + Did::::new_owned(owner_did).unwrap(), + Rkey::::new_owned(rkey).unwrap(), + Some(Did::::new_owned(repo_did).unwrap()), + ) + .await; + + let app = router(state); + let resp = app + .oneshot(xrpc_request( + "sh.tangled.repo.getRepoByRepoDid", + "repoDid", + repo_did, + )) + .await + .unwrap(); + let (status, body) = json_response(resp).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body["uri"], + format!("at://{owner_did}/sh.tangled.repo/{rkey}") + ); + assert_eq!(body["value"]["name"], "scallop"); + assert_eq!(body["value"]["repoDid"], repo_did); +} + +#[tokio::test] +async fn get_repo_by_repo_did_404_when_unobserved() { + let server = MockServer::start().await; + let state = fresh_app(&server.uri()).await; + let app = router(state); + let resp = app + .oneshot(xrpc_request( + "sh.tangled.repo.getRepoByRepoDid", + "repoDid", + "did:plc:whelk", + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn get_repo_by_repo_did_400_on_invalid_did() { + let server = MockServer::start().await; + let state = fresh_app(&server.uri()).await; + let app = router(state); + let resp = app + .oneshot(xrpc_request( + "sh.tangled.repo.getRepoByRepoDid", + "repoDid", + "not-a-did", + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} diff --git a/crates/xrpc/tests/knot_proxy.rs b/crates/xrpc/tests/knot_proxy.rs index 47b4a04..2cd4725 100644 --- a/crates/xrpc/tests/knot_proxy.rs +++ b/crates/xrpc/tests/knot_proxy.rs @@ -78,13 +78,23 @@ impl Harness { } async fn mount_repo_record(&self, did: &str, rkey: &str, name: &str) { + self.mount_repo_record_inner(did, rkey, Some(name)).await; + } + + async fn mount_repo_record_rkey_as_name(&self, did: &str, rkey: &str) { + self.mount_repo_record_inner(did, rkey, None).await; + } + + async fn mount_repo_record_inner(&self, did: &str, rkey: &str, name: Option<&str>) { let knot_value = self.knot.uri(); - let record = json!({ + let mut record = json!({ "$type": "sh.tangled.repo", "createdAt": "2026-05-01T00:00:00Z", "knot": knot_value, - "name": name, }); + if let Some(n) = name { + record["name"] = json!(n); + } let uri = format!("at://{did}/sh.tangled.repo/{rkey}"); Mock::given(method("GET")) .and(path("/xrpc/com.atproto.repo.getRecord")) @@ -138,7 +148,8 @@ async fn body_value(resp: http::Response) -> Value { #[tokio::test] async fn proxies_repo_blob_with_did_slash_name_repo_param() { let h = Harness::new().await; - h.mount_repo_record("did:plc:abalone", "r1", "barnacle") + let tid = "3jzfcijpj2z2a"; + h.mount_repo_record("did:plc:abalone", tid, "barnacle") .await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.blob")) @@ -154,7 +165,7 @@ async fn proxies_repo_blob_with_did_slash_name_repo_param() { let target = format!( "/xrpc/sh.tangled.repo.blob?repo={}&ref=main&path=README.md", - enc("at://did:plc:abalone/sh.tangled.repo/r1"), + enc(&format!("at://did:plc:abalone/sh.tangled.repo/{tid}")), ); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::OK); @@ -167,10 +178,110 @@ async fn proxies_repo_blob_with_did_slash_name_repo_param() { assert_eq!(v["content"], "hi"); } +#[tokio::test] +async fn modern_rkey_as_name_uses_rkey_even_when_name_field_set() { + let h = Harness::new().await; + h.mount_repo_record("did:plc:abalone", "core", "Tangled Core") + .await; + Mock::given(method("GET")) + .and(path("/xrpc/sh.tangled.repo.getDefaultBranch")) + .and(query_param("repo", "did:plc:abalone/core")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "hash": "abc", + "name": "main", + "when": "2026-05-01T00:00:00Z", + }))) + .mount(&h.knot) + .await; + + let target = format!( + "/xrpc/sh.tangled.repo.getDefaultBranch?repo={}", + enc("at://did:plc:abalone/sh.tangled.repo/core"), + ); + let resp = h.call(&target).await; + assert_eq!(resp.status(), StatusCode::OK); + let v = body_value(resp).await; + assert_eq!(v["name"], "main"); +} + +#[tokio::test] +async fn modern_rkey_as_name_works_when_name_field_null() { + let h = Harness::new().await; + h.mount_repo_record_rkey_as_name("did:plc:abalone", "core") + .await; + Mock::given(method("GET")) + .and(path("/xrpc/sh.tangled.repo.getDefaultBranch")) + .and(query_param("repo", "did:plc:abalone/core")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "hash": "abc", + "name": "main", + "when": "2026-05-01T00:00:00Z", + }))) + .mount(&h.knot) + .await; + + let target = format!( + "/xrpc/sh.tangled.repo.getDefaultBranch?repo={}", + enc("at://did:plc:abalone/sh.tangled.repo/core"), + ); + let resp = h.call(&target).await; + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn legacy_tid_rkey_falls_back_to_name_field() { + let h = Harness::new().await; + let tid_rkey = "3jzfcijpj2z2a"; + h.mount_repo_record("did:plc:abalone", tid_rkey, "dotfiles") + .await; + Mock::given(method("GET")) + .and(path("/xrpc/sh.tangled.repo.getDefaultBranch")) + .and(query_param("repo", "did:plc:abalone/dotfiles")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "hash": "abc", + "name": "main", + "when": "2026-05-01T00:00:00Z", + }))) + .mount(&h.knot) + .await; + + let target = format!( + "/xrpc/sh.tangled.repo.getDefaultBranch?repo={}", + enc(&format!("at://did:plc:abalone/sh.tangled.repo/{tid_rkey}")), + ); + let resp = h.call(&target).await; + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn tid_rkey_without_name_falls_back_to_tid() { + let h = Harness::new().await; + let tid_rkey = "3jzfcijpj2z2a"; + h.mount_repo_record_rkey_as_name("did:plc:abalone", tid_rkey) + .await; + Mock::given(method("GET")) + .and(path("/xrpc/sh.tangled.repo.getDefaultBranch")) + .and(query_param("repo", format!("did:plc:abalone/{tid_rkey}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "hash": "abc", + "name": "main", + "when": "2026-05-01T00:00:00Z", + }))) + .mount(&h.knot) + .await; + let target = format!( + "/xrpc/sh.tangled.repo.getDefaultBranch?repo={}", + enc(&format!("at://did:plc:abalone/sh.tangled.repo/{tid_rkey}")), + ); + let resp = h.call(&target).await; + assert_eq!(resp.status(), StatusCode::OK); +} + #[tokio::test] async fn streams_binary_archive_through_proxy() { let h = Harness::new().await; - h.mount_repo_record("did:plc:limpet", "r2", "kelp").await; + let tid = "3jzfcijpj2z2b"; + h.mount_repo_record("did:plc:limpet", tid, "kelp").await; let payload: Vec = (0u8..=255).collect(); Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.archive")) @@ -186,7 +297,7 @@ async fn streams_binary_archive_through_proxy() { let target = format!( "/xrpc/sh.tangled.repo.archive?repo={}&ref=v1", - enc("at://did:plc:limpet/sh.tangled.repo/r2"), + enc(&format!("at://did:plc:limpet/sh.tangled.repo/{tid}")), ); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::OK); @@ -365,7 +476,8 @@ async fn missing_knot_param_on_knot_route_returns_400() { #[tokio::test] async fn second_proxy_call_skips_slingshot_via_lru() { let h = Harness::new().await; - h.mount_repo_record("did:plc:abalone", "r1", "barnacle") + let tid = "3jzfcijpj2z2c"; + h.mount_repo_record("did:plc:abalone", tid, "barnacle") .await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.tree")) @@ -379,7 +491,7 @@ async fn second_proxy_call_skips_slingshot_via_lru() { .await; let target = format!( "/xrpc/sh.tangled.repo.tree?repo={}&ref=main", - enc("at://did:plc:abalone/sh.tangled.repo/r1"), + enc(&format!("at://did:plc:abalone/sh.tangled.repo/{tid}")), ); let r1 = h.call(&target).await; assert_eq!(r1.status(), StatusCode::OK); @@ -439,7 +551,8 @@ async fn does_not_inject_auth_or_atproto_proxy_headers() { #[tokio::test] async fn forwards_range_and_conditional_request_headers() { let h = Harness::new().await; - h.mount_repo_record("did:plc:limpet", "r3", "kelp").await; + let tid = "3jzfcijpj2z2d"; + h.mount_repo_record("did:plc:limpet", tid, "kelp").await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.archive")) .and(query_param("repo", "did:plc:limpet/kelp")) @@ -456,7 +569,7 @@ async fn forwards_range_and_conditional_request_headers() { let target = format!( "/xrpc/sh.tangled.repo.archive?repo={}&ref=v1", - enc("at://did:plc:limpet/sh.tangled.repo/r3"), + enc(&format!("at://did:plc:limpet/sh.tangled.repo/{tid}")), ); let resp = h .call_with_headers( -- 2.51.2