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]