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
Signed-off-by: cls_宁波本机 <908705107@qq.com>
679 lines
24 KiB
Rust
679 lines
24 KiB
Rust
//! 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!(""),
|
||
)
|
||
.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();
|
||
}
|