Files
buzz/crates/buzz-test-client/tests/e2e_media_video.rs
Will Pfleger f7ff4294fa feat(nip-fi): harden Blossom kind-24242 verifier to NIP-FI spec (#7288)
Brings `buzz-media`, `buzz-relay`, all first-party kind-24242 minters,
and desktop token minting into compliance with [NIP-FI
§kind-24242](https://github.com/block/buzz/blob/main/docs/nips/NIP-FI.md).

## What changed

**`buzz-media/src/auth.rs`**
- Add `BlossomStrictness` enum (`Strict` | `Permissive`). `Strict`
applies full NIP-FI rules (active modes); `Permissive` preserves
pre-NIP-FI Off-mode behavior byte-identical [FI-INV-15].
- Rewrite `verify_blossom_auth_event_for_verb` with count-based
cardinality: exactly one `t`/`expiration`/`server` in `Strict`;
duplicate `x` also rejected. `Permissive` keeps main's boolean semantics
exactly: any valued `t` other than the verb (including `""`) rejects,
valueless `t` is ignored.
- `Strict`: mandatory `server` tag on all proofs (upload + read); absent
or mismatched → `evidence_rejected` (`ServerMismatch`).
- `Strict`: 60-second proof window (`now - created_at <= 60s`,
`expiration <= created_at + 60s`). `Permissive`: 3600s / optional
server.
- **[F1]** Permissive server validation uses any-match semantics: a
proof with multiple `server` tags is accepted if any tag matches the
bound host. This restores origin/main base behavior. Strict
duplicate-rejection unchanged.
- **[F1 compat]** Permissive server gate uses `server_values.is_empty()`
(valued tags only) rather than `server_count > 0` (includes valueless
tags). A lone valueless `["server"]` tag is treated as absent in
Permissive/Off mode, matching pre-NIP-FI base behavior [FI-INV-15].
- **[F4]** Valueless `x` tags in Strict get reads are tracked before
`filter_map` so they cannot become absent scope (host-wide
authorization). Any `x` tag present in Strict must contain the exact
requested sha256.
- **[F3 unit]** Cases A–D in `auth.rs` are helper-level unit tests: they
call `verify_blossom_upload_auth` and `verify_upload_hash_only`
directly. They do not enter the upload pipeline
(`process_upload`/`process_video_upload`). Retained as helper evidence.

**`buzz-media/src/error.rs`**
- Add `DuplicateTag(&'static str)` variant.
- Add `blossom_denial_kind()` method: classifies each auth error as
`MissingEvidence`, `EvidenceRejected`, or `AuthorizationDenied`.
Non-auth errors return `None`.
- `IntoResponse` retains the legacy JSON 401 shape [FI-INV-15].

**`buzz-relay/src/api/media.rs`**
- Add `MediaDenial(MediaError, BlossomStrictness)` wrapper mapping to
byte-exact NIP-FI fixed responses in `Strict`; falls through to legacy
JSON in `Permissive`.
- `blossom_strictness_from_state()` selects `Strict` when
`state.config.nip_fi.is_enforce()`, otherwise `Permissive`. Strictness
is threaded through the `admit_nip_fi_http_on_state` NIP-98 closures on
upload and read, and through the post-admission X-SHA-256, hash-binding,
and membership denials.
- `extract_blossom_auth` keeps first-value `Authorization` extraction;
Enforce rejects repeated fields at the `admit_nip_fi_http` cardinality
gate, and Off keeps legacy behavior [FI-INV-15].
- Both membership-denial call sites use
`media_denial(RelayMembershipRequired, strictness)` so Strict mode
produces the correct fixed-text response.
- `upload_blob` serving-write fence conversion (`:406`) uses
`map_err(|e| media_denial(e, strictness))` — a community fence committed
between auth admission and lease acquisition no longer resets Strict to
Permissive.
- Mode-driven regressions in `mod postgres_tests` (selected by the
nextest `postgres-ci` profile via `test(/postgres_tests::/)`, `#[ignore
= "requires Postgres"]`), run through the full relay router with a real
Enforce verifier and assertion: Enforce rejects Permissive-only proofs
(300s lifetime, missing `server` tag) on both upload routes and reads
with `evidence rejected`; Off accepts the same proofs with pre-NIP-FI
responses; DenyProtected answers 503 before any proof check; membership
denials are `authorization denied` text/plain in Enforce and exact
legacy JSON 403 in Off, with a member positive control. GET and HEAD are
each discriminated (HEAD with a suppressed body). Both upload routes pin
the handler's `X-SHA-256` missing/malformed/unsigned and post-body
hash-mismatch denials in both modes. Off rejects an empty-valued `t`
beside a valid one with legacy 401 JSON in either order, and ignores a
valueless one. Exact status, Content-Type, challenge, and body bytes are
pinned.

**`buzz-media/src/upload.rs`** (`mod minio_tests`)
- **[F3 pipeline]** Four tests exercising the actual post-body call
sites (`process_upload` `:85`, `process_video_upload` `:413`) with real
MinIO I/O: buffered/streaming × expired-proof-accept +
mismatched-hash-reject. The positive cases use
`fresh_strict_upload_auth` (`created_at = now-58`, `expiration = now+2`,
lifetime = 60s) — Strict-admissible at sign time — followed by a real 3s
sleep so the proof is expired when the upload completes.
`nostr::Timestamp::now()` reads the OS wall clock directly; paused Tokio
time does not advance it. The negative cases use a pre-expired proof
with a mismatched `x` tag. All four are `#[ignore = "requires MinIO"]`;
no CI lane runs them automatically.

**`buzz-media/src/validation.rs`**
- `minimal_valid_mp4()` promoted to `#[cfg(test)] pub(crate)` — a
618-byte pre-computed H.264/avc1 fast-start MP4 available to all
`buzz-media` test modules; smoke-tested by
`test_minimal_valid_mp4_passes_validate_video_file`.

**`desktop/src-tauri/src/commands/media.rs`**
- `sign_blossom_upload_auth`: `server` tag mandatory; upload expiry 60s
(was 3600s/300s).
- `MEDIA_GET_AUTH_EXPIRY_SECS`: 60s (was 600s).

**`desktop/src-tauri/src/media_proxy.rs`**
- Single re-mint+retry on 401/403 for range requests; non-auth failures
do not retry.

**`crates/buzz-cli/src/client.rs`**
- Read expiry: 60s (was 600s). Upload expiry: 60s. `server` tag
mandatory on upload.

**`crates/buzz-dev-mcp/src/view_image.rs`**
- `MEDIA_GET_AUTH_EXPIRY_SECS`: 60s (was 600s). Test updated.

**`mobile/lib/shared/relay/media_auth.dart`**
- `_mediaGetAuthLifetimeSeconds`: 60 (was 600). Refresh margin updated.

**`mobile/lib/shared/relay/media_upload.dart`**
- `_uploadAuthLifetimeSeconds`: 60 (was 300). `server` tag mandatory.

**`mobile/lib/features/channels/media_viewer_page/video_viewer.dart`**
- **[F2]** Remove Android-specific `VideoPlayerController.networkUrl`
streaming path. `video_player_android` 2.9.5 freezes headers into static
`DefaultHttpDataSource` request properties — a 60s proof minted at
controller creation time becomes stale on seeks. All platforms use the
authenticated local-file download path.
- **[F2 transport]** `unawaited(request.sink.close())` before
`client.send()`. `AbortableStreamedRequest` carries no request body;
without closing the sink, `IOClient.send()` awaits
`stream.pipe(ioRequest)` which blocks until the sink ends — every video
download hung in loading indefinitely on every platform.
- **[F2 pending-ctrl]** `pendingController` ref set before the first
`await (initialize())`, cleared in all exit paths. Effect cleanup
disposes `pendingController.value` so a close-during-init releases the
native player even if the initialized event never arrives.
- **[F2r(a)]** On native init failure (post-create,
pre-initialized-event), the inner catch starts disposal detached via
`unawaited(localController.dispose().catchError(...))` — the
`.catchError` handler absorbs any disposal exception so it does not
reach the test error zone — then rethrows the original load error.
`video_player` 2.11.1 completes the init future with an error without
disposing the native player.
- **[F2r(b)]** Non-2xx error body cancelled via `_cancelVideoResponse()`
(subscribe+cancel) instead of `drain()`. `drain()` waits for the
upstream to close; a stalled server body blocked `initializeVideo`
indefinitely.
- **[F2r(d)]** `unawaited(localController.dispose().catchError(...))` in
the catch — not `await`. Two failure mechanisms exist: (1) when
`createWithOptions()` throws, `_creatingCompleter` is never completed
and `await dispose()` deadlocks at `:682-683`, blocking the outer catch
from setting error state. (2) when `createWithOptions()` succeeds but
initialization fails, `dispose()` runs but its thrown exception becomes
an unhandled async error reaching the test zone. Both are fixed:
`unawaited` lets the outer catch run immediately and set `error.value`;
`.catchError(...)` on the detached disposal future absorbs any disposal
exception.


**`mobile/test/features/channels/media_viewer_page/video_viewer_test.dart`**
- `_FinalizingFakeClient`: calls `request.finalize().drain()` — a test
without the sink fix times out because the drain never completes,
validating the transport contract.
- F2r(a): `forceInitError=true` fake emits `PlatformException` on
subscribe; `disposeCallCount >= 1` verifies disposal even on init
failure.
- F2r(b): `stalledBody` `StreamController` with `onCancel` Completer
(bounded completion signal, not a sleep). Asserts: (1)
`bodyStreamCancelled=true` while body stream is still open (impossible
with `drain()`); (2) error UI visible while body remains open. Restoring
`drain()` breaks both.
- F2r(c): `neverInitialize=true` fake never sends initialized event;
unmount asserts `disposeCallCount >= 1` via `pendingController`.
Distinct from the event-error case.
- F2r(d): `forceCreateError=true` fake throws `PlatformException` from
`createWithOptions()` itself (before any player ID is returned). Asserts
error UI visible within a bounded deadline. With the old `await
dispose()` the outer catch is blocked — `error.value` is never set and
the error UI assertion fails at the deadline; with
`unawaited(dispose())` the error text appears immediately.


**`mobile/test/features/channels/media_viewer_page/video_viewer_transport_test.dart`**
(separate file)
- Real-IO loopback probes using `flutter_test` (not `package:test`) to
avoid `depend_on_referenced_packages` lint. No
`TestWidgetsFlutterBinding` — no HttpClient override interfering with
`dart:io`.
- Probe 1: `HttpServer.bind(loopbackIPv4, 0)` reads the full request
body before replying. Without `sink.close()`, `req.drain()` hangs and
the test times out.
- Probe 2: server signals request arrival via a Completer (not a sleep);
abort fires after confirmed arrival; `client.send()` must throw
`RequestAbortedException` (typed assertion) within a bounded 5s
deadline.

**`scripts/test-video-upload.sh`**
- Proof minted with `+60` expiry and mandatory `server` tag.

**`buzz-test-client/tests/e2e_media*.rs`** +
**`buzz-relay/src/api/media.rs` test fixtures**
- All fixtures updated to 55s expiry + `server` tag.

**`RELEASING.md`**
- New "NIP-FI upload proof freshness (relay floor)" section documenting
that 60s proofs require the first relay release carrying this PR's
post-body hash-only check (the first tag > `v0.2.1` — update once cut)
for reliable large-file uploads. Operators on older self-hosted relays
(≤ `v0.2.1`) must upgrade before receiving these clients.

## Finding 3: 60s upload proofs vs old-relay compatibility

Desktop/CLI/mobile upload proofs are now 60s. Older relays re-run full
expiry verification after the body is transferred. A valid upload whose
body transfer takes >60s will fail on an old relay.

**Resolution (relay floor prerequisite):** `RELEASING.md` requires that
verified supported deployments (relay releases confirmed to carry the
post-body hash-only check, commit `75e9bef748`) be in place before
distributing 60s-proof client builds. Unknown or unverified self-hosted
relay deployments are excluded from distribution until they upgrade.
This makes the verified-deployment status a prerequisite for release
distribution, not a post-distribution advisory. The concrete tag (`>
v0.2.1`) is to be recorded once cut.

## Sequencing note

Builds on #7264 (merged): Blossom strictness is derived from its
`config.nip_fi` mode in this PR, so enabling `BUZZ_NIP_FI_MODE=enforce`
activates the strict verifier with no further wiring.

## GIF compatibility exception

An unauthenticated Off-mode request on a configured tenant with no GIF
provider changes from 404 to 401 (base `gifs.rs:269-275` checked an
unconfigured provider first; the current code authenticates first). All
other Off-mode Blossom responses are byte-identical to pre-NIP-FI
behavior.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-28 14:46:56 -04:00

694 lines
25 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 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())
}
/// 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(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 + 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. 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 + 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")
}
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();
}