Files
buzz/scripts/test-video-upload.sh
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

381 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# test-video-upload.sh — Live validation of the Blossom video upload flow.
#
# Prerequisites:
# - Relay running at $RELAY_URL (default: http://localhost:3000)
# - Dev mode (BUZZ_REQUIRE_AUTH_TOKEN=false) or valid API token
# - ffmpeg, nak, curl, jq, shasum on PATH
#
# Usage:
# ./scripts/test-video-upload.sh # run all tests
# RELAY_URL=http://host:3000 ./scripts/... # custom relay URL
# NSEC=nsec1... ./scripts/... # use existing key
set -euo pipefail
RELAY_URL="${RELAY_URL:-http://localhost:3000}"
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT
PASS=0
FAIL=0
pass() { echo " ✅ $1"; PASS=$((PASS + 1)); }
fail() { echo " ❌ $1"; FAIL=$((FAIL + 1)); }
# ── Dependencies ───────────────────────────────────────────────────────────────
for cmd in ffmpeg nak curl jq shasum; do
command -v "$cmd" >/dev/null 2>&1 || { echo "Missing: $cmd"; exit 1; }
done
# ── Key generation ─────────────────────────────────────────────────────────────
if [ -z "${NSEC:-}" ]; then
NSEC="$(nak key generate)"
echo "Generated key: $(echo "$NSEC" | nak key public)"
fi
NPUB="$(echo "$NSEC" | nak key public)"
echo "Using pubkey: $NPUB"
echo "Relay: $RELAY_URL"
echo ""
# ── Generate test MP4 ─────────────────────────────────────────────────────────
# Minimal 1-second H.264 video with moov at front (faststart).
TEST_MP4="$TMPDIR/test.mp4"
ffmpeg -y -f lavfi -i "color=c=blue:s=320x240:d=1" \
-c:v libx264 -profile:v baseline -pix_fmt yuv420p \
-movflags +faststart \
"$TEST_MP4" 2>/dev/null
FILE_SIZE=$(wc -c < "$TEST_MP4" | tr -d ' ')
SHA256=$(shasum -a 256 "$TEST_MP4" | cut -d' ' -f1)
echo "Test MP4: ${FILE_SIZE} bytes, sha256=${SHA256:0:16}..."
echo ""
# ── Helper: build Blossom auth header ──────────────────────────────────────────
# Creates a kind:24242 event with t=upload, x=<sha256>, expiration=+60s, server=<relay>.
blossom_auth() {
local sha256="$1"
local now exp server auth_event auth_b64
now=$(date +%s)
exp=$((now + 60))
server=$(echo "$RELAY_URL" | sed -E 's|^https?://||' | cut -d'/' -f1)
auth_event=$(nak event \
--sec "$NSEC" \
-k 24242 \
-c "Upload test video" \
-t t=upload \
-t "x=$sha256" \
-t "expiration=$exp" \
-t "server=$server" \
2>/dev/null)
auth_b64=$(echo -n "$auth_event" | base64 | tr -d '\n')
echo "Nostr $auth_b64"
}
# ── Test 1: Upload MP4 ────────────────────────────────────────────────────────
echo "Test 1: Upload MP4 via PUT /media/upload"
AUTH="$(blossom_auth "$SHA256")"
UPLOAD_RESP=$(curl -s -w "\n%{http_code}" \
-X PUT "$RELAY_URL/media/upload" \
-H "Authorization: $AUTH" \
-H "Content-Type: video/mp4" \
-H "X-SHA-256: $SHA256" \
--data-binary "@$TEST_MP4")
UPLOAD_HTTP=$(echo "$UPLOAD_RESP" | tail -1)
UPLOAD_BODY=$(echo "$UPLOAD_RESP" | sed '$d')
if [ "$UPLOAD_HTTP" = "200" ]; then
pass "Upload returned 200"
BLOB_URL=$(echo "$UPLOAD_BODY" | jq -r '.url // empty')
if [ -n "$BLOB_URL" ]; then
pass "Response contains url: ${BLOB_URL:0:60}..."
else
fail "Response missing url field"
fi
# Check duration field present
DURATION=$(echo "$UPLOAD_BODY" | jq -r '.duration // empty')
if [ -n "$DURATION" ]; then
pass "Response contains duration: ${DURATION}s"
else
fail "Response missing duration field"
fi
else
fail "Upload returned $UPLOAD_HTTP (expected 200)"
echo " Body: $UPLOAD_BODY"
fi
echo ""
# ── Test 2: GET full blob ─────────────────────────────────────────────────────
echo "Test 2: GET /media/${SHA256}.mp4 (full download)"
GET_RESP=$(curl -s -o "$TMPDIR/downloaded.mp4" -w "%{http_code}" \
"$RELAY_URL/media/${SHA256}.mp4")
if [ "$GET_RESP" = "200" ]; then
pass "GET returned 200"
DL_SIZE=$(wc -c < "$TMPDIR/downloaded.mp4" | tr -d ' ')
if [ "$DL_SIZE" = "$FILE_SIZE" ]; then
pass "Downloaded size matches ($DL_SIZE bytes)"
else
fail "Size mismatch: expected $FILE_SIZE, got $DL_SIZE"
fi
else
fail "GET returned $GET_RESP (expected 200)"
fi
echo ""
# ── Test 3: HEAD with Accept-Ranges ──────────────────────────────────────────
echo "Test 3: HEAD /media/${SHA256}.mp4 (Accept-Ranges)"
HEAD_RESP=$(curl -s -I "$RELAY_URL/media/${SHA256}.mp4")
HEAD_HTTP=$(echo "$HEAD_RESP" | head -1 | grep -o '[0-9]\{3\}')
ACCEPT_RANGES=$(echo "$HEAD_RESP" | grep -i "accept-ranges" | tr -d '\r')
if [ "$HEAD_HTTP" = "200" ]; then
pass "HEAD returned 200"
else
fail "HEAD returned $HEAD_HTTP (expected 200)"
fi
if echo "$ACCEPT_RANGES" | grep -qi "bytes"; then
pass "Accept-Ranges: bytes present"
else
fail "Accept-Ranges header missing or wrong: '$ACCEPT_RANGES'"
fi
echo ""
# ── Test 4: Range GET (206 Partial Content) ──────────────────────────────────
echo "Test 4: Range GET bytes=0-499 (206 Partial Content)"
RANGE_RESP=$(curl -s -o "$TMPDIR/range.bin" -w "%{http_code}" \
-H "Range: bytes=0-499" \
"$RELAY_URL/media/${SHA256}.mp4")
if [ "$RANGE_RESP" = "206" ]; then
pass "Range GET returned 206"
RANGE_SIZE=$(wc -c < "$TMPDIR/range.bin" | tr -d ' ')
if [ "$RANGE_SIZE" = "500" ]; then
pass "Received exactly 500 bytes"
else
fail "Expected 500 bytes, got $RANGE_SIZE"
fi
else
fail "Range GET returned $RANGE_RESP (expected 206)"
fi
echo ""
# ── Test 5: Range GET past EOF (416) ─────────────────────────────────────────
echo "Test 5: Range GET bytes=999999999- (416 Range Not Satisfiable)"
RANGE416_RESP=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Range: bytes=999999999-" \
"$RELAY_URL/media/${SHA256}.mp4")
if [ "$RANGE416_RESP" = "416" ]; then
pass "Past-EOF range returned 416"
else
fail "Past-EOF range returned $RANGE416_RESP (expected 416)"
fi
echo ""
# ── Test 6: Content-Type spoofing rejection ──────────────────────────────────
# Send video/mp4 Content-Type but with a PNG body — should be rejected.
echo "Test 6: Content-Type spoofing (video/mp4 header, PNG body)"
PNG_FILE="$TMPDIR/fake.png"
# Minimal valid PNG (1x1 red pixel)
printf '\x89PNG\r\n\x1a\n' > "$PNG_FILE"
dd if=/dev/zero bs=100 count=1 >> "$PNG_FILE" 2>/dev/null
PNG_SHA=$(shasum -a 256 "$PNG_FILE" | cut -d' ' -f1)
SPOOF_AUTH="$(blossom_auth "$PNG_SHA")"
SPOOF_RESP=$(curl -s -w "\n%{http_code}" \
-X PUT "$RELAY_URL/media/upload" \
-H "Authorization: $SPOOF_AUTH" \
-H "Content-Type: video/mp4" \
-H "X-SHA-256: $PNG_SHA" \
--data-binary "@$PNG_FILE")
SPOOF_HTTP=$(echo "$SPOOF_RESP" | tail -1)
if [ "$SPOOF_HTTP" = "415" ] || [ "$SPOOF_HTTP" = "400" ]; then
pass "Spoofed upload rejected with $SPOOF_HTTP"
else
fail "Spoofed upload returned $SPOOF_HTTP (expected 400 or 415)"
fi
echo ""
# ── Test 7: Idempotent re-upload ─────────────────────────────────────────────
echo "Test 7: Idempotent re-upload (same file, same hash)"
REUP_AUTH="$(blossom_auth "$SHA256")"
REUP_RESP=$(curl -s -w "\n%{http_code}" \
-X PUT "$RELAY_URL/media/upload" \
-H "Authorization: $REUP_AUTH" \
-H "Content-Type: video/mp4" \
-H "X-SHA-256: $SHA256" \
--data-binary "@$TEST_MP4")
REUP_HTTP=$(echo "$REUP_RESP" | tail -1)
if [ "$REUP_HTTP" = "200" ]; then
pass "Re-upload returned 200 (idempotent)"
else
fail "Re-upload returned $REUP_HTTP (expected 200)"
fi
echo ""
# ── Test 8: Poster frame upload + imeta validation ───────────────────────────
# Upload a JPEG poster frame, then verify the server accepts an imeta tag
# that links the video and poster via the NIP-71 `image` field.
echo "Test 8: Upload poster frame (JPEG image)"
POSTER_JPG="$TMPDIR/poster.jpg"
ffmpeg -y -ss 0.5 -i "$TEST_MP4" -vframes 1 -vf "scale=640:-2" -q:v 2 \
"$POSTER_JPG" 2>/dev/null
if [ ! -s "$POSTER_JPG" ]; then
# Fallback: first frame (video may be too short for 0.5s seek)
ffmpeg -y -i "$TEST_MP4" -vframes 1 -vf "scale=640:-2" -q:v 2 \
"$POSTER_JPG" 2>/dev/null
fi
POSTER_SIZE=$(wc -c < "$POSTER_JPG" | tr -d ' ')
POSTER_SHA=$(shasum -a 256 "$POSTER_JPG" | cut -d' ' -f1)
echo " Poster: ${POSTER_SIZE} bytes, sha256=${POSTER_SHA:0:16}..."
POSTER_AUTH="$(blossom_auth "$POSTER_SHA")"
POSTER_RESP=$(curl -s -w "\n%{http_code}" \
-X PUT "$RELAY_URL/media/upload" \
-H "Authorization: $POSTER_AUTH" \
-H "Content-Type: image/jpeg" \
-H "X-SHA-256: $POSTER_SHA" \
--data-binary "@$POSTER_JPG")
POSTER_HTTP=$(echo "$POSTER_RESP" | tail -1)
POSTER_BODY=$(echo "$POSTER_RESP" | sed '$d')
if [ "$POSTER_HTTP" = "200" ]; then
pass "Poster upload returned 200"
POSTER_URL=$(echo "$POSTER_BODY" | jq -r '.url // empty')
if [ -n "$POSTER_URL" ]; then
pass "Poster has url: ${POSTER_URL:0:60}..."
else
fail "Poster response missing url"
fi
# Poster should have dim but NOT duration
POSTER_DIM=$(echo "$POSTER_BODY" | jq -r '.dim // empty')
POSTER_DUR=$(echo "$POSTER_BODY" | jq -r '.duration // empty')
if [ -n "$POSTER_DIM" ]; then
pass "Poster has dim: $POSTER_DIM"
else
fail "Poster missing dim"
fi
if [ -z "$POSTER_DUR" ]; then
pass "Poster correctly omits duration"
else
fail "Poster should not have duration, got: $POSTER_DUR"
fi
else
fail "Poster upload returned $POSTER_HTTP (expected 200)"
echo " Body: $POSTER_BODY"
fi
echo ""
# ── Test 9: GET poster frame ─────────────────────────────────────────────────
echo "Test 9: GET poster frame"
POSTER_GET_RESP=$(curl -s -o "$TMPDIR/poster_dl.jpg" -w "%{http_code}" \
"$RELAY_URL/media/${POSTER_SHA}.jpg")
if [ "$POSTER_GET_RESP" = "200" ]; then
pass "GET poster returned 200"
POSTER_DL_SIZE=$(wc -c < "$TMPDIR/poster_dl.jpg" | tr -d ' ')
if [ "$POSTER_DL_SIZE" = "$POSTER_SIZE" ]; then
pass "Poster download size matches ($POSTER_DL_SIZE bytes)"
else
fail "Poster size mismatch: expected $POSTER_SIZE, got $POSTER_DL_SIZE"
fi
else
fail "GET poster returned $POSTER_GET_RESP (expected 200)"
fi
echo ""
# ── Test 10: Video + poster blobs coexist and are independently retrievable ──
# The server links video and poster purely through the imeta tag at message
# send time. Here we verify the prerequisite: both blobs exist, have correct
# Content-Types, and are independently addressable.
echo "Test 10: Video + poster blobs coexist"
VIDEO_HEAD=$(curl -s -o /dev/null -w "%{http_code}" -I "$RELAY_URL/media/${SHA256}.mp4")
POSTER_HEAD=$(curl -s -o /dev/null -w "%{http_code}" -I "$RELAY_URL/media/${POSTER_SHA}.jpg")
if [ "$VIDEO_HEAD" = "200" ] && [ "$POSTER_HEAD" = "200" ]; then
pass "Both video and poster blobs exist (HEAD 200)"
else
fail "Blob existence check failed: video=$VIDEO_HEAD poster=$POSTER_HEAD"
fi
# Verify poster is an image (not video) by checking Content-Type
POSTER_CT=$(curl -s -I "$RELAY_URL/media/${POSTER_SHA}.jpg" | grep -i "content-type" | tr -d '\r' | awk '{print $2}')
if echo "$POSTER_CT" | grep -qi "image/jpeg"; then
pass "Poster Content-Type is image/jpeg"
else
fail "Poster Content-Type should be image/jpeg, got: $POSTER_CT"
fi
# Verify video and poster have different hashes (independent blobs)
if [ "$SHA256" != "$POSTER_SHA" ]; then
pass "Video and poster have distinct content hashes"
else
fail "Video and poster hashes should differ"
fi
echo ""
# ── Test 11: Poster sidecar has correct metadata ─────────────────────────────
# The server writes a JSON sidecar for every uploaded blob. Verify the poster
# sidecar exists and has image MIME type (the server's verify_imeta_blobs
# checks this at message send time).
echo "Test 11: Poster sidecar metadata"
# The bare-hash GET resolves via sidecar — if it returns 200 with image/jpeg
# content-type, the sidecar is correctly configured.
POSTER_BARE_RESP=$(curl -s -D "$TMPDIR/poster_bare_headers.txt" -o /dev/null -w "%{http_code}" \
"$RELAY_URL/media/${POSTER_SHA}")
if [ "$POSTER_BARE_RESP" = "200" ]; then
pass "Poster bare-hash GET resolves via sidecar (200)"
else
fail "Poster bare-hash GET returned $POSTER_BARE_RESP (expected 200)"
fi
# Verify Content-Type on the bare-hash response
POSTER_BARE_CT=$(grep -i "content-type" "$TMPDIR/poster_bare_headers.txt" | tr -d '\r' | awk '{print $2}')
if echo "$POSTER_BARE_CT" | grep -qi "image/jpeg"; then
pass "Poster bare-hash Content-Type is image/jpeg"
else
fail "Poster bare-hash Content-Type should be image/jpeg, got: $POSTER_BARE_CT"
fi
# Note: Full imeta image validation (accept/reject at message send time) is
# covered by Rust unit tests: test_imeta_image_poster_frame_accepted,
# test_imeta_image_video_url_rejected, test_imeta_image_thumbnail_url_rejected.
echo ""
# ── Summary ───────────────────────────────────────────────────────────────────
echo "════════════════════════════════════════"
echo " Results: $PASS passed, $FAIL failed"
echo "════════════════════════════════════════"
[ "$FAIL" -eq 0 ] && exit 0 || exit 1