Files
buzz/crates/buzz-test-client/tests/e2e_media_video.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

679 lines
24 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 video upload tests (Blossom protocol, MP4/H.264).
//!
//! 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_video -- --ignored --nocapture
//! ```
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
use reqwest::{Client, StatusCode};
use sha2::{Digest, Sha256};
use std::time::Duration;
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(30))
.build()
.expect("failed to build HTTP client")
}
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. Reads are authenticated
/// unconditionally, so blob and range GETs must present one of these -- without it
/// the 206 and 416 range behaviour below would never be reached.
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")
}
fn blossom_auth_header(event: &nostr::Event) -> String {
format!(
"Nostr {}",
URL_SAFE_NO_PAD.encode(event.as_json().as_bytes())
)
}
/// Build a minimal but structurally valid fast-start MP4 (H.264, 1s, 320×240).
///
/// Layout: ftyp | moov(mvhd + trak(tkhd + mdia(mdhd + hdlr + minf(vmhd + dinf + stbl)))) | mdat
/// This is enough for `infer` to detect video/mp4 and for the `mp4` crate to parse.
fn build_test_mp4() -> Vec<u8> {
fn box_wrap(fourcc: &[u8; 4], payload: &[u8]) -> Vec<u8> {
let size = (8 + payload.len()) as u32;
let mut b = Vec::new();
b.extend_from_slice(&size.to_be_bytes());
b.extend_from_slice(fourcc);
b.extend_from_slice(payload);
b
}
// ftyp
let ftyp = {
let mut b = Vec::new();
b.extend_from_slice(&20u32.to_be_bytes());
b.extend_from_slice(b"ftyp");
b.extend_from_slice(b"isom");
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(b"isom");
b
};
// mvhd (version 0, timescale=1000, duration=1000ms)
let mvhd_payload = {
let mut b = vec![0u8; 4]; // version=0, flags=0
b.extend_from_slice(&0u32.to_be_bytes()); // creation_time
b.extend_from_slice(&0u32.to_be_bytes()); // modification_time
b.extend_from_slice(&1000u32.to_be_bytes()); // timescale
b.extend_from_slice(&1000u32.to_be_bytes()); // duration
b.extend_from_slice(&0x00010000u32.to_be_bytes()); // rate
b.extend_from_slice(&0x0100u16.to_be_bytes()); // volume
b.extend_from_slice(&[0u8; 10]); // reserved
// identity matrix (9 × u32)
for &v in &[0x00010000u32, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000] {
b.extend_from_slice(&v.to_be_bytes());
}
b.extend_from_slice(&[0u8; 24]); // pre_defined
b.extend_from_slice(&2u32.to_be_bytes()); // next_track_id
b
};
let mvhd = box_wrap(b"mvhd", &mvhd_payload);
// tkhd
let tkhd_payload = {
let mut b = vec![0u8, 0, 0, 3]; // version=0, flags=3
b.extend_from_slice(&0u32.to_be_bytes()); // creation
b.extend_from_slice(&0u32.to_be_bytes()); // modification
b.extend_from_slice(&1u32.to_be_bytes()); // track_id
b.extend_from_slice(&0u32.to_be_bytes()); // reserved
b.extend_from_slice(&1000u32.to_be_bytes()); // duration
b.extend_from_slice(&[0u8; 8]); // reserved
b.extend_from_slice(&0i16.to_be_bytes()); // layer
b.extend_from_slice(&0i16.to_be_bytes()); // alternate_group
b.extend_from_slice(&0u16.to_be_bytes()); // volume
b.extend_from_slice(&0u16.to_be_bytes()); // reserved
for &v in &[0x00010000u32, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000] {
b.extend_from_slice(&v.to_be_bytes());
}
b.extend_from_slice(&(320u32 << 16).to_be_bytes()); // width 16.16
b.extend_from_slice(&(240u32 << 16).to_be_bytes()); // height 16.16
b
};
let tkhd = box_wrap(b"tkhd", &tkhd_payload);
// mdhd
let mdhd_payload = {
let mut b = vec![0u8; 4];
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&1000u32.to_be_bytes()); // timescale
b.extend_from_slice(&1000u32.to_be_bytes()); // duration
b.extend_from_slice(&0u16.to_be_bytes()); // language
b.extend_from_slice(&0u16.to_be_bytes()); // pre_defined
b
};
let mdhd = box_wrap(b"mdhd", &mdhd_payload);
// hdlr (video)
let hdlr_payload = {
let mut b = vec![0u8; 4];
b.extend_from_slice(&0u32.to_be_bytes()); // pre_defined
b.extend_from_slice(b"vide");
b.extend_from_slice(&[0u8; 12]); // reserved
b.extend_from_slice(b"VideoHandler\0");
b
};
let hdlr = box_wrap(b"hdlr", &hdlr_payload);
// vmhd
let vmhd_payload = {
let mut b = vec![0u8, 0, 0, 1]; // flags=1
b.extend_from_slice(&0u16.to_be_bytes());
b.extend_from_slice(&[0u8; 6]);
b
};
let vmhd = box_wrap(b"vmhd", &vmhd_payload);
// dinf -> dref -> url
let url_box = box_wrap(b"url ", &[0, 0, 0, 1]);
let dref_payload = {
let mut b = vec![0u8; 4];
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&url_box);
b
};
let dref = box_wrap(b"dref", &dref_payload);
let dinf = box_wrap(b"dinf", &dref);
// stsd -> avc1 (H.264)
let avc1_entry = {
let mut b = vec![0u8; 6]; // reserved
b.extend_from_slice(&1u16.to_be_bytes()); // data_ref_idx
b.extend_from_slice(&[0u8; 2]); // pre_defined
b.extend_from_slice(&[0u8; 2]); // reserved
b.extend_from_slice(&[0u8; 12]); // pre_defined
b.extend_from_slice(&320u16.to_be_bytes()); // width
b.extend_from_slice(&240u16.to_be_bytes()); // height
b.extend_from_slice(&0x00480000u32.to_be_bytes()); // horiz_res
b.extend_from_slice(&0x00480000u32.to_be_bytes()); // vert_res
b.extend_from_slice(&0u32.to_be_bytes()); // reserved
b.extend_from_slice(&1u16.to_be_bytes()); // frame_count
b.extend_from_slice(&[0u8; 32]); // compressorname
b.extend_from_slice(&0x0018u16.to_be_bytes()); // depth
b.extend_from_slice(&(-1i16).to_be_bytes()); // pre_defined
// avcC
let avcc = vec![
0x01, 0x42, 0x00, 0x1E, 0xFF, 0xE1, 0x00, 0x00, 0x01, 0x00, 0x00,
];
b.extend_from_slice(&box_wrap(b"avcC", &avcc));
b
};
let avc1 = box_wrap(b"avc1", &avc1_entry);
let stsd_payload = {
let mut b = vec![0u8; 4];
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&avc1);
b
};
let stsd = box_wrap(b"stsd", &stsd_payload);
// Minimal sample tables
let stts = box_wrap(b"stts", &{
let mut b = vec![0u8; 4];
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&1000u32.to_be_bytes());
b
});
let stsc = box_wrap(b"stsc", &{
let mut b = vec![0u8; 4];
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&1u32.to_be_bytes());
b
});
let stsz = box_wrap(b"stsz", &{
let mut b = vec![0u8; 4];
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b
});
let stco = box_wrap(b"stco", &{
let mut b = vec![0u8; 4];
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&28u32.to_be_bytes());
b
});
let stbl_payload = [&stsd[..], &stts, &stsc, &stsz, &stco].concat();
let stbl = box_wrap(b"stbl", &stbl_payload);
let minf_payload = [&vmhd[..], &dinf, &stbl].concat();
let minf = box_wrap(b"minf", &minf_payload);
let mdia_payload = [&mdhd[..], &hdlr, &minf].concat();
let mdia = box_wrap(b"mdia", &mdia_payload);
let trak_payload = [&tkhd[..], &mdia].concat();
let trak = box_wrap(b"trak", &trak_payload);
let moov_payload = [&mvhd[..], &trak].concat();
let moov = box_wrap(b"moov", &moov_payload);
let mdat = box_wrap(b"mdat", &[]);
[ftyp, moov, mdat].concat()
}
/// Upload a valid MP4 video via Blossom, verify the BlobDescriptor includes
/// video-specific fields (duration, dim) and the blob is retrievable.
#[tokio::test]
#[ignore]
async fn test_video_upload_and_get() {
let client = http_client();
let keys = Keys::generate();
let mp4 = build_test_mp4();
let sha256 = hex::encode(Sha256::digest(&mp4));
let auth = sign_blossom_auth(&keys, &sha256);
let url = format!("{}/upload", relay_http_url());
let resp = client
.put(&url)
.header("Authorization", blossom_auth_header(&auth))
.header("X-SHA-256", &sha256)
.header("Content-Type", "video/mp4")
.body(mp4.clone())
.send()
.await
.expect("upload request");
assert_eq!(resp.status(), StatusCode::OK, "upload should succeed");
let desc: serde_json::Value = resp.json().await.expect("json body");
assert_eq!(desc["sha256"].as_str().unwrap(), sha256);
assert_eq!(desc["type"].as_str().unwrap(), "video/mp4");
assert!(desc["size"].as_u64().unwrap() > 0);
// Video descriptor should have duration
assert!(
desc.get("duration").is_some(),
"video descriptor should include duration"
);
// GET the blob back
let get_url = desc["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 blob");
assert_eq!(get_resp.status(), StatusCode::OK);
let body = get_resp.bytes().await.expect("body bytes");
assert_eq!(body.len(), mp4.len());
}
/// The relay ignores the Content-Type header and sniffs magic bytes. MP4
/// uploaded with a spoofed image/jpeg header is detected as video/mp4 and
/// accepted through the streaming media validator, including on the legacy
/// compatibility route.
#[tokio::test]
#[ignore]
async fn test_video_content_type_header_ignored() {
let client = http_client();
let keys = Keys::generate();
let mp4 = build_test_mp4();
let sha256 = hex::encode(Sha256::digest(&mp4));
let auth = sign_blossom_auth(&keys, &sha256);
let url = format!("{}/media/upload", relay_http_url());
// Upload MP4 bytes but claim it's image/jpeg
let resp = client
.put(&url)
.header("Authorization", blossom_auth_header(&auth))
.header("X-SHA-256", &sha256)
.header("Content-Type", "image/jpeg")
.body(mp4)
.send()
.await
.expect("upload request");
assert_eq!(
resp.status().as_u16(),
200,
"MP4 with spoofed Content-Type should be accepted, got {}",
resp.status()
);
let desc: serde_json::Value = resp.json().await.unwrap();
assert_eq!(desc["type"].as_str().unwrap(), "video/mp4");
println!(
"✅ MP4 with spoofed Content-Type → 200 as video/mp4 (header ignored, magic bytes used)"
);
}
/// Range request on a video blob should return 206 Partial Content.
#[tokio::test]
#[ignore]
async fn test_video_range_request_206() {
let client = http_client();
let keys = Keys::generate();
let mp4 = build_test_mp4();
let sha256 = hex::encode(Sha256::digest(&mp4));
// Upload first
let auth = sign_blossom_auth(&keys, &sha256);
let url = format!("{}/media/upload", relay_http_url());
let resp = client
.put(&url)
.header("Authorization", blossom_auth_header(&auth))
.header("X-SHA-256", &sha256)
.header("Content-Type", "video/mp4")
.body(mp4.clone())
.send()
.await
.expect("upload");
assert_eq!(resp.status(), StatusCode::OK);
let desc: serde_json::Value = resp.json().await.unwrap();
let blob_url = desc["url"].as_str().unwrap();
// Range request: first 100 bytes
let range_resp = client
.get(blob_url)
.header(
"Authorization",
blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)),
)
.header("Range", "bytes=0-99")
.send()
.await
.expect("range GET");
assert_eq!(range_resp.status(), StatusCode::PARTIAL_CONTENT);
assert!(range_resp.headers().get("content-range").is_some());
assert!(range_resp
.headers()
.get("accept-ranges")
.is_some_and(|v| v == "bytes"));
let body = range_resp.bytes().await.unwrap();
assert_eq!(body.len(), 100);
assert_eq!(&body[..], &mp4[..100]);
}
/// Unsatisfiable range request should return 416.
#[tokio::test]
#[ignore]
async fn test_video_range_request_416() {
let client = http_client();
let keys = Keys::generate();
let mp4 = build_test_mp4();
let sha256 = hex::encode(Sha256::digest(&mp4));
// Upload first
let auth = sign_blossom_auth(&keys, &sha256);
let url = format!("{}/media/upload", relay_http_url());
let resp = client
.put(&url)
.header("Authorization", blossom_auth_header(&auth))
.header("X-SHA-256", &sha256)
.header("Content-Type", "video/mp4")
.body(mp4.clone())
.send()
.await
.expect("upload");
assert_eq!(resp.status(), StatusCode::OK);
let desc: serde_json::Value = resp.json().await.unwrap();
let blob_url = desc["url"].as_str().unwrap();
// Request a range beyond the file size
let range_resp = client
.get(blob_url)
.header(
"Authorization",
blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)),
)
.header(
"Range",
format!("bytes={}-{}", mp4.len() + 1000, mp4.len() + 2000),
)
.send()
.await
.expect("range GET");
assert_eq!(
range_resp.status(),
StatusCode::RANGE_NOT_SATISFIABLE,
"out-of-range request should return 416"
);
}
/// Upload without auth should return 401.
#[tokio::test]
#[ignore]
async fn test_video_upload_no_auth_returns_401() {
let client = http_client();
let mp4 = build_test_mp4();
let url = format!("{}/media/upload", relay_http_url());
let resp = client
.put(&url)
.header("Content-Type", "video/mp4")
.body(mp4)
.send()
.await
.expect("upload request");
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"upload without auth should return 401"
);
}
fn relay_ws_url() -> String {
relay_http_url()
.replace("http://", "ws://")
.replace("https://", "wss://")
}
/// Minimal valid JPEG (1x1 pixel) — used as a poster frame blob.
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 video + poster, then send a message with imeta `image` field
/// referencing the poster. The relay must accept the event.
#[tokio::test]
#[ignore]
async fn test_video_poster_imeta_accepted_via_ws() {
use buzz_test_client::BuzzTestClient;
let client = http_client();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
// 1. Create a channel
let channel_uuid = uuid::Uuid::new_v4();
let channel_id = channel_uuid.to_string();
let create_event = EventBuilder::new(Kind::from(9007), "")
.tags(vec![
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["name", &format!("video-poster-test-{channel_id}")]).unwrap(),
Tag::parse(["channel_type", "stream"]).unwrap(),
Tag::parse(["visibility", "open"]).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let resp = client
.post(format!("{}/events", relay_http_url()))
.header("X-Pubkey", &pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&create_event).unwrap())
.send()
.await
.unwrap();
assert!(resp.status().is_success(), "channel creation failed");
// 2. Upload video
let mp4 = build_test_mp4();
let video_sha = hex::encode(Sha256::digest(&mp4));
let video_auth = sign_blossom_auth(&keys, &video_sha);
let video_resp = client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&video_auth))
.header("X-SHA-256", &video_sha)
.header("Content-Type", "video/mp4")
.body(mp4.clone())
.send()
.await
.unwrap();
assert_eq!(video_resp.status(), StatusCode::OK, "video upload failed");
let video_desc: serde_json::Value = video_resp.json().await.unwrap();
let video_size = video_desc["size"].as_u64().unwrap();
// 3. Upload poster (tiny JPEG)
let poster = tiny_jpeg();
let poster_sha = hex::encode(Sha256::digest(&poster));
let poster_auth = sign_blossom_auth(&keys, &poster_sha);
let poster_resp = client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&poster_auth))
.header("X-SHA-256", &poster_sha)
.body(poster.to_vec())
.send()
.await
.unwrap();
assert_eq!(poster_resp.status(), StatusCode::OK, "poster upload failed");
// 4. Send message with imeta referencing both video and poster
let mut ws = BuzzTestClient::connect(&relay_ws_url(), &keys)
.await
.unwrap();
let base = relay_http_url();
let event = EventBuilder::new(
Kind::from(9),
format!("![video]({base}/media/{video_sha}.mp4)"),
)
.tags(vec![
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse([
"imeta",
&format!("url {base}/media/{video_sha}.mp4"),
"m video/mp4",
&format!("x {video_sha}"),
&format!("size {video_size}"),
&format!("image {base}/media/{poster_sha}.jpg"),
])
.unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let ok = ws.send_event(event).await.unwrap();
assert!(
ok.accepted,
"video+poster imeta must be accepted: {:?}",
ok.message
);
ws.disconnect().await.unwrap();
}
/// Send a message with imeta `image` pointing to the video URL (not an image).
/// The relay must reject this — poster must be an image file, not video.
#[tokio::test]
#[ignore]
async fn test_video_poster_imeta_rejects_video_as_poster() {
use buzz_test_client::BuzzTestClient;
let client = http_client();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
// 1. Create channel
let channel_uuid = uuid::Uuid::new_v4();
let channel_id = channel_uuid.to_string();
let create_event = EventBuilder::new(Kind::from(9007), "")
.tags(vec![
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["name", &format!("poster-reject-test-{channel_id}")]).unwrap(),
Tag::parse(["channel_type", "stream"]).unwrap(),
Tag::parse(["visibility", "open"]).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let resp = client
.post(format!("{}/events", relay_http_url()))
.header("X-Pubkey", &pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&create_event).unwrap())
.send()
.await
.unwrap();
assert!(resp.status().is_success());
// 2. Upload video
let mp4 = build_test_mp4();
let video_sha = hex::encode(Sha256::digest(&mp4));
let video_auth = sign_blossom_auth(&keys, &video_sha);
let video_resp = client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&video_auth))
.header("X-SHA-256", &video_sha)
.header("Content-Type", "video/mp4")
.body(mp4.clone())
.send()
.await
.unwrap();
assert_eq!(video_resp.status(), StatusCode::OK);
let video_desc: serde_json::Value = video_resp.json().await.unwrap();
let video_size = video_desc["size"].as_u64().unwrap();
// 3. Send message with imeta `image` pointing to the VIDEO (not an image)
let mut ws = BuzzTestClient::connect(&relay_ws_url(), &keys)
.await
.unwrap();
let base = relay_http_url();
let event = EventBuilder::new(Kind::from(9), "bad poster")
.tags(vec![
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse([
"imeta",
&format!("url {base}/media/{video_sha}.mp4"),
"m video/mp4",
&format!("x {video_sha}"),
&format!("size {video_size}"),
// BAD: image field points to the video itself (.mp4 extension)
&format!("image {base}/media/{video_sha}.mp4"),
])
.unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let ok = ws.send_event(event).await.unwrap();
assert!(
!ok.accepted,
"video URL as poster must be rejected, but was accepted"
);
ws.disconnect().await.unwrap();
}