Files
buzz/crates/buzz-test-client/tests/e2e_media.rs
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

461 lines
17 KiB
Rust
Raw 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())
}
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 + 300).to_string();
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"),
];
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 + 300).to_string();
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"),
];
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");
}
/// 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");
}