Files
buzz/crates/buzz-media/src/error.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

422 lines
16 KiB
Rust

//! Media error types.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
/// Coarse Blossom denial kind for NIP-FI response shaping.
///
/// Callers that know the active [`crate::auth::BlossomStrictness`] use this to
/// choose the correct HTTP response shape — NIP-FI fixed text/plain in Strict
/// mode, legacy JSON in Permissive mode — without requiring `buzz-media` to
/// depend on `buzz-auth`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlossomDenialKind {
/// `Authorization` header was absent. Maps to HTTP 401 + `WWW-Authenticate: Nostr`.
MissingEvidence,
/// Authorization header or proof was present but structurally invalid,
/// malformed, expired, or otherwise rejected. Maps to HTTP 403.
EvidenceRejected,
/// A local-policy denial: relay membership required, community write fenced,
/// or similar authorization refusal that is not an auth-credential failure.
/// Maps to HTTP 403 with fixed `authorization denied\n` body [NIP-FI.md:764-775].
AuthorizationDenied,
}
/// Errors from media operations.
#[derive(Debug, thiserror::Error)]
pub enum MediaError {
#[error("unknown content type")]
UnknownContentType,
#[error("disallowed content type: {0}")]
DisallowedContentType(String),
#[error("file too large: {size} bytes (max {max})")]
FileTooLarge { size: u64, max: u64 },
#[error("image dimensions too large")]
ImageTooLarge,
#[error("invalid image data")]
InvalidImage,
#[error("media contains metadata or a non-canonical metadata channel")]
MetadataForbidden,
#[error("invalid signature")]
InvalidSignature,
#[error("invalid auth event kind")]
InvalidAuthKind,
#[error("invalid auth verb")]
InvalidAuthVerb,
#[error("missing required tag: {0}")]
MissingTag(&'static str),
/// A tag that must appear exactly once appeared more than once.
#[error("duplicate tag: {0}")]
DuplicateTag(&'static str),
#[error("hash mismatch")]
HashMismatch,
#[error("server mismatch")]
ServerMismatch,
#[error("token expired")]
TokenExpired,
#[error("timestamp out of window")]
TimestampOutOfWindow,
#[error("storage error: {0}")]
StorageError(String),
#[error("internal error")]
Internal,
#[error("not found")]
NotFound,
#[error("missing authorization header")]
MissingAuth,
#[error("invalid authorization scheme")]
InvalidAuthScheme,
#[error("invalid base64 encoding")]
InvalidBase64,
#[error("invalid auth event")]
InvalidAuthEvent,
#[error("unauthorized")]
Unauthorized,
#[error("insufficient scope")]
InsufficientScope,
#[error("relay membership required")]
RelayMembershipRequired,
#[error("community writes are fenced")]
CommunityWriteFenced,
#[error("media service temporarily unavailable")]
ServiceUnavailable,
#[error("token revoked")]
TokenRevoked,
#[error("pubkey mismatch")]
PubkeyMismatch,
#[error("upload rate limit exceeded")]
UploadRateLimitExceeded,
#[error("upload concurrency limit reached")]
UploadConcurrencyLimitReached,
/// A video/audio track does not use the canonical H.264/AAC codecs.
#[error("unsupported media codec: only H.264 video and AAC audio are accepted")]
WrongCodec,
/// Video duration exceeds the 600-second limit.
#[error("video too long: duration exceeds 600 seconds")]
DurationTooLong,
/// Video resolution exceeds the 2160 short-edge / 3840 long-edge envelope.
#[error(
"video resolution too high: maximum is 2160 on the short edge and 3840 on the long edge"
)]
ResolutionTooHigh,
/// MP4 moov atom appears after mdat — not fast-start.
#[error("moov atom not at front of file (not fast-start)")]
MoovNotAtFront,
/// Container is not MP4 (e.g. MOV, MKV).
#[error("unsupported container: only MP4 is accepted")]
UnsupportedContainer,
/// MP4 metadata could not be parsed.
#[error("invalid video data")]
InvalidVideo,
/// I/O error during streaming upload.
#[error("io error: {0}")]
Io(String),
}
impl From<image::ImageError> for MediaError {
fn from(_: image::ImageError) -> Self {
Self::InvalidImage
}
}
impl From<s3::error::S3Error> for MediaError {
fn from(e: s3::error::S3Error) -> Self {
Self::StorageError(e.to_string())
}
}
impl From<serde_json::Error> for MediaError {
fn from(e: serde_json::Error) -> Self {
Self::StorageError(e.to_string())
}
}
impl MediaError {
/// Classify this error as a Blossom denial kind for response-shape selection.
///
/// Returns `Some(BlossomDenialKind::MissingEvidence)` when the
/// `Authorization` header was absent, `Some(BlossomDenialKind::EvidenceRejected)`
/// for any structurally present but invalid/malformed/expired proof,
/// `Some(BlossomDenialKind::AuthorizationDenied)` for local-policy denials
/// (relay membership, community write fence), and `None` for non-auth errors.
///
/// Relay call sites that know the active `BlossomStrictness` use this to
/// select the appropriate response shape: NIP-FI fixed text/plain in Strict
/// mode, legacy JSON 401 in Permissive mode.
pub fn blossom_denial_kind(&self) -> Option<BlossomDenialKind> {
match self {
Self::MissingAuth => Some(BlossomDenialKind::MissingEvidence),
Self::InvalidAuthScheme
| Self::InvalidBase64
| Self::InvalidAuthEvent
| Self::InvalidAuthKind
| Self::InvalidAuthVerb
| Self::DuplicateTag(_)
| Self::InvalidSignature
| Self::TokenExpired
| Self::TimestampOutOfWindow
| Self::Unauthorized
| Self::TokenRevoked
| Self::PubkeyMismatch
| Self::HashMismatch
| Self::ServerMismatch
| Self::MissingTag(_) => Some(BlossomDenialKind::EvidenceRejected),
// Local-policy denials: identity proved, but access refused by relay policy.
// Maps to AuthorizationDenied per NIP-FI.md:764-775.
Self::RelayMembershipRequired | Self::CommunityWriteFenced => {
Some(BlossomDenialKind::AuthorizationDenied)
}
_ => None,
}
}
}
impl IntoResponse for MediaError {
fn into_response(self) -> Response {
let (status, msg) = match &self {
Self::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
Self::DisallowedContentType(_) => {
(StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string())
}
Self::FileTooLarge { .. } | Self::ImageTooLarge => {
(StatusCode::PAYLOAD_TOO_LARGE, self.to_string())
}
// All authentication failures return the same generic 401 to prevent oracle
// enumeration in the legacy/Permissive path [FI-INV-15]. Off-mode deployments
// preserve this pre-NIP-FI behavior. InsufficientScope is intentionally 403
// below — it is an authorization (not authentication) failure and is safe to
// distinguish because it requires a valid identity first.
//
// Strict mode routes through MediaDenial (buzz-relay) and applies the full
// NIP-FI rejection table (missing_evidence → 401, evidence_rejected → 403)
// independently of this path.
Self::MissingAuth
| Self::InvalidAuthScheme
| Self::InvalidBase64
| Self::InvalidAuthEvent
| Self::InvalidSignature
| Self::InvalidAuthKind
| Self::InvalidAuthVerb
| Self::DuplicateTag(_)
| Self::TokenExpired
| Self::TimestampOutOfWindow
| Self::Unauthorized
| Self::TokenRevoked
| Self::PubkeyMismatch
| Self::HashMismatch
| Self::ServerMismatch
| Self::MissingTag(_) => {
tracing::warn!(error = %self, "authentication failed");
(
StatusCode::UNAUTHORIZED,
"authentication failed".to_string(),
)
}
Self::InsufficientScope => (StatusCode::FORBIDDEN, self.to_string()),
Self::RelayMembershipRequired | Self::CommunityWriteFenced => {
(StatusCode::FORBIDDEN, self.to_string())
}
Self::ServiceUnavailable => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()),
Self::UploadRateLimitExceeded | Self::UploadConcurrencyLimitReached => {
(StatusCode::TOO_MANY_REQUESTS, self.to_string())
}
Self::UnknownContentType | Self::UnsupportedContainer | Self::WrongCodec => {
(StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string())
}
Self::DurationTooLong
| Self::ResolutionTooHigh
| Self::MoovNotAtFront
| Self::InvalidVideo
| Self::InvalidImage
| Self::MetadataForbidden => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
Self::Io(_) | Self::StorageError(_) | Self::Internal => {
tracing::error!(error = %self, "media storage error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
}
};
(status, axum::Json(serde_json::json!({"error": msg}))).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── BlossomDenialKind classification ─────────────────────────────────────
#[test]
fn missing_auth_is_missing_evidence() {
assert_eq!(
MediaError::MissingAuth.blossom_denial_kind(),
Some(BlossomDenialKind::MissingEvidence)
);
}
#[test]
fn structural_proof_errors_are_evidence_rejected() {
for error in [
MediaError::InvalidAuthScheme,
MediaError::InvalidBase64,
MediaError::InvalidAuthEvent,
MediaError::InvalidAuthKind,
MediaError::InvalidAuthVerb,
MediaError::DuplicateTag("Authorization"),
MediaError::InvalidSignature,
MediaError::TokenExpired,
MediaError::TimestampOutOfWindow,
MediaError::Unauthorized,
MediaError::TokenRevoked,
MediaError::PubkeyMismatch,
MediaError::HashMismatch,
MediaError::ServerMismatch,
MediaError::MissingTag("t"),
] {
assert_eq!(
error.blossom_denial_kind(),
Some(BlossomDenialKind::EvidenceRejected),
"expected EvidenceRejected for {error:?}"
);
}
}
#[test]
fn non_auth_errors_have_no_denial_kind() {
for error in [
MediaError::NotFound,
MediaError::FileTooLarge { size: 1, max: 0 },
MediaError::Internal,
MediaError::ServiceUnavailable,
MediaError::InsufficientScope,
] {
assert_eq!(
error.blossom_denial_kind(),
None,
"expected None for {error:?}"
);
}
}
#[test]
fn local_policy_denials_are_authorization_denied() {
for error in [
MediaError::RelayMembershipRequired,
MediaError::CommunityWriteFenced,
] {
assert_eq!(
error.blossom_denial_kind(),
Some(BlossomDenialKind::AuthorizationDenied),
"expected AuthorizationDenied for {error:?}"
);
}
}
// ── IntoResponse status code pins ──────────────────────────────────────
// These pins cover the legacy/Permissive path (MediaError::into_response).
// Strict mode overrides this via MediaDenial in buzz-relay [FI-INV-15].
#[tokio::test]
async fn all_auth_failures_return_json_401_in_permissive_path() {
// In the legacy/Permissive path all auth failures collapse to a single
// JSON 401 to prevent oracle enumeration [FI-INV-15]. Strict mode
// (MediaDenial in buzz-relay) applies the NIP-FI 401/403 split instead.
for error in [
MediaError::MissingAuth,
MediaError::InvalidAuthScheme,
MediaError::InvalidBase64,
MediaError::InvalidAuthEvent,
MediaError::InvalidAuthKind,
MediaError::InvalidAuthVerb,
MediaError::DuplicateTag("Authorization"),
MediaError::InvalidSignature,
MediaError::TokenExpired,
MediaError::TimestampOutOfWindow,
MediaError::Unauthorized,
MediaError::TokenRevoked,
MediaError::PubkeyMismatch,
MediaError::HashMismatch,
MediaError::ServerMismatch,
MediaError::MissingTag("t"),
] {
let label = format!("{error:?}");
let resp = error.into_response();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"Permissive path: expected 401 for {label}"
);
let ct = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
ct.contains("application/json"),
"expected JSON CT for {label}, got: {ct}"
);
// No WWW-Authenticate in the legacy JSON path.
assert!(
resp.headers().get("www-authenticate").is_none(),
"Permissive path must not include WWW-Authenticate for {label}"
);
// Exact body: legacy path emits {"error":"authentication failed"} [FI-INV-15].
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("body collect");
assert_eq!(
body.as_ref(),
br#"{"error":"authentication failed"}"#,
"Permissive path: wrong body for {label}"
);
}
}
// ── Existing non-auth response map tests ────────────────────────────────
#[test]
fn serving_backend_failures_map_to_5xx_but_fences_remain_403() {
for error in [
MediaError::ServiceUnavailable,
MediaError::Internal,
MediaError::StorageError("backend".to_string()),
] {
assert!(error.into_response().status().is_server_error());
}
assert_eq!(
MediaError::CommunityWriteFenced.into_response().status(),
StatusCode::FORBIDDEN
);
}
#[test]
fn unsupported_media_maps_to_415() {
for error in [
MediaError::UnknownContentType,
MediaError::DisallowedContentType("audio/mpeg".to_string()),
MediaError::UnsupportedContainer,
MediaError::WrongCodec,
] {
assert_eq!(
error.into_response().status(),
StatusCode::UNSUPPORTED_MEDIA_TYPE
);
}
}
#[test]
fn invalid_or_noncanonical_media_maps_to_422() {
for error in [
MediaError::InvalidImage,
MediaError::InvalidVideo,
MediaError::MetadataForbidden,
MediaError::MoovNotAtFront,
MediaError::DurationTooLong,
MediaError::ResolutionTooHigh,
] {
assert_eq!(
error.into_response().status(),
StatusCode::UNPROCESSABLE_ENTITY
);
}
}
}