mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +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>
422 lines
16 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|
|
}
|