diff --git a/bobbin/crates/xrpc/tests/aggregation.rs b/bobbin/crates/xrpc/tests/aggregation.rs index bbe7292b..31a5dedc 100644 --- a/bobbin/crates/xrpc/tests/aggregation.rs +++ b/bobbin/crates/xrpc/tests/aggregation.rs @@ -155,7 +155,7 @@ impl Harness { } fn list_request(endpoint: &str, subject: &str, extras: &[(&str, &str)]) -> Request { - let mut qs = format!("subject={}", encode(subject)); + let mut qs = format!("subject={subject}"); extras.iter().for_each(|(k, v)| { qs.push('&'); qs.push_str(k); diff --git a/bobbin/crates/xrpc/tests/bulk.rs b/bobbin/crates/xrpc/tests/bulk.rs index 18a432fb..5f8de0d0 100644 --- a/bobbin/crates/xrpc/tests/bulk.rs +++ b/bobbin/crates/xrpc/tests/bulk.rs @@ -125,6 +125,18 @@ fn enc(s: &str) -> String { } fn bulk_request(endpoint: &str, key: &str, values: &[&str]) -> Request { + let qs = values + .iter() + .map(|v| format!("{key}={v}")) + .collect::>() + .join("&"); + Request::builder() + .uri(format!("/xrpc/{endpoint}?{qs}")) + .body(Body::empty()) + .unwrap() +} + +fn bulk_request_escaped(endpoint: &str, key: &str, values: &[&str]) -> Request { let qs = values .iter() .map(|v| format!("{key}={}", enc(v))) @@ -260,6 +272,31 @@ async fn get_profiles_returns_all_resolved_profiles() { assert_eq!(items.len(), 2); } +#[tokio::test] +async fn get_profiles_accepts_percent_escaped_at_uris() { + let h = Harness::new().await; + h.mount( + &did("did:plc:nel"), + &nsid("sh.tangled.actor.profile"), + &rkey("self"), + profile_body(&handle("witchcraft.systems")), + ) + .await; + let app = router(h.state.clone()); + let (status, body) = json_response( + app.oneshot(bulk_request_escaped( + "sh.tangled.actor.getProfiles", + "actors", + &["at://did:plc:nel/sh.tangled.actor.profile/self"], + )) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["items"].as_array().unwrap().len(), 1); +} + #[tokio::test] async fn get_issues_returns_all_resolved_issues() { let h = Harness::new().await; diff --git a/bobbin/crates/xrpc/tests/cold_start.rs b/bobbin/crates/xrpc/tests/cold_start.rs index 9c7dc32e..dc97f38d 100644 --- a/bobbin/crates/xrpc/tests/cold_start.rs +++ b/bobbin/crates/xrpc/tests/cold_start.rs @@ -84,6 +84,13 @@ async fn mount_record( } fn xrpc_request(endpoint: &str, param: &str, value: &str) -> Request { + Request::builder() + .uri(format!("/xrpc/{endpoint}?{param}={value}")) + .body(Body::empty()) + .unwrap() +} + +fn xrpc_request_escaped(endpoint: &str, param: &str, value: &str) -> Request { let encoded: String = byte_serialize(value.as_bytes()).collect(); Request::builder() .uri(format!("/xrpc/{endpoint}?{param}={encoded}")) @@ -214,6 +221,59 @@ async fn cold_start_serves_all_four_point_lookups() { .await; } +#[tokio::test] +async fn percent_escaped_at_uri_resolves_identically_to_raw() { + let server = MockServer::start().await; + let clam = did("did:plc:clam"); + mount_record( + &server, + &clam, + &nsid("sh.tangled.actor.profile"), + &rkey("self"), + json!({ + "$type": "sh.tangled.actor.profile", + "bluesky": false, + "description": "clam shell" + }), + ) + .await; + + let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await; + let app = router(state); + + let at_uri = format!("at://{}/sh.tangled.actor.profile/self", clam.as_ref()); + + let (raw_status, raw_body) = json_response( + app.clone() + .oneshot(xrpc_request( + "sh.tangled.actor.getProfile", + "actor", + &at_uri, + )) + .await + .unwrap(), + ) + .await; + let (escaped_status, escaped_body) = json_response( + app.oneshot(xrpc_request_escaped( + "sh.tangled.actor.getProfile", + "actor", + &at_uri, + )) + .await + .unwrap(), + ) + .await; + + assert_eq!(raw_status, StatusCode::OK, "raw at-uri status"); + assert_eq!(escaped_status, StatusCode::OK, "escaped at-uri status"); + assert_eq!( + raw_body, escaped_body, + "raw and escaped must resolve identically" + ); + assert_eq!(escaped_body["uri"], at_uri); +} + #[tokio::test] async fn second_call_is_served_from_lru() { let server = MockServer::start().await; diff --git a/bobbin/crates/xrpc/tests/extended.rs b/bobbin/crates/xrpc/tests/extended.rs index a0152ffe..2c6df453 100644 --- a/bobbin/crates/xrpc/tests/extended.rs +++ b/bobbin/crates/xrpc/tests/extended.rs @@ -133,6 +133,24 @@ impl Harness { } fn list_request(endpoint: &str, subject: &str, extras: &[(&str, &str)]) -> Request { + let mut qs = format!("subject={subject}"); + extras.iter().for_each(|(k, v)| { + qs.push('&'); + qs.push_str(k); + qs.push('='); + qs.push_str(&encode(v)); + }); + Request::builder() + .uri(format!("/xrpc/{endpoint}?{qs}")) + .body(Body::empty()) + .unwrap() +} + +fn list_request_escaped_subject( + endpoint: &str, + subject: &str, + extras: &[(&str, &str)], +) -> Request { let mut qs = format!("subject={}", encode(subject)); extras.iter().for_each(|(k, v)| { qs.push('&'); @@ -385,6 +403,49 @@ async fn list_label_ops_accepts_issue_subject() { assert_eq!(items[0]["value"]["add"][0]["key"], json!(def_uri.as_ref())); } +#[tokio::test] +async fn list_ops_accepts_percent_escaped_subject() { + let h = Harness::new().await; + let issue_uri = at("at://did:plc:clam/sh.tangled.repo.issue/i1"); + let author = did("did:plc:nel"); + let rk = rkey("op1"); + let def_uri = at("at://did:plc:clam/sh.tangled.label.definition/bug"); + h.add_edge( + &nsid("sh.tangled.label.op"), + &issue_uri, + &at(&format!( + "at://{}/sh.tangled.label.op/{}", + author.as_ref(), + rk.as_ref() + )), + ); + h.mount( + &author, + &nsid("sh.tangled.label.op"), + &rk, + label_op_body(&issue_uri, &def_uri, "true"), + ) + .await; + + let app = router(h.state.clone()); + let (status, body) = json_response( + app.oneshot(list_request_escaped_subject( + "sh.tangled.label.listOps", + issue_uri.as_ref(), + &[], + )) + .await + .unwrap(), + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "escaped subject must still be accepted" + ); + assert_eq!(body["items"].as_array().unwrap().len(), 1); +} + #[tokio::test] async fn list_label_ops_pull_subject_round_trip() { let h = Harness::new().await;