Files
buzz/crates/buzz-test-client/tests/e2e_media.rs
John Matthew Tennant f9ef721e68 fix(media): keep transparency in image thumbnails with WebP (#9003)
## Summary

This fixes transparent images showing a black background. Image
thumbnails were always JPEG, which can't store transparency, so a
transparent PNG or GIF upload, such as an agent avatar, rendered on
black wherever a client showed its thumbnail.

The relay now writes a lossy WebP thumbnail at `{sha256}.thumb.webp`,
which keeps transparency, alongside the existing `{sha256}.thumb.jpg`:

- **Encoding:** the WebP comes from libwebp through the `webp` crate,
built from vendored C source, so the relay needs no system library. A
GIF uses its first frame.
- **Upload responses:** the upload descriptor's `thumb`, and its
per-tenant rewrite, point to the WebP.
- **JPEG kept:** installed clients derive `.thumb.jpg` themselves, so
the relay keeps writing it until clients move to WebP with a JPEG
fallback. Existing images still have only their JPEG thumbnail.
- **Serving:** `/media/` serves both variants, each with its own content
type. Other `.thumb.*` extensions still return 404.
- **imeta:** a `thumb` may name either variant, and the relay checks
that the named object exists.
- **Storage sweep:** the bucket taxonomy counts both variants as the
image's thumbnail bytes instead of classifying `.thumb.webp` as unknown.

| 320px thumbnail | JPEG | PNG | lossy WebP (q80) |
|---|---|---|---|
| Transparent avatar (starter-team `fizz.png`) | 11.3 KB, black
background | 65.1 KB | 13.8 KB |
| Opaque avatar (`sprout-agent.png`) | 23.6 KB | 156.5 KB | 12.3 KB |
| Screenshot (`channel-thread.png`) | 13.4 KB | 62.5 KB | 6.4 KB |

Encoding a WebP thumbnail takes 3 to 20 ms.

### Related issue

Supersedes #8772, which served a PNG at the `.thumb.jpg` path.

### Testing

Red/green against a local relay on `:3000`, built from the branch under
test, with Postgres, Redis and MinIO from `docker compose`:

```bash
cargo test -p buzz-test-client --test e2e_media test_transparent_png_thumbnail_is_webp_with_alpha -- --ignored
cargo test -p buzz-test-client --test e2e_media_extended test_ws_imeta_webp_thumb -- --ignored
```

- **On `main`:** both tests fail. The descriptor's `thumb` is
`.thumb.jpg`, and the WebSocket send is rejected with `invalid: imeta
thumb must be a local .thumb.jpg path`.
- **On this branch:** both tests pass:
- Uploading a half-transparent PNG returns a `thumb` ending
`.thumb.webp`.
  - HEAD and GET on that URL return `Content-Type: image/webp`.
- The decoded thumbnail keeps alpha 0 on the transparent half and alpha
255 on the opaque half.
  - `.thumb.jpg` still returns `image/jpeg`.
- A kind 9 message whose imeta `thumb` names the `.thumb.webp` is
accepted.

## Screenshots

The starter-team `fizz.png` avatar uploaded to a relay built from this
branch. Both thumbnails are shown as round avatars beside the original
PNG. `.thumb.jpg` fills the transparent area with a solid disc, and
`.thumb.webp` keeps the transparency.

![Transparent avatar thumbnails: .thumb.jpg, .thumb.webp and the
original PNG on light and dark
backgrounds](https://raw.githubusercontent.com/block/buzz/9ee1c633d8158ba401efcf738eb5676070ebd9cf/pr-9003--transparent-avatar-thumbnails.png)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: John Matthew Tennant <jtennant@squareup.com>
Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 10:11:00 -05:00

564 lines
20 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! End-to-end media upload tests (Blossom / NIP-96 style).
//!
//! Requires: relay running at localhost:3000, MinIO running at localhost:9000.
//! All tests are `#[ignore]` so they don't run in CI by default.
//!
//! # Running
//!
//! ```text
//! cargo test -p buzz-test-client --test e2e_media -- --ignored --nocapture
//! ```
//!
//! Override the relay URL:
//!
//! ```text
//! RELAY_HTTP_URL=http://localhost:3000 cargo test -p buzz-test-client --test e2e_media -- --ignored
//! ```
use std::time::Duration;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
use reqwest::Client;
use sha2::{Digest, Sha256};
fn relay_http_url() -> String {
std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
}
/// Extract the host:port authority from the relay URL for use as the `server` tag.
fn relay_server_authority() -> String {
let url = relay_http_url();
url.trim_start_matches("https://")
.trim_start_matches("http://")
.split('/')
.next()
.unwrap_or("localhost:3000")
.to_string()
}
fn http_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(15))
.build()
.expect("failed to build HTTP client")
}
/// Sign a kind:24242 Blossom upload auth event for the given sha256.
fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let server = relay_server_authority();
let tags = vec![
Tag::parse(["t", "upload"]).expect("t tag"),
Tag::parse(["x", sha256]).expect("x tag"),
Tag::parse(["expiration", &exp_str]).expect("expiration tag"),
Tag::parse(["server", &server]).expect("server tag"),
];
EventBuilder::new(Kind::from(24242), "Upload test")
.tags(tags)
.sign_with_keys(keys)
.expect("sign blossom auth")
}
/// Sign a kind:24242 Blossom *read* auth event for the given sha256.
///
/// Reads are authenticated unconditionally, so every successful GET/HEAD in this
/// file has to present one of these. The `x` tag is hash-scoped and covers the
/// derived paths too -- the relay matches on the sha256 before the extension, so
/// one token serves `{sha}.jpg` and `{sha}.thumb.jpg` alike.
fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event {
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let server = relay_server_authority();
let tags = vec![
Tag::parse(["t", "get"]).expect("t tag"),
Tag::parse(["x", sha256]).expect("x tag"),
Tag::parse(["expiration", &exp_str]).expect("expiration tag"),
Tag::parse(["server", &server]).expect("server tag"),
];
EventBuilder::new(Kind::from(24242), "Get test")
.tags(tags)
.sign_with_keys(keys)
.expect("sign blossom get auth")
}
/// Build `Authorization: Nostr <base64url(json)>` header value.
fn blossom_auth_header(event: &nostr::Event) -> String {
format!(
"Nostr {}",
URL_SAFE_NO_PAD.encode(event.as_json().as_bytes())
)
}
/// A valid 1×1 red JPEG (339 bytes). Used for fast upload tests.
fn tiny_jpeg() -> Vec<u8> {
vec![
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06,
0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B,
0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, 0x30, 0x31,
0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF,
0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00,
0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B,
0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05,
0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21,
0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08,
0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A,
0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37,
0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56,
0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93,
0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9,
0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6,
0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7,
0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x7B, 0x94,
0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xFF, 0xD9,
]
}
/// Upload a tiny JPEG, then GET it back and verify the bytes match.
/// Also checks BlobDescriptor fields and thumbnail endpoint.
#[tokio::test]
#[ignore]
async fn test_upload_and_get() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
println!("sha256: {sha256}");
println!("relay: {}", relay_http_url());
// Standards-compliant BUD-02 upload route.
let auth = sign_blossom_auth(&keys, &sha256);
let resp = client
.put(format!("{}/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("Content-Type", "image/jpeg")
.header("X-SHA-256", &sha256)
.body(jpeg.clone())
.send()
.await
.expect("upload PUT failed");
let status = resp.status();
let body_text = resp.text().await.unwrap_or_default();
println!("PUT /upload → {status}: {body_text}");
assert_eq!(status, 200, "upload should succeed");
// Parse BlobDescriptor
let descriptor: serde_json::Value =
serde_json::from_str(&body_text).expect("BlobDescriptor JSON");
println!("BlobDescriptor: {descriptor:#}");
assert_eq!(
descriptor["sha256"].as_str().unwrap(),
sha256,
"sha256 must match"
);
assert!(
descriptor["url"].as_str().unwrap().contains(&sha256),
"url must contain sha256"
);
assert!(
descriptor["size"].as_u64().unwrap() > 0,
"size must be positive"
);
assert!(
descriptor["type"].as_str().is_some(),
"mime type must be present"
);
// dim and blurhash are best-effort (image processing may not run on 1x1 JPEG)
println!(
"dim: {:?}, blurhash: {:?}",
descriptor["dim"], descriptor["blurhash"]
);
// Reads are authenticated, so mint one hash-scoped token for all three below.
let read_auth = blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256));
// GET /media/{sha256}.jpg — bytes must match
let get_url = format!("{}/media/{sha256}.jpg", relay_http_url());
let get_resp = client
.get(&get_url)
.header("Authorization", &read_auth)
.send()
.await
.expect("GET /media/{sha256}.jpg failed");
assert_eq!(get_resp.status(), 200, "GET should return 200");
let returned_bytes = get_resp.bytes().await.unwrap();
assert_eq!(
returned_bytes.as_ref(),
jpeg.as_slice(),
"GET must return original bytes"
);
// HEAD /media/{sha256}.jpg — must return 200 with content-type
let head_resp = client
.head(&get_url)
.header("Authorization", &read_auth)
.send()
.await
.expect("HEAD /media/{sha256}.jpg failed");
assert_eq!(head_resp.status(), 200, "HEAD should return 200");
assert!(
head_resp.headers().get("content-type").is_some(),
"HEAD must include content-type"
);
// GET thumbnail — /media/{sha256}.thumb.jpg
let thumb_url = format!("{}/media/{sha256}.thumb.jpg", relay_http_url());
let thumb_resp = client
.get(&thumb_url)
.header("Authorization", &read_auth)
.send()
.await
.expect("GET thumbnail failed");
println!("GET thumbnail → {}", thumb_resp.status());
// Thumbnail may be same as original for 1x1 images — just check 200
assert_eq!(thumb_resp.status(), 200, "thumbnail should return 200");
}
/// An 8x8 PNG whose left half is transparent. A random opaque color keeps the
/// bytes, and so the sha, unique per run.
fn transparent_png() -> (Vec<u8>, [u8; 3]) {
let [r, g, b]: [u8; 3] = rand::random();
let mut png = Vec::new();
image::RgbaImage::from_fn(8, 8, |x, _| {
image::Rgba([r, g, b, if x < 4 { 0 } else { 255 }])
})
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.expect("encode PNG");
(png, [r, g, b])
}
/// A transparent image's descriptor points at a WebP thumbnail that keeps its
/// alpha, and the JPEG thumbnail installed clients derive is still served.
#[tokio::test]
#[ignore]
async fn test_transparent_png_thumbnail_is_webp_with_alpha() {
let client = http_client();
let keys = Keys::generate();
let (png, [r, g, b]) = transparent_png();
let sha256 = hex::encode(Sha256::digest(&png));
let auth = sign_blossom_auth(&keys, &sha256);
let resp = client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("Content-Type", "image/png")
.header("X-SHA-256", &sha256)
.body(png)
.send()
.await
.expect("upload PUT failed");
assert_eq!(resp.status(), 200, "upload should succeed");
let descriptor: serde_json::Value = resp.json().await.expect("BlobDescriptor JSON");
let thumb_url = descriptor["thumb"].as_str().expect("descriptor thumb");
assert!(
thumb_url.ends_with(&format!("/media/{sha256}.thumb.webp")),
"{thumb_url}"
);
let read_auth = blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256));
let head = client
.head(thumb_url)
.header("Authorization", &read_auth)
.send()
.await
.expect("HEAD thumbnail failed");
assert_eq!(head.status(), 200);
assert_eq!(head.headers()["content-type"], "image/webp");
let get = client
.get(thumb_url)
.header("Authorization", &read_auth)
.send()
.await
.expect("GET thumbnail failed");
assert_eq!(get.status(), 200);
assert_eq!(get.headers()["content-type"], "image/webp");
let thumb = image::load_from_memory(&get.bytes().await.unwrap())
.expect("decode thumbnail")
.to_rgba8();
assert_eq!(
thumb.get_pixel(0, 0)[3],
0,
"transparent pixel stays transparent"
);
let [tr, tg, tb, ta] = thumb.get_pixel(thumb.width() - 1, 0).0;
assert_eq!(ta, 255, "opaque pixel stays opaque");
assert!(
[(tr, r), (tg, g), (tb, b)]
.iter()
.all(|(t, o)| t.abs_diff(*o) <= 16),
"opaque color survives lossy encoding: {:?} vs {:?}",
[tr, tg, tb],
[r, g, b]
);
let jpeg = client
.get(format!("{}/media/{sha256}.thumb.jpg", relay_http_url()))
.header("Authorization", &read_auth)
.send()
.await
.expect("GET JPEG thumbnail failed");
assert_eq!(jpeg.status(), 200, "installed clients' JPEG thumbnail");
assert_eq!(jpeg.headers()["content-type"], "image/jpeg");
}
/// Idempotency: uploading the same file twice returns the same BlobDescriptor.
#[tokio::test]
#[ignore]
async fn test_upload_idempotent() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let upload = |keys: &Keys| {
let auth = sign_blossom_auth(keys, &sha256);
client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("Content-Type", "image/jpeg")
.header("X-SHA-256", sha256.clone())
.body(jpeg.clone())
.send()
};
let r1: serde_json::Value = upload(&keys)
.await
.expect("first upload failed")
.json()
.await
.expect("first descriptor parse");
// Second upload — different key, same content
let keys2 = Keys::generate();
let r2: serde_json::Value = upload(&keys2)
.await
.expect("second upload failed")
.json()
.await
.expect("second descriptor parse");
assert_eq!(
r1["sha256"], r2["sha256"],
"sha256 must be identical on re-upload"
);
assert_eq!(r1["url"], r2["url"], "url must be identical on re-upload");
}
/// Upload without an Authorization header must return 401.
#[tokio::test]
#[ignore]
async fn test_upload_no_auth_returns_401() {
let client = http_client();
let jpeg = tiny_jpeg();
let resp = client
.put(format!("{}/media/upload", relay_http_url()))
.header("Content-Type", "image/jpeg")
.body(jpeg)
.send()
.await
.expect("request failed");
println!("no-auth → {}", resp.status());
assert_eq!(resp.status(), 401, "upload without auth must be 401");
}
/// Upload without X-SHA-256 header must return 401 (BUD-11: mandatory).
#[tokio::test]
#[ignore]
async fn test_upload_missing_x_sha256_returns_401() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let auth = sign_blossom_auth(&keys, &sha256);
let resp = client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("Content-Type", "image/jpeg")
// Intentionally omit X-SHA-256
.body(jpeg)
.send()
.await
.expect("request failed");
println!("missing-x-sha256 → {}", resp.status());
assert_eq!(resp.status(), 401, "upload without X-SHA-256 must be 401");
}
/// Upload where the `x` tag sha256 doesn't match the actual body must return 401.
#[tokio::test]
#[ignore]
async fn test_upload_hash_mismatch_returns_400() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let wrong_hash = "f".repeat(64); // definitely not the real sha256
let auth = sign_blossom_auth(&keys, &wrong_hash);
let resp = client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("Content-Type", "image/jpeg")
.header("X-SHA-256", &wrong_hash)
.body(jpeg)
.send()
.await
.expect("request failed");
println!("hash-mismatch → {}", resp.status());
assert_eq!(resp.status(), 401, "hash mismatch must be 401");
}
/// GET an authenticated sha256 that was never uploaded must return 404.
///
/// The token has to be valid for the 404 to be reachable at all: authentication
/// runs before the storage lookup, so a bare request is rejected with 401 and
/// never distinguishes "missing" from "unauthorized" (see
/// `test_unauthenticated_reads_are_rejected`).
#[tokio::test]
#[ignore]
async fn test_get_nonexistent_returns_404() {
let client = http_client();
let keys = Keys::generate();
let missing_sha256 = "0".repeat(64);
let url = format!("{}/media/{missing_sha256}.jpg", relay_http_url());
let resp = client
.get(&url)
.header(
"Authorization",
blossom_auth_header(&sign_blossom_get_auth(&keys, &missing_sha256)),
)
.send()
.await
.expect("GET failed");
println!("missing blob → {}", resp.status());
assert_eq!(resp.status(), 404, "missing blob must be 404");
}
/// Bare reads are rejected with 401 before any storage lookup.
///
/// This is the boundary PR #4610 made unconditional: there is no longer a config
/// flag that lets an unauthenticated GET through, so the acceptance lane has to
/// assert the rejection directly. Uses a never-uploaded hash deliberately -- a 401
/// here rather than a 404 proves auth runs ahead of the storage lookup and that the
/// endpoint does not leak blob existence to an unauthenticated caller.
#[tokio::test]
#[ignore]
async fn test_unauthenticated_reads_are_rejected() {
let client = http_client();
let missing_sha256 = "0".repeat(64);
let blob_url = format!("{}/media/{missing_sha256}.jpg", relay_http_url());
let thumb_url = format!("{}/media/{missing_sha256}.thumb.jpg", relay_http_url());
let get_resp = client.get(&blob_url).send().await.expect("bare GET failed");
println!("bare GET → {}", get_resp.status());
assert_eq!(get_resp.status(), 401, "bare GET must be 401");
let head_resp = client
.head(&blob_url)
.send()
.await
.expect("bare HEAD failed");
println!("bare HEAD → {}", head_resp.status());
assert_eq!(head_resp.status(), 401, "bare HEAD must be 401");
let thumb_resp = client
.get(&thumb_url)
.send()
.await
.expect("bare thumbnail GET failed");
println!("bare thumbnail GET → {}", thumb_resp.status());
assert_eq!(thumb_resp.status(), 401, "bare thumbnail GET must be 401");
}
/// Upload a real image from the filesystem (set TEST_IMAGE_PATH env var).
/// Verifies the full round-trip: upload → BlobDescriptor → GET bytes match.
#[tokio::test]
#[ignore]
async fn test_upload_real_image() {
let image_path = match std::env::var("TEST_IMAGE_PATH") {
Ok(p) => p,
Err(_) => {
println!("Skipping: TEST_IMAGE_PATH not set");
return;
}
};
let client = http_client();
let keys = Keys::generate();
let bytes = std::fs::read(&image_path).expect("read image file");
let sha256 = hex::encode(Sha256::digest(&bytes));
let size = bytes.len();
println!("image: {image_path}");
println!("size: {size} bytes");
println!("sha256: {sha256}");
let auth = sign_blossom_auth(&keys, &sha256);
let resp = client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("Content-Type", "image/jpeg")
.header("X-SHA-256", &sha256)
.body(bytes.clone())
.send()
.await
.expect("upload PUT failed");
let status = resp.status();
let body_text = resp.text().await.unwrap_or_default();
println!("PUT /media/upload → {status}: {body_text}");
assert_eq!(status, 200, "upload should succeed");
let descriptor: serde_json::Value =
serde_json::from_str(&body_text).expect("BlobDescriptor JSON");
println!("BlobDescriptor: {descriptor:#}");
assert_eq!(descriptor["sha256"].as_str().unwrap(), sha256);
assert_eq!(descriptor["size"].as_u64().unwrap(), size as u64);
assert!(descriptor["url"].as_str().unwrap().contains(&sha256));
assert!(
descriptor["dim"].as_str().is_some(),
"real image should have dim"
);
assert!(
descriptor["blurhash"].as_str().is_some(),
"real image should have blurhash"
);
// GET bytes back and verify
let get_url = descriptor["url"].as_str().unwrap();
let get_resp = client
.get(get_url)
.header(
"Authorization",
blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)),
)
.send()
.await
.expect("GET failed");
assert_eq!(get_resp.status(), 200);
let returned = get_resp.bytes().await.unwrap();
assert_eq!(
returned.as_ref(),
bytes.as_slice(),
"GET must return original bytes"
);
println!("✅ Real image upload round-trip passed");
}