mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
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>
476 lines
17 KiB
Rust
476 lines
17 KiB
Rust
//! End-to-end media upload tests (Blossom / NIP-96 style).
|
||
//!
|
||
//! Requires: relay running at localhost:3000, MinIO running at localhost:9000.
|
||
//! All tests are `#[ignore]` so they don't run in CI by default.
|
||
//!
|
||
//! # Running
|
||
//!
|
||
//! ```text
|
||
//! cargo test -p buzz-test-client --test e2e_media -- --ignored --nocapture
|
||
//! ```
|
||
//!
|
||
//! Override the relay URL:
|
||
//!
|
||
//! ```text
|
||
//! RELAY_HTTP_URL=http://localhost:3000 cargo test -p buzz-test-client --test e2e_media -- --ignored
|
||
//! ```
|
||
|
||
use std::time::Duration;
|
||
|
||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
|
||
use reqwest::Client;
|
||
use sha2::{Digest, Sha256};
|
||
|
||
fn relay_http_url() -> String {
|
||
std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
|
||
}
|
||
|
||
/// 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("failed to build HTTP client")
|
||
}
|
||
|
||
/// Sign a kind:24242 Blossom upload auth event for the given sha256.
|
||
fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
|
||
let now = Timestamp::now().as_secs();
|
||
let exp_str = (now + 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 for the given sha256.
|
||
///
|
||
/// Reads are authenticated unconditionally, so every successful GET/HEAD in this
|
||
/// file has to present one of these. The `x` tag is hash-scoped and covers the
|
||
/// derived paths too -- the relay matches on the sha256 before the extension, so
|
||
/// one token serves `{sha}.jpg` and `{sha}.thumb.jpg` alike.
|
||
fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event {
|
||
let now = Timestamp::now().as_secs();
|
||
let exp_str = (now + 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")
|
||
}
|
||
|
||
/// Build `Authorization: Nostr <base64url(json)>` header value.
|
||
fn blossom_auth_header(event: &nostr::Event) -> String {
|
||
format!(
|
||
"Nostr {}",
|
||
URL_SAFE_NO_PAD.encode(event.as_json().as_bytes())
|
||
)
|
||
}
|
||
|
||
/// A valid 1×1 red JPEG (339 bytes). Used for fast upload tests.
|
||
fn tiny_jpeg() -> Vec<u8> {
|
||
vec![
|
||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
|
||
0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06,
|
||
0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B,
|
||
0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
|
||
0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, 0x30, 0x31,
|
||
0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF,
|
||
0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00,
|
||
0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||
0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B,
|
||
0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05,
|
||
0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21,
|
||
0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08,
|
||
0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A,
|
||
0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37,
|
||
0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56,
|
||
0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
|
||
0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93,
|
||
0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9,
|
||
0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6,
|
||
0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
|
||
0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7,
|
||
0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x7B, 0x94,
|
||
0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||
0xFF, 0xD9,
|
||
]
|
||
}
|
||
|
||
/// Upload a tiny JPEG, then GET it back and verify the bytes match.
|
||
/// Also checks BlobDescriptor fields and thumbnail endpoint.
|
||
#[tokio::test]
|
||
#[ignore]
|
||
async fn test_upload_and_get() {
|
||
let client = http_client();
|
||
let keys = Keys::generate();
|
||
let jpeg = tiny_jpeg();
|
||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||
|
||
println!("sha256: {sha256}");
|
||
println!("relay: {}", relay_http_url());
|
||
|
||
// Standards-compliant BUD-02 upload route.
|
||
let auth = sign_blossom_auth(&keys, &sha256);
|
||
let resp = client
|
||
.put(format!("{}/upload", relay_http_url()))
|
||
.header("Authorization", blossom_auth_header(&auth))
|
||
.header("Content-Type", "image/jpeg")
|
||
.header("X-SHA-256", &sha256)
|
||
.body(jpeg.clone())
|
||
.send()
|
||
.await
|
||
.expect("upload PUT failed");
|
||
|
||
let status = resp.status();
|
||
let body_text = resp.text().await.unwrap_or_default();
|
||
println!("PUT /upload → {status}: {body_text}");
|
||
assert_eq!(status, 200, "upload should succeed");
|
||
|
||
// Parse BlobDescriptor
|
||
let descriptor: serde_json::Value =
|
||
serde_json::from_str(&body_text).expect("BlobDescriptor JSON");
|
||
println!("BlobDescriptor: {descriptor:#}");
|
||
|
||
assert_eq!(
|
||
descriptor["sha256"].as_str().unwrap(),
|
||
sha256,
|
||
"sha256 must match"
|
||
);
|
||
assert!(
|
||
descriptor["url"].as_str().unwrap().contains(&sha256),
|
||
"url must contain sha256"
|
||
);
|
||
assert!(
|
||
descriptor["size"].as_u64().unwrap() > 0,
|
||
"size must be positive"
|
||
);
|
||
assert!(
|
||
descriptor["type"].as_str().is_some(),
|
||
"mime type must be present"
|
||
);
|
||
// dim and blurhash are best-effort (image processing may not run on 1x1 JPEG)
|
||
println!(
|
||
"dim: {:?}, blurhash: {:?}",
|
||
descriptor["dim"], descriptor["blurhash"]
|
||
);
|
||
|
||
// Reads are authenticated, so mint one hash-scoped token for all three below.
|
||
let read_auth = blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256));
|
||
|
||
// GET /media/{sha256}.jpg — bytes must match
|
||
let get_url = format!("{}/media/{sha256}.jpg", relay_http_url());
|
||
let get_resp = client
|
||
.get(&get_url)
|
||
.header("Authorization", &read_auth)
|
||
.send()
|
||
.await
|
||
.expect("GET /media/{sha256}.jpg failed");
|
||
assert_eq!(get_resp.status(), 200, "GET should return 200");
|
||
let returned_bytes = get_resp.bytes().await.unwrap();
|
||
assert_eq!(
|
||
returned_bytes.as_ref(),
|
||
jpeg.as_slice(),
|
||
"GET must return original bytes"
|
||
);
|
||
|
||
// HEAD /media/{sha256}.jpg — must return 200 with content-type
|
||
let head_resp = client
|
||
.head(&get_url)
|
||
.header("Authorization", &read_auth)
|
||
.send()
|
||
.await
|
||
.expect("HEAD /media/{sha256}.jpg failed");
|
||
assert_eq!(head_resp.status(), 200, "HEAD should return 200");
|
||
assert!(
|
||
head_resp.headers().get("content-type").is_some(),
|
||
"HEAD must include content-type"
|
||
);
|
||
|
||
// GET thumbnail — /media/{sha256}.thumb.jpg
|
||
let thumb_url = format!("{}/media/{sha256}.thumb.jpg", relay_http_url());
|
||
let thumb_resp = client
|
||
.get(&thumb_url)
|
||
.header("Authorization", &read_auth)
|
||
.send()
|
||
.await
|
||
.expect("GET thumbnail failed");
|
||
println!("GET thumbnail → {}", thumb_resp.status());
|
||
// Thumbnail may be same as original for 1x1 images — just check 200
|
||
assert_eq!(thumb_resp.status(), 200, "thumbnail should return 200");
|
||
}
|
||
|
||
/// Idempotency: uploading the same file twice returns the same BlobDescriptor.
|
||
#[tokio::test]
|
||
#[ignore]
|
||
async fn test_upload_idempotent() {
|
||
let client = http_client();
|
||
let keys = Keys::generate();
|
||
let jpeg = tiny_jpeg();
|
||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||
|
||
let upload = |keys: &Keys| {
|
||
let auth = sign_blossom_auth(keys, &sha256);
|
||
client
|
||
.put(format!("{}/media/upload", relay_http_url()))
|
||
.header("Authorization", blossom_auth_header(&auth))
|
||
.header("Content-Type", "image/jpeg")
|
||
.header("X-SHA-256", sha256.clone())
|
||
.body(jpeg.clone())
|
||
.send()
|
||
};
|
||
|
||
let r1: serde_json::Value = upload(&keys)
|
||
.await
|
||
.expect("first upload failed")
|
||
.json()
|
||
.await
|
||
.expect("first descriptor parse");
|
||
|
||
// Second upload — different key, same content
|
||
let keys2 = Keys::generate();
|
||
let r2: serde_json::Value = upload(&keys2)
|
||
.await
|
||
.expect("second upload failed")
|
||
.json()
|
||
.await
|
||
.expect("second descriptor parse");
|
||
|
||
assert_eq!(
|
||
r1["sha256"], r2["sha256"],
|
||
"sha256 must be identical on re-upload"
|
||
);
|
||
assert_eq!(r1["url"], r2["url"], "url must be identical on re-upload");
|
||
}
|
||
|
||
/// Upload without an Authorization header must return 401.
|
||
#[tokio::test]
|
||
#[ignore]
|
||
async fn test_upload_no_auth_returns_401() {
|
||
let client = http_client();
|
||
let jpeg = tiny_jpeg();
|
||
|
||
let resp = client
|
||
.put(format!("{}/media/upload", relay_http_url()))
|
||
.header("Content-Type", "image/jpeg")
|
||
.body(jpeg)
|
||
.send()
|
||
.await
|
||
.expect("request failed");
|
||
|
||
println!("no-auth → {}", resp.status());
|
||
assert_eq!(resp.status(), 401, "upload without auth must be 401");
|
||
}
|
||
|
||
/// Upload without X-SHA-256 header must return 401 (BUD-11: mandatory).
|
||
#[tokio::test]
|
||
#[ignore]
|
||
async fn test_upload_missing_x_sha256_returns_401() {
|
||
let client = http_client();
|
||
let keys = Keys::generate();
|
||
let jpeg = tiny_jpeg();
|
||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||
|
||
let auth = sign_blossom_auth(&keys, &sha256);
|
||
let resp = client
|
||
.put(format!("{}/media/upload", relay_http_url()))
|
||
.header("Authorization", blossom_auth_header(&auth))
|
||
.header("Content-Type", "image/jpeg")
|
||
// Intentionally omit X-SHA-256
|
||
.body(jpeg)
|
||
.send()
|
||
.await
|
||
.expect("request failed");
|
||
|
||
println!("missing-x-sha256 → {}", resp.status());
|
||
assert_eq!(resp.status(), 401, "upload without X-SHA-256 must be 401");
|
||
}
|
||
|
||
/// Upload where the `x` tag sha256 doesn't match the actual body must return 401.
|
||
#[tokio::test]
|
||
#[ignore]
|
||
async fn test_upload_hash_mismatch_returns_400() {
|
||
let client = http_client();
|
||
let keys = Keys::generate();
|
||
let jpeg = tiny_jpeg();
|
||
let wrong_hash = "f".repeat(64); // definitely not the real sha256
|
||
|
||
let auth = sign_blossom_auth(&keys, &wrong_hash);
|
||
let resp = client
|
||
.put(format!("{}/media/upload", relay_http_url()))
|
||
.header("Authorization", blossom_auth_header(&auth))
|
||
.header("Content-Type", "image/jpeg")
|
||
.header("X-SHA-256", &wrong_hash)
|
||
.body(jpeg)
|
||
.send()
|
||
.await
|
||
.expect("request failed");
|
||
|
||
println!("hash-mismatch → {}", resp.status());
|
||
assert_eq!(resp.status(), 401, "hash mismatch must be 401");
|
||
}
|
||
|
||
/// GET an authenticated sha256 that was never uploaded must return 404.
|
||
///
|
||
/// The token has to be valid for the 404 to be reachable at all: authentication
|
||
/// runs before the storage lookup, so a bare request is rejected with 401 and
|
||
/// never distinguishes "missing" from "unauthorized" (see
|
||
/// `test_unauthenticated_reads_are_rejected`).
|
||
#[tokio::test]
|
||
#[ignore]
|
||
async fn test_get_nonexistent_returns_404() {
|
||
let client = http_client();
|
||
let keys = Keys::generate();
|
||
let missing_sha256 = "0".repeat(64);
|
||
let url = format!("{}/media/{missing_sha256}.jpg", relay_http_url());
|
||
|
||
let resp = client
|
||
.get(&url)
|
||
.header(
|
||
"Authorization",
|
||
blossom_auth_header(&sign_blossom_get_auth(&keys, &missing_sha256)),
|
||
)
|
||
.send()
|
||
.await
|
||
.expect("GET failed");
|
||
println!("missing blob → {}", resp.status());
|
||
assert_eq!(resp.status(), 404, "missing blob must be 404");
|
||
}
|
||
|
||
/// Bare reads are rejected with 401 before any storage lookup.
|
||
///
|
||
/// This is the boundary PR #4610 made unconditional: there is no longer a config
|
||
/// flag that lets an unauthenticated GET through, so the acceptance lane has to
|
||
/// assert the rejection directly. Uses a never-uploaded hash deliberately -- a 401
|
||
/// here rather than a 404 proves auth runs ahead of the storage lookup and that the
|
||
/// endpoint does not leak blob existence to an unauthenticated caller.
|
||
#[tokio::test]
|
||
#[ignore]
|
||
async fn test_unauthenticated_reads_are_rejected() {
|
||
let client = http_client();
|
||
let missing_sha256 = "0".repeat(64);
|
||
let blob_url = format!("{}/media/{missing_sha256}.jpg", relay_http_url());
|
||
let thumb_url = format!("{}/media/{missing_sha256}.thumb.jpg", relay_http_url());
|
||
|
||
let get_resp = client.get(&blob_url).send().await.expect("bare GET failed");
|
||
println!("bare GET → {}", get_resp.status());
|
||
assert_eq!(get_resp.status(), 401, "bare GET must be 401");
|
||
|
||
let head_resp = client
|
||
.head(&blob_url)
|
||
.send()
|
||
.await
|
||
.expect("bare HEAD failed");
|
||
println!("bare HEAD → {}", head_resp.status());
|
||
assert_eq!(head_resp.status(), 401, "bare HEAD must be 401");
|
||
|
||
let thumb_resp = client
|
||
.get(&thumb_url)
|
||
.send()
|
||
.await
|
||
.expect("bare thumbnail GET failed");
|
||
println!("bare thumbnail GET → {}", thumb_resp.status());
|
||
assert_eq!(thumb_resp.status(), 401, "bare thumbnail GET must be 401");
|
||
}
|
||
|
||
/// Upload a real image from the filesystem (set TEST_IMAGE_PATH env var).
|
||
/// Verifies the full round-trip: upload → BlobDescriptor → GET bytes match.
|
||
#[tokio::test]
|
||
#[ignore]
|
||
async fn test_upload_real_image() {
|
||
let image_path = match std::env::var("TEST_IMAGE_PATH") {
|
||
Ok(p) => p,
|
||
Err(_) => {
|
||
println!("Skipping: TEST_IMAGE_PATH not set");
|
||
return;
|
||
}
|
||
};
|
||
|
||
let client = http_client();
|
||
let keys = Keys::generate();
|
||
let bytes = std::fs::read(&image_path).expect("read image file");
|
||
let sha256 = hex::encode(Sha256::digest(&bytes));
|
||
let size = bytes.len();
|
||
|
||
println!("image: {image_path}");
|
||
println!("size: {size} bytes");
|
||
println!("sha256: {sha256}");
|
||
|
||
let auth = sign_blossom_auth(&keys, &sha256);
|
||
let resp = client
|
||
.put(format!("{}/media/upload", relay_http_url()))
|
||
.header("Authorization", blossom_auth_header(&auth))
|
||
.header("Content-Type", "image/jpeg")
|
||
.header("X-SHA-256", &sha256)
|
||
.body(bytes.clone())
|
||
.send()
|
||
.await
|
||
.expect("upload PUT failed");
|
||
|
||
let status = resp.status();
|
||
let body_text = resp.text().await.unwrap_or_default();
|
||
println!("PUT /media/upload → {status}: {body_text}");
|
||
assert_eq!(status, 200, "upload should succeed");
|
||
|
||
let descriptor: serde_json::Value =
|
||
serde_json::from_str(&body_text).expect("BlobDescriptor JSON");
|
||
println!("BlobDescriptor: {descriptor:#}");
|
||
|
||
assert_eq!(descriptor["sha256"].as_str().unwrap(), sha256);
|
||
assert_eq!(descriptor["size"].as_u64().unwrap(), size as u64);
|
||
assert!(descriptor["url"].as_str().unwrap().contains(&sha256));
|
||
assert!(
|
||
descriptor["dim"].as_str().is_some(),
|
||
"real image should have dim"
|
||
);
|
||
assert!(
|
||
descriptor["blurhash"].as_str().is_some(),
|
||
"real image should have blurhash"
|
||
);
|
||
|
||
// GET bytes back and verify
|
||
let get_url = descriptor["url"].as_str().unwrap();
|
||
let get_resp = client
|
||
.get(get_url)
|
||
.header(
|
||
"Authorization",
|
||
blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)),
|
||
)
|
||
.send()
|
||
.await
|
||
.expect("GET failed");
|
||
assert_eq!(get_resp.status(), 200);
|
||
let returned = get_resp.bytes().await.unwrap();
|
||
assert_eq!(
|
||
returned.as_ref(),
|
||
bytes.as_slice(),
|
||
"GET must return original bytes"
|
||
);
|
||
|
||
println!("✅ Real image upload round-trip passed");
|
||
}
|