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

820 lines
29 KiB
Rust

//! Extended media upload integration tests — auth edge cases, content validation,
//! multi-format uploads, WebSocket imeta validation.
//!
//! Run: cargo test -p buzz-test-client --test e2e_media_extended -- --ignored --nocapture
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};
use std::time::Duration;
fn relay_http_url() -> String {
std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
}
fn relay_ws_url() -> String {
relay_http_url()
.replace("http://", "ws://")
.replace("https://", "wss://")
}
/// Extract the host:port authority from the relay URL for use as the `server` tag.
fn relay_server_authority() -> String {
let url = relay_http_url();
url.trim_start_matches("https://")
.trim_start_matches("http://")
.split('/')
.next()
.unwrap_or("localhost:3000")
.to_string()
}
fn http_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(15))
.build()
.expect("http client")
}
fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
let now = Timestamp::now().as_secs();
let server = relay_server_authority();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", sha256]).unwrap(),
Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(),
Tag::parse(["server", &server]).unwrap(),
];
EventBuilder::new(Kind::from(24242), "Upload test")
.tags(tags)
.sign_with_keys(keys)
.unwrap()
}
/// Sign a kind:24242 Blossom *read* auth event. Reads are authenticated
/// unconditionally, so round-trip GETs must present one of these.
fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event {
let now = Timestamp::now().as_secs();
let server = relay_server_authority();
let tags = vec![
Tag::parse(["t", "get"]).unwrap(),
Tag::parse(["x", sha256]).unwrap(),
Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(),
Tag::parse(["server", &server]).unwrap(),
];
EventBuilder::new(Kind::from(24242), "Get test")
.tags(tags)
.sign_with_keys(keys)
.unwrap()
}
fn blossom_auth_header(event: &nostr::Event) -> String {
format!(
"Nostr {}",
URL_SAFE_NO_PAD.encode(event.as_json().as_bytes())
)
}
async fn upload(client: &Client, keys: &Keys, body: &[u8]) -> reqwest::Response {
upload_to_path(client, keys, "/upload", body).await
}
async fn upload_to_path(
client: &Client,
keys: &Keys,
path: &str,
body: &[u8],
) -> reqwest::Response {
let sha256 = hex::encode(Sha256::digest(body));
let auth = sign_blossom_auth(keys, &sha256);
client
.put(format!("{}{path}", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("X-SHA-256", &sha256)
.body(body.to_vec())
.send()
.await
.expect("upload request")
}
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,
]
}
fn tiny_png() -> Vec<u8> {
// Valid 2x2 red PNG generated by ffmpeg, with ffmpeg's pHYs chunk stripped:
// `validate_png_metadata_free` rejects pHYs as an identity channel, so the
// original fixture uploaded as 422 MetadataForbidden. IHDR/IDAT/IEND only.
vec![
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x08, 0x02, 0x00, 0x00, 0x00, 0xfd,
0xd4, 0x9a, 0x73, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xfc,
0xc3, 0x00, 0x02, 0x2c, 0x60, 0x92, 0x01, 0x00, 0x0d, 0x04, 0x01, 0x02, 0xbf, 0x50, 0x15,
0xb3, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
]
}
fn tiny_gif() -> Vec<u8> {
vec![
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a
0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, // LSD 1x1
0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, // GCT
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // Image Descriptor
0x02, 0x02, 0x4C, 0x01, 0x00, // Image Data
0x3B, // Trailer
]
}
fn tiny_webp() -> Vec<u8> {
// Valid 2x2 red lossy WebP generated by ffmpeg
vec![
0x52, 0x49, 0x46, 0x46, 0x3c, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38,
0x20, 0x30, 0x00, 0x00, 0x00, 0xd0, 0x01, 0x00, 0x9d, 0x01, 0x2a, 0x02, 0x00, 0x02, 0x00,
0x02, 0x00, 0x34, 0x25, 0xa0, 0x02, 0x74, 0xba, 0x01, 0xf8, 0x00, 0x03, 0xb0, 0x00, 0xfe,
0xf0, 0xe8, 0xf7, 0xff, 0x20, 0xb9, 0x61, 0x75, 0xc8, 0xd7, 0xff, 0x20, 0x3f, 0xe4, 0x07,
0xfc, 0x80, 0xff, 0xf8, 0xf2, 0x00, 0x00, 0x00,
]
}
fn sign_custom_auth(keys: &Keys, kind: u16, content: &str, tags: Vec<Tag>) -> nostr::Event {
EventBuilder::new(Kind::from(kind), content)
.tags(tags)
.sign_with_keys(keys)
.unwrap()
}
async fn upload_with_auth(
client: &Client,
auth_event: &nostr::Event,
sha256: &str,
body: &[u8],
) -> reqwest::Response {
client
.put(format!("{}/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(auth_event))
.header("X-SHA-256", sha256)
.body(body.to_vec())
.send()
.await
.expect("upload request")
}
#[tokio::test]
#[ignore]
async fn test_upload_png_roundtrip() {
let client = http_client();
let keys = Keys::generate();
let png = tiny_png();
let resp = upload(&client, &keys, &png).await;
assert_eq!(resp.status(), 200, "PNG upload should succeed");
let desc: serde_json::Value = resp.json().await.unwrap();
assert_eq!(desc["type"].as_str().unwrap(), "image/png");
assert!(desc["url"].as_str().unwrap().ends_with(".png"));
println!("✅ PNG upload: {}", desc["url"]);
// GET back — reads are authenticated, so scope a token to the uploaded hash.
let sha256 = desc["sha256"].as_str().expect("descriptor sha256");
let get = client
.get(desc["url"].as_str().unwrap())
.header(
"Authorization",
blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)),
)
.send()
.await
.unwrap();
assert_eq!(get.status(), 200);
assert_eq!(get.bytes().await.unwrap().as_ref(), png.as_slice());
println!("✅ PNG GET roundtrip verified");
}
#[tokio::test]
#[ignore]
async fn test_upload_gif_roundtrip() {
let client = http_client();
let keys = Keys::generate();
let gif = tiny_gif();
let resp = upload(&client, &keys, &gif).await;
assert_eq!(resp.status(), 200, "GIF upload should succeed");
let desc: serde_json::Value = resp.json().await.unwrap();
assert_eq!(desc["type"].as_str().unwrap(), "image/gif");
assert!(desc["url"].as_str().unwrap().ends_with(".gif"));
println!("✅ GIF upload: {}", desc["url"]);
let sha256 = desc["sha256"].as_str().expect("descriptor sha256");
let get = client
.get(desc["url"].as_str().unwrap())
.header(
"Authorization",
blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)),
)
.send()
.await
.unwrap();
assert_eq!(get.status(), 200);
assert_eq!(get.bytes().await.unwrap().as_ref(), gif.as_slice());
println!("✅ GIF GET roundtrip verified");
}
#[tokio::test]
#[ignore]
async fn test_upload_webp_roundtrip() {
let client = http_client();
let keys = Keys::generate();
let webp = tiny_webp();
let resp = upload(&client, &keys, &webp).await;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
println!("WebP upload → {status}: {body}");
assert_eq!(status, 200, "WebP upload should succeed");
let desc: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(desc["type"].as_str().unwrap(), "image/webp");
assert!(desc["url"].as_str().unwrap().ends_with(".webp"));
println!("✅ WebP upload: {}", desc["url"]);
}
#[tokio::test]
#[ignore]
async fn test_auth_wrong_kind() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let now = Timestamp::now().as_secs();
let auth = sign_custom_auth(
&keys,
27235,
"Upload test",
vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(),
],
);
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
assert_eq!(resp.status(), 401, "wrong kind must be 401");
println!("✅ Wrong kind → 401");
}
#[tokio::test]
#[ignore]
async fn test_auth_missing_t_tag() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let now = Timestamp::now().as_secs();
let auth = sign_custom_auth(
&keys,
24242,
"Upload test",
vec![
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(),
],
);
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
assert_eq!(resp.status(), 401, "missing t tag must be 401");
println!("✅ Missing t tag → 401");
}
#[tokio::test]
#[ignore]
async fn test_auth_missing_expiration() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let auth = sign_custom_auth(
&keys,
24242,
"Upload test",
vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
],
);
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
assert_eq!(resp.status(), 401, "missing expiration must be 401");
println!("✅ Missing expiration → 401");
}
#[tokio::test]
#[ignore]
async fn test_auth_expired_token() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let now = Timestamp::now().as_secs();
let auth = sign_custom_auth(
&keys,
24242,
"Upload test",
vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &(now - 60).to_string()]).unwrap(),
],
);
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
assert_eq!(resp.status(), 401, "expired token must be 401");
println!("✅ Expired token → 401");
}
#[tokio::test]
#[ignore]
async fn test_auth_empty_content() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let now = Timestamp::now().as_secs();
let auth = sign_custom_auth(
&keys,
24242,
"",
vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(),
],
);
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
assert_eq!(resp.status(), 401, "empty content must be 401");
println!("✅ Empty content → 401");
}
#[tokio::test]
#[ignore]
async fn test_auth_server_tag_mismatch() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let now = Timestamp::now().as_secs();
let auth = sign_custom_auth(
&keys,
24242,
"Upload test",
vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(),
Tag::parse(["server", "evil.example.com"]).unwrap(),
],
);
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
assert_eq!(resp.status(), 401, "server tag mismatch must be 401");
println!("✅ Server tag mismatch → 401");
}
#[tokio::test]
#[ignore]
async fn test_auth_server_tag_correct() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let now = Timestamp::now().as_secs();
let auth = sign_custom_auth(
&keys,
24242,
"Upload test",
vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(),
Tag::parse(["server", "localhost:3000"]).unwrap(),
],
);
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
assert_eq!(resp.status(), 200, "correct server tag must be 200");
println!("✅ Correct server tag → 200");
}
#[tokio::test]
#[ignore]
async fn test_upload_svg_accepted_as_text_xml() {
// SVG with XML declaration is detected by `infer` as text/xml (not image/svg+xml),
// which is not in the blocked list, so it routes through the generic file path.
let client = http_client();
let keys = Keys::generate();
let svg = b"<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>";
let resp = upload(&client, &keys, svg).await;
let status = resp.status().as_u16();
assert_eq!(
status, 200,
"SVG (undetected) should succeed via file path, got {status}"
);
let desc: serde_json::Value = resp.json().await.unwrap();
assert_eq!(desc["type"].as_str().unwrap(), "text/xml");
println!("✅ SVG (XML declaration) → 200 as text/xml");
}
#[tokio::test]
#[ignore]
async fn test_upload_html_served_as_inert_attachment() {
// HTML is accepted on the generic file path and MUST be served as an inert
// download: the security property the whole feature relies on is that the
// relay returns `Content-Disposition: attachment` + `X-Content-Type-Options:
// nosniff` + `Content-Security-Policy: default-src 'none'` so the payload can
// never execute or render as active content. This response-level regression
// pins that end to end (upload → GET), not just the deny-list membership.
let client = http_client();
let keys = Keys::generate();
// Exactly the shape `infer` classifies as text/html (leading recognised tag).
let html = b"<!DOCTYPE html><html><body><script>alert(1)</script></body></html>";
let resp = upload(&client, &keys, html).await;
let status = resp.status().as_u16();
assert_eq!(
status, 200,
"HTML should upload via file path, got {status}"
);
let desc: serde_json::Value = resp.json().await.unwrap();
assert_eq!(desc["type"].as_str().unwrap(), "text/html");
let url = desc["url"].as_str().unwrap();
assert!(
url.ends_with(".html"),
"served URL must carry the .html extension, got {url}"
);
let sha256 = desc["sha256"].as_str().unwrap();
let get_resp = client
.get(url)
.header(
"Authorization",
blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)),
)
.send()
.await
.expect("GET request");
assert_eq!(get_resp.status(), 200, "HTML GET roundtrip should succeed");
let header = |name: &str| {
get_resp
.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string()
};
assert_eq!(header("content-type"), "text/html");
assert_eq!(
header("content-disposition"),
"attachment",
"HTML must be forced to download, never rendered inline"
);
assert_eq!(
header("x-content-type-options"),
"nosniff",
"nosniff must prevent MIME re-sniffing to an executable type"
);
assert_eq!(
header("content-security-policy"),
"default-src 'none'",
"restrictive CSP must neutralise any active content"
);
println!("✅ HTML → 200, served as inert attachment (disposition+nosniff+CSP)");
}
#[tokio::test]
#[ignore]
async fn test_upload_pdf_accepted() {
// PDF is detected by `infer` and is not in the blocked list, so it
// routes through the generic file path successfully.
let client = http_client();
let keys = Keys::generate();
let pdf = b"%PDF-1.4 fake pdf content here for testing";
let resp = upload(&client, &keys, pdf).await;
let status = resp.status().as_u16();
assert_eq!(
status, 200,
"PDF should succeed via file path, got {status}"
);
let desc: serde_json::Value = resp.json().await.unwrap();
assert_eq!(desc["type"].as_str().unwrap(), "application/pdf");
println!("✅ PDF → 200");
}
#[tokio::test]
#[ignore]
async fn test_legacy_media_route_rejects_non_media() {
let client = http_client();
let keys = Keys::generate();
let pdf = b"%PDF-1.4 fake pdf content here for testing";
let resp = upload_to_path(&client, &keys, "/media/upload", pdf).await;
assert_eq!(
resp.status(),
reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE,
"legacy media route must not accept generic attachments"
);
}
#[tokio::test]
#[ignore]
async fn test_legacy_media_route_still_accepts_canonical_media() {
let client = http_client();
let keys = Keys::generate();
let resp = upload_to_path(&client, &keys, "/media/upload", &tiny_jpeg()).await;
assert_eq!(resp.status(), reqwest::StatusCode::OK);
}
#[tokio::test]
#[ignore]
async fn test_standard_upload_rejects_recognized_audio() {
let client = http_client();
let keys = Keys::generate();
let mp3 = b"ID3\x04\x00\x00\x00\x00\x00\x00";
let resp = upload(&client, &keys, mp3).await;
assert_eq!(
resp.status(),
reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE,
"recognized audio must not bypass the location policy as an attachment"
);
}
#[tokio::test]
#[ignore]
async fn test_upload_zero_bytes_accepted() {
// Empty body has no magic bytes — routes through the generic file path
// as application/octet-stream.
let client = http_client();
let keys = Keys::generate();
let resp = upload(&client, &keys, b"").await;
let status = resp.status().as_u16();
assert_eq!(
status, 200,
"zero bytes should succeed via file path, got {status}"
);
let desc: serde_json::Value = resp.json().await.unwrap();
assert_eq!(desc["type"].as_str().unwrap(), "application/octet-stream");
assert_eq!(desc["size"].as_u64().unwrap(), 0);
println!("✅ Zero bytes → 200 as octet-stream");
}
#[tokio::test]
#[ignore]
async fn test_upload_random_bytes_accepted() {
// Random bytes with no magic signature route through the generic file
// path as application/octet-stream.
let client = http_client();
let keys = Keys::generate();
let random: Vec<u8> = (0..1000).map(|i| (i * 37 % 256) as u8).collect();
let resp = upload(&client, &keys, &random).await;
let status = resp.status().as_u16();
assert_eq!(
status, 200,
"random bytes should succeed via file path, got {status}"
);
let desc: serde_json::Value = resp.json().await.unwrap();
assert_eq!(desc["type"].as_str().unwrap(), "application/octet-stream");
println!("✅ Random bytes → 200 as octet-stream");
}
#[tokio::test]
#[ignore]
async fn test_concurrent_upload_same_file() {
let client = http_client();
let jpeg = tiny_jpeg();
let keys1 = Keys::generate();
let keys2 = Keys::generate();
let c1 = client.clone();
let c2 = client.clone();
let j1 = jpeg.clone();
let j2 = jpeg.clone();
let (r1, r2) = tokio::join!(async { upload(&c1, &keys1, &j1).await }, async {
upload(&c2, &keys2, &j2).await
},);
assert_eq!(r1.status(), 200, "concurrent upload 1 must succeed");
assert_eq!(r2.status(), 200, "concurrent upload 2 must succeed");
let d1: serde_json::Value = r1.json().await.unwrap();
let d2: serde_json::Value = r2.json().await.unwrap();
assert_eq!(d1["sha256"], d2["sha256"], "same content = same hash");
assert_eq!(d1["url"], d2["url"], "same content = same URL");
println!("✅ Concurrent upload: both succeeded, same sha256/url");
}
#[tokio::test]
#[ignore]
async fn test_ws_valid_imeta() {
use buzz_test_client::BuzzTestClient;
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let http = http_client();
// Create channel via signed kind:9007 event
let channel_uuid = uuid::Uuid::new_v4();
let channel_name = format!("ws-imeta-test-{}", channel_uuid);
let create_event = EventBuilder::new(Kind::from(9007), "")
.tags(vec![
Tag::parse(["h", &channel_uuid.to_string()]).unwrap(),
Tag::parse(["name", &channel_name]).unwrap(),
Tag::parse(["channel_type", "stream"]).unwrap(),
Tag::parse(["visibility", "open"]).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let create_resp = http
.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!(create_resp.status().is_success(), "channel creation failed");
let channel_id = channel_uuid.to_string();
println!("Channel: {channel_id}");
// Upload a JPEG to get a valid sha256
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let resp = upload(&http, &keys, &jpeg).await;
assert_eq!(resp.status(), 200);
// Connect via WebSocket
let mut client = BuzzTestClient::connect(&relay_ws_url(), &keys)
.await
.unwrap();
// Send event with valid imeta
let event = EventBuilder::new(Kind::from(9), "image via ws")
.tags(vec![
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse([
"imeta",
&format!("url http://localhost:3000/media/{sha256}.jpg"),
"m image/jpeg",
&format!("x {sha256}"),
"size 347",
])
.unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let ok = client.send_event(event).await.unwrap();
assert!(
ok.accepted,
"valid imeta via WS must be accepted: {:?}",
ok.message
);
println!("✅ WS valid imeta accepted");
client.disconnect().await.unwrap();
}
#[tokio::test]
#[ignore]
async fn test_ws_invalid_imeta_external_url() {
use buzz_test_client::BuzzTestClient;
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let http = http_client();
let channel_uuid = uuid::Uuid::new_v4();
let channel_name = format!("ws-imeta-bad-{}", channel_uuid);
let create_event = EventBuilder::new(Kind::from(9007), "")
.tags(vec![
Tag::parse(["h", &channel_uuid.to_string()]).unwrap(),
Tag::parse(["name", &channel_name]).unwrap(),
Tag::parse(["channel_type", "stream"]).unwrap(),
Tag::parse(["visibility", "open"]).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let create_resp = http
.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!(create_resp.status().is_success(), "channel creation failed");
let channel_id = channel_uuid.to_string();
let sha = "a".repeat(64);
let mut client = BuzzTestClient::connect(&relay_ws_url(), &keys)
.await
.unwrap();
let event = EventBuilder::new(Kind::from(9), "bad imeta")
.tags(vec![
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse([
"imeta",
&format!("url https://evil.com/media/{sha}.jpg"),
"m image/jpeg",
&format!("x {sha}"),
"size 347",
])
.unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let ok = client.send_event(event).await.unwrap();
assert!(!ok.accepted, "external URL imeta via WS must be rejected");
assert!(
ok.message.contains("invalid"),
"rejection message must contain 'invalid': {:?}",
ok.message
);
println!(
"✅ WS invalid imeta (external URL) rejected: {:?}",
ok.message
);
client.disconnect().await.unwrap();
}
#[tokio::test]
#[ignore]
async fn test_ws_invalid_imeta_missing_fields() {
use buzz_test_client::BuzzTestClient;
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let http = http_client();
let channel_uuid = uuid::Uuid::new_v4();
let channel_name = format!("ws-imeta-miss-{}", channel_uuid);
let create_event = EventBuilder::new(Kind::from(9007), "")
.tags(vec![
Tag::parse(["h", &channel_uuid.to_string()]).unwrap(),
Tag::parse(["name", &channel_name]).unwrap(),
Tag::parse(["channel_type", "stream"]).unwrap(),
Tag::parse(["visibility", "open"]).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let create_resp = http
.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!(create_resp.status().is_success(), "channel creation failed");
let channel_id = channel_uuid.to_string();
let sha = "b".repeat(64);
let mut client = BuzzTestClient::connect(&relay_ws_url(), &keys)
.await
.unwrap();
// Only url, missing m/x/size
let event = EventBuilder::new(Kind::from(9), "incomplete imeta")
.tags(vec![
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse([
"imeta",
&format!("url http://localhost:3000/media/{sha}.jpg"),
])
.unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let ok = client.send_event(event).await.unwrap();
assert!(!ok.accepted, "incomplete imeta via WS must be rejected");
println!("✅ WS incomplete imeta rejected: {:?}", ok.message);
client.disconnect().await.unwrap();
}