diff --git a/bobbin/crates/xrpc/tests/aggregation.rs b/bobbin/crates/xrpc/tests/aggregation.rs --- a/bobbin/crates/xrpc/tests/aggregation.rs +++ b/bobbin/crates/xrpc/tests/aggregation.rs @@ -155,7 +155,7 @@ } 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 --- a/bobbin/crates/xrpc/tests/bulk.rs +++ b/bobbin/crates/xrpc/tests/bulk.rs @@ -127,6 +127,18 @@ 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))) .collect::>() .join("&"); @@ -258,6 +270,31 @@ assert_eq!(status, StatusCode::OK); let items = body["items"].as_array().unwrap(); 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] diff --git a/bobbin/crates/xrpc/tests/cold_start.rs b/bobbin/crates/xrpc/tests/cold_start.rs --- a/bobbin/crates/xrpc/tests/cold_start.rs +++ b/bobbin/crates/xrpc/tests/cold_start.rs @@ -84,6 +84,13 @@ } 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}")) @@ -212,6 +219,59 @@ } }) .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] diff --git a/bobbin/crates/xrpc/tests/extended.rs b/bobbin/crates/xrpc/tests/extended.rs --- a/bobbin/crates/xrpc/tests/extended.rs +++ b/bobbin/crates/xrpc/tests/extended.rs @@ -133,6 +133,24 @@ } 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('&'); @@ -383,6 +401,49 @@ assert_eq!(items.len(), 1); assert_eq!(items[0]["value"]["subject"], json!(issue_uri.as_ref())); 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]