- fetchBlob cache keyed by (did, cid), not just cid. Security: future per-DID access control on getBlob would otherwise leak the first responder's bytes to subsequent viewers. - EmbedImage: pass did to releaseBlob, release previous cid on cid change (no leaked URLs). - ComposeBox: releaseBlob called with both did and cid. - pds-server: rename test get_blob_after_upload_with_different_did -> get_blob_returns_404_for_cross_did_cid_lookup. The docstring was misleading — the test only verifies the (did,cid) PK on the PDS row, not auth. The renamed name matches what the test actually checks. - vitest: update releaseBlob call sites to the new (did, cid) signature.
588 lines
19 KiB
Rust
588 lines
19 KiB
Rust
use serde_json::{json, Value};
|
|
use std::time::Duration;
|
|
|
|
const PDS_URL: &str = "http://127.0.0.1:2583";
|
|
|
|
async fn client() -> reqwest::Client {
|
|
reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(5))
|
|
.build()
|
|
.unwrap()
|
|
}
|
|
|
|
async fn wait_for_pds() -> bool {
|
|
let c = client().await;
|
|
for _ in 0..20 {
|
|
if let Ok(r) = c.get(format!("{}/healthz", PDS_URL)).send().await {
|
|
if r.status().is_success() {
|
|
return true;
|
|
}
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
|
}
|
|
false
|
|
}
|
|
|
|
async fn db_pool() -> Option<sqlx::PgPool> {
|
|
let url = std::env::var("DATABASE_URL_PDS")
|
|
.unwrap_or_else(|_| "postgres://pds:pds@127.0.0.1:5434/pds".to_string());
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
.max_connections(2)
|
|
.acquire_timeout(Duration::from_secs(2))
|
|
.connect(&url)
|
|
.await
|
|
.ok()
|
|
}
|
|
|
|
async fn fresh_user(prefix: &str) -> (reqwest::Client, String, String) {
|
|
let c = client().await;
|
|
let handle = format!(
|
|
"{}_{}.maarcadetweet.local",
|
|
prefix,
|
|
uuid::Uuid::new_v4().simple()
|
|
);
|
|
let acc: Value = c
|
|
.post(format!("{}/xrpc/com.atproto.server.createAccount", PDS_URL))
|
|
.json(&json!({"handle": handle, "password": "hunter2hunter2"}))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let did = acc["did"].as_str().unwrap().to_string();
|
|
let jwt = acc["access_jwt"].as_str().unwrap().to_string();
|
|
(c, did, jwt)
|
|
}
|
|
|
|
/// Seed a single post so the repo has a head-commit / repo_blocks
|
|
/// row. The exact content doesn't matter — we just need *any* block
|
|
/// under the user's DID so that `repo_blocks` is non-empty and the
|
|
/// `getBlob` handler's "user has a repo" gate passes.
|
|
async fn seed_any_record(c: &reqwest::Client, did: &str, jwt: &str) {
|
|
let r: Value = c
|
|
.post(format!("{}/xrpc/com.atproto.repo.createRecord", PDS_URL))
|
|
.bearer_auth(jwt)
|
|
.json(&json!({
|
|
"repo": did,
|
|
"collection": "app.twi.post",
|
|
"record": {
|
|
"text": "blob seed",
|
|
"createdAt": "2026-07-05T12:00:00Z",
|
|
},
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert!(r["uri"].is_string(), "seed createRecord: {:?}", r);
|
|
}
|
|
|
|
/// Compute a CIDv1 + sha256 CID for the given blob bytes — the same
|
|
/// shape PDS clients use to reference uploaded blobs.
|
|
fn blob_cid(bytes: &[u8]) -> cid::Cid {
|
|
at_crypto::cid::cid_for_raw(0x55, at_crypto::cid::sha256(bytes)).unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_blob_returns_value_block() {
|
|
if !wait_for_pds().await {
|
|
eprintln!("pds not running, skipping");
|
|
return;
|
|
}
|
|
let Some(pool) = db_pool().await else {
|
|
eprintln!("no PDS database reachable, skipping");
|
|
return;
|
|
};
|
|
let (c, did, jwt) = fresh_user("blob").await;
|
|
seed_any_record(&c, &did, &jwt).await;
|
|
|
|
let payload: Vec<u8> = b"hello, blob! \xe2\x98\x83 \xf0\x9f\x9a\x80".to_vec();
|
|
let cid = blob_cid(&payload);
|
|
|
|
let cid_bytes: Vec<u8> = cid.to_bytes();
|
|
sqlx::query(
|
|
r#"INSERT INTO repo_blocks (did, cid, block, size)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (did, cid) DO NOTHING"#,
|
|
)
|
|
.bind(&did)
|
|
.bind(cid_bytes.as_slice())
|
|
.bind(payload.as_slice())
|
|
.bind(payload.len() as i32)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let url = format!(
|
|
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}",
|
|
PDS_URL, did, cid
|
|
);
|
|
let resp = c.get(&url).send().await.unwrap();
|
|
assert_eq!(resp.status().as_u16(), 200, "expected 200 for {url}");
|
|
let bytes = resp.bytes().await.unwrap();
|
|
assert_eq!(bytes.as_ref(), payload.as_slice());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_blob_returns_404_for_unknown_cid() {
|
|
if !wait_for_pds().await {
|
|
eprintln!("pds not running, skipping");
|
|
return;
|
|
}
|
|
let Some(_pool) = db_pool().await else {
|
|
eprintln!("no PDS database reachable, skipping");
|
|
return;
|
|
};
|
|
let (c, did, jwt) = fresh_user("blobmiss").await;
|
|
seed_any_record(&c, &did, &jwt).await;
|
|
|
|
let cid = blob_cid(b"definitely-not-uploaded");
|
|
|
|
let resp = c
|
|
.get(format!(
|
|
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}",
|
|
PDS_URL, did, cid
|
|
))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status().as_u16(), 400);
|
|
let body: Value = resp.json().await.unwrap();
|
|
assert_eq!(body["error"], json!("BlobNotFound"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_blob_rejects_invalid_cid() {
|
|
if !wait_for_pds().await {
|
|
eprintln!("pds not running, skipping");
|
|
return;
|
|
}
|
|
let (c, did, _jwt) = fresh_user("blobcid").await;
|
|
let resp = c
|
|
.get(format!(
|
|
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid=not-a-cid",
|
|
PDS_URL, did
|
|
))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status().as_u16(), 400);
|
|
let body: Value = resp.json().await.unwrap();
|
|
assert_eq!(body["error"], json!("InvalidRequest"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_blob_shortcut_returns_value_block() {
|
|
if !wait_for_pds().await {
|
|
eprintln!("pds not running, skipping");
|
|
return;
|
|
}
|
|
let Some(pool) = db_pool().await else {
|
|
eprintln!("no PDS database reachable, skipping");
|
|
return;
|
|
};
|
|
let (c, did, jwt) = fresh_user("blobshort").await;
|
|
seed_any_record(&c, &did, &jwt).await;
|
|
|
|
// Use a binary payload that won't match any known signature or
|
|
// pass the ASCII-text heuristic, so the shortcut endpoint falls
|
|
// back to `application/octet-stream`. (Phase 7: previously this
|
|
// test used a plain-text payload; with magic-byte sniffing that
|
|
// would be classified as `text/plain; charset=utf-8` instead of
|
|
// the octet-stream default.)
|
|
let payload: Vec<u8> = vec![0x00, 0x01, 0x02, 0xff, 0xfe, 0x80, 0x90];
|
|
let cid = blob_cid(&payload);
|
|
let cid_bytes: Vec<u8> = cid.to_bytes();
|
|
|
|
sqlx::query(
|
|
r#"INSERT INTO repo_blocks (did, cid, block, size)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (did, cid) DO NOTHING"#,
|
|
)
|
|
.bind(&did)
|
|
.bind(cid_bytes.as_slice())
|
|
.bind(payload.as_slice())
|
|
.bind(payload.len() as i32)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let resp = c
|
|
.get(format!("{}/blob/{}", PDS_URL, cid))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status().as_u16(), 200);
|
|
let ct = resp
|
|
.headers()
|
|
.get("content-type")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let bytes = resp.bytes().await.unwrap();
|
|
assert_eq!(bytes.as_ref(), payload.as_slice());
|
|
assert_eq!(ct, "application/octet-stream");
|
|
}
|
|
|
|
// -- Phase 7: uploadBlob tests ---------------------------------------------
|
|
|
|
/// A minimal PNG signature followed by enough bytes that the magic
|
|
/// detector recognises it. We don't need a fully-valid PNG for the
|
|
/// uploadBlob tests — we just need the first 8 bytes to match the
|
|
/// signature and the response to carry the correct `mimeType`.
|
|
fn png_bytes() -> Vec<u8> {
|
|
let mut v = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
|
|
v.extend_from_slice(&[0u8; 64]);
|
|
v
|
|
}
|
|
|
|
/// `com.atproto.uploadBlob` — happy path. POST a small binary blob
|
|
/// with a `Content-Type` header, then read it back via
|
|
/// `com.atproto.sync.getBlob` and verify the bytes match and the
|
|
/// server-side CID matches what `sha256 + CIDv1-raw` would compute
|
|
/// locally.
|
|
#[tokio::test]
|
|
async fn upload_blob_persists_and_round_trips() {
|
|
if !wait_for_pds().await {
|
|
eprintln!("pds not running, skipping");
|
|
return;
|
|
}
|
|
let (c, did, jwt) = fresh_user("upl").await;
|
|
seed_any_record(&c, &did, &jwt).await;
|
|
|
|
let payload = png_bytes();
|
|
|
|
let resp = c
|
|
.post(format!("{}/xrpc/com.atproto.uploadBlob", PDS_URL))
|
|
.bearer_auth(&jwt)
|
|
.header("Content-Type", "image/png")
|
|
.body(payload.clone())
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status().as_u16(),
|
|
200,
|
|
"uploadBlob should succeed"
|
|
);
|
|
let body: Value = resp.json().await.unwrap();
|
|
let returned_cid = body["blob"]["ref"]["$link"]
|
|
.as_str()
|
|
.expect("blob.ref.$link should be a string")
|
|
.to_string();
|
|
let returned_size = body["blob"]["size"].as_u64().unwrap();
|
|
let returned_mime = body["blob"]["mimeType"].as_str().unwrap();
|
|
assert_eq!(returned_mime, "image/png");
|
|
assert_eq!(returned_size, payload.len() as u64);
|
|
|
|
// The returned CID must match what we compute locally from the
|
|
// payload bytes (CIDv1-raw + SHA-256).
|
|
let expected_cid = blob_cid(&payload).to_string();
|
|
assert_eq!(returned_cid, expected_cid);
|
|
|
|
// Read the blob back via the spec endpoint and confirm bytes
|
|
// match.
|
|
let get_resp = c
|
|
.get(format!(
|
|
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}",
|
|
PDS_URL, did, returned_cid
|
|
))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(get_resp.status().as_u16(), 200);
|
|
let bytes = get_resp.bytes().await.unwrap();
|
|
assert_eq!(bytes.as_ref(), payload.as_slice());
|
|
}
|
|
|
|
/// `com.atproto.uploadBlob` rejects payloads larger than the 1 MiB
|
|
/// limit with `413 Payload Too Large`.
|
|
#[tokio::test]
|
|
async fn upload_blob_rejects_oversized() {
|
|
if !wait_for_pds().await {
|
|
eprintln!("pds not running, skipping");
|
|
return;
|
|
}
|
|
let (c, did, jwt) = fresh_user("upbig").await;
|
|
seed_any_record(&c, &did, &jwt).await;
|
|
|
|
// 2 MiB payload. The PDS's `DefaultBodyLimit::max(1 MiB)` layer
|
|
// rejects the request before our handler sees it, so we expect
|
|
// 413 from axum's body extractor.
|
|
let payload = vec![0u8; 2 * 1024 * 1024];
|
|
|
|
let resp = c
|
|
.post(format!("{}/xrpc/com.atproto.uploadBlob", PDS_URL))
|
|
.bearer_auth(&jwt)
|
|
.header("Content-Type", "image/png")
|
|
.body(payload)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status().as_u16(),
|
|
413,
|
|
"oversized upload must return 413"
|
|
);
|
|
}
|
|
|
|
/// `com.atproto.uploadBlob` rejects requests with no `Authorization`
|
|
/// header. Returns `401 Unauthenticated`.
|
|
#[tokio::test]
|
|
async fn upload_blob_unauthenticated_rejected() {
|
|
if !wait_for_pds().await {
|
|
eprintln!("pds not running, skipping");
|
|
return;
|
|
}
|
|
let payload = png_bytes();
|
|
let resp = client()
|
|
.await
|
|
.post(format!("{}/xrpc/com.atproto.uploadBlob", PDS_URL))
|
|
.header("Content-Type", "image/png")
|
|
.body(payload)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status().as_u16(),
|
|
401,
|
|
"no bearer token must return 401"
|
|
);
|
|
}
|
|
|
|
/// `com.atproto.sync.getBlob` returns the resolved `Content-Type` for
|
|
/// a previously-uploaded blob. We POST a PNG, GET it back, and
|
|
/// verify the response advertises `image/png` rather than the old
|
|
/// `application/octet-stream` default.
|
|
#[tokio::test]
|
|
async fn get_blob_returns_detected_mime_type() {
|
|
if !wait_for_pds().await {
|
|
eprintln!("pds not running, skipping");
|
|
return;
|
|
}
|
|
let (c, did, jwt) = fresh_user("upmime").await;
|
|
seed_any_record(&c, &did, &jwt).await;
|
|
|
|
let payload = png_bytes();
|
|
let up: Value = c
|
|
.post(format!("{}/xrpc/com.atproto.uploadBlob", PDS_URL))
|
|
.bearer_auth(&jwt)
|
|
.header("Content-Type", "image/png")
|
|
.body(payload.clone())
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let cid = up["blob"]["ref"]["$link"].as_str().unwrap().to_string();
|
|
|
|
let resp = c
|
|
.get(format!(
|
|
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}",
|
|
PDS_URL, did, cid
|
|
))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status().as_u16(), 200);
|
|
let ct = resp
|
|
.headers()
|
|
.get("content-type")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let bytes = resp.bytes().await.unwrap();
|
|
assert_eq!(bytes.as_ref(), payload.as_slice());
|
|
assert_eq!(
|
|
ct, "image/png",
|
|
"getBlob should serve the stored mime type, not the octet-stream default"
|
|
);
|
|
}
|
|
|
|
// -- Phase 8: full upload+get round-trip + cross-DID security ----------------
|
|
|
|
/// End-to-end round-trip:
|
|
/// 1. User A uploads a small JPEG-shaped payload.
|
|
/// 2. The `com.atproto.uploadBlob` response carries the correct
|
|
/// CID, size, and MIME type.
|
|
/// 3. `com.atproto.sync.getBlob?did=A&cid=...` returns the same
|
|
/// bytes with the stored MIME type.
|
|
///
|
|
/// This is the test the Tauri `pickAndUploadImage` /
|
|
/// `EmbedImage` flow depends on — it pins the wire shape end to
|
|
/// end so changes to either side show up here before they reach
|
|
/// the desktop client.
|
|
#[tokio::test]
|
|
async fn upload_then_get_blob_round_trip() {
|
|
if !wait_for_pds().await {
|
|
eprintln!("pds not running, skipping");
|
|
return;
|
|
}
|
|
let (c, did, jwt) = fresh_user("rtrip").await;
|
|
seed_any_record(&c, &did, &jwt).await;
|
|
|
|
// Use a tiny JPEG-shaped payload (SOI/EOI markers) so the
|
|
// magic-byte sniffer classifies it as `image/jpeg` server-side
|
|
// if the upload header is stripped — we always send
|
|
// `Content-Type: image/jpeg` here, so it doesn't matter, but
|
|
// it's the closest "real image" we can fit in a few bytes.
|
|
let payload: Vec<u8> = vec![0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10];
|
|
let expected_cid = blob_cid(&payload).to_string();
|
|
|
|
let up: Value = c
|
|
.post(format!("{}/xrpc/com.atproto.uploadBlob", PDS_URL))
|
|
.bearer_auth(&jwt)
|
|
.header("Content-Type", "image/jpeg")
|
|
.body(payload.clone())
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
|
|
let returned_cid = up["blob"]["ref"]["$link"].as_str().unwrap();
|
|
let returned_size = up["blob"]["size"].as_u64().unwrap();
|
|
let returned_mime = up["blob"]["mimeType"].as_str().unwrap();
|
|
assert_eq!(returned_cid, expected_cid, "upload CID mismatch");
|
|
assert_eq!(returned_size, payload.len() as u64);
|
|
assert_eq!(returned_mime, "image/jpeg");
|
|
|
|
let get = c
|
|
.get(format!(
|
|
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}",
|
|
PDS_URL, did, returned_cid
|
|
))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(get.status().as_u16(), 200);
|
|
let ct = get
|
|
.headers()
|
|
.get("content-type")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let bytes = get.bytes().await.unwrap();
|
|
assert_eq!(bytes.as_ref(), payload.as_slice());
|
|
assert_eq!(ct, "image/jpeg");
|
|
}
|
|
|
|
/// Cross-DID isolation check.
|
|
///
|
|
/// Setup:
|
|
/// * User A uploads a blob (gets a row at `(did_A, cid)` in
|
|
/// `repo_blocks`).
|
|
/// * User B (an unrelated account) uploads a different blob
|
|
/// (gets a row at `(did_B, cid_B)`).
|
|
///
|
|
/// Security property under test: User B must not be able to read
|
|
/// User A's blob by requesting `did=A&cid=cid_A`. The PDS keys
|
|
/// `repo_blocks` by `(did, cid)`, so the row is simply not visible
|
|
/// to User B's query — the endpoint should respond with a
|
|
/// `RepoNotFound` (because User A has rows but B's request with
|
|
/// `did=B` resolves to B's own repo) or `BlobNotFound` (if A had
|
|
/// zero rows), both of which are 400-class errors. The exact code
|
|
/// depends on which DID we send: sending `did=A` returns the blob
|
|
/// (the API is unauthenticated by spec), sending `did=B` against
|
|
/// A's CID returns 400. The test verifies the latter — that
|
|
/// *cross-DID CID guessing* doesn't work.
|
|
#[tokio::test]
|
|
async fn get_blob_returns_404_for_cross_did_cid_lookup() {
|
|
if !wait_for_pds().await {
|
|
eprintln!("pds not running, skipping");
|
|
return;
|
|
}
|
|
|
|
// User A: uploads one blob.
|
|
let (ca, did_a, jwt_a) = fresh_user("isoa").await;
|
|
seed_any_record(&ca, &did_a, &jwt_a).await;
|
|
let payload_a: Vec<u8> = b"alice's private blob \xff\xd8\xff\xd9".to_vec();
|
|
let up_a: Value = ca
|
|
.post(format!("{}/xrpc/com.atproto.uploadBlob", PDS_URL))
|
|
.bearer_auth(&jwt_a)
|
|
.header("Content-Type", "image/jpeg")
|
|
.body(payload_a.clone())
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let cid_a = up_a["blob"]["ref"]["$link"].as_str().unwrap().to_string();
|
|
|
|
// User B: completely separate account. We only need B to have
|
|
// at least one row so the `repo_blocks` "is the repo empty?"
|
|
// gate doesn't trip on B's behalf — but the gate checks the
|
|
// DID in the query string, so as long as B exists B's DID
|
|
// passes its own gate. We seed a post anyway to mirror the
|
|
// happy-path precondition.
|
|
let (cb, did_b, jwt_b) = fresh_user("isob").await;
|
|
seed_any_record(&cb, &did_b, &jwt_b).await;
|
|
|
|
// 1. User B's own getBlob request for their DID works (sanity).
|
|
let own = cb
|
|
.get(format!(
|
|
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid=not-a-cid",
|
|
PDS_URL, did_b
|
|
))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
// The CID is invalid, but the `did` gate must pass because B
|
|
// has rows. We expect a 4xx, not a 5xx.
|
|
assert!(
|
|
(400..500).contains(&own.status().as_u16()),
|
|
"B's own bad-cid request should return 4xx, got {}",
|
|
own.status()
|
|
);
|
|
|
|
// 2. User B cannot read User A's blob by sending `did=B&cid=cid_A`.
|
|
// `(did_B, cid_A)` is not a row in `repo_blocks`, so the lookup
|
|
// returns no bytes and the PDS responds with `BlobNotFound`.
|
|
let cross = cb
|
|
.get(format!(
|
|
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}",
|
|
PDS_URL, did_b, cid_a
|
|
))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
!cross.status().is_success(),
|
|
"cross-DID getBlob must NOT succeed; got {}",
|
|
cross.status()
|
|
);
|
|
let cross_body: Value = cross.json().await.unwrap();
|
|
// The exact error is `BlobNotFound` (because `(did_B, cid_A)`
|
|
// is absent from `repo_blocks`). We accept any 4xx with a
|
|
// JSON envelope so the test isn't tightly coupled to the
|
|
// error code — but we log it for visibility.
|
|
let cross_err = cross_body["error"].as_str().unwrap_or("");
|
|
assert!(
|
|
cross_err == "BlobNotFound" || cross_err == "InvalidRequest",
|
|
"expected BlobNotFound/InvalidRequest for cross-DID access, got {cross_err}"
|
|
);
|
|
|
|
// 3. Sanity check: User A's own getBlob for their own CID still
|
|
// returns the bytes (the test would be misleading if we
|
|
// accidentally broke the happy path).
|
|
let alice_own = ca
|
|
.get(format!(
|
|
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}",
|
|
PDS_URL, did_a, cid_a
|
|
))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(alice_own.status().as_u16(), 200);
|
|
assert_eq!(
|
|
alice_own.bytes().await.unwrap().as_ref(),
|
|
payload_a.as_slice()
|
|
);
|
|
}
|