mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +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>
1708 lines
52 KiB
Dart
1708 lines
52 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart' as http_testing;
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
import 'package:buzz/shared/relay/media_auth.dart';
|
|
import 'package:buzz/shared/relay/media_upload.dart';
|
|
|
|
final _pngBytes = Uint8List.fromList([
|
|
0x89,
|
|
0x50,
|
|
0x4e,
|
|
0x47,
|
|
0x0d,
|
|
0x0a,
|
|
0x1a,
|
|
0x0a,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x0d,
|
|
0x49,
|
|
0x48,
|
|
0x44,
|
|
0x52,
|
|
]);
|
|
|
|
final _jpegBytes = Uint8List.fromList([
|
|
0xff,
|
|
0xd8,
|
|
0xff,
|
|
0xdb,
|
|
0x00,
|
|
0x43,
|
|
0x00,
|
|
0x01,
|
|
]);
|
|
|
|
final _heicBytes = Uint8List.fromList([
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x18,
|
|
0x66,
|
|
0x74,
|
|
0x79,
|
|
0x70,
|
|
0x68,
|
|
0x65,
|
|
0x69,
|
|
0x63,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x6d,
|
|
0x69,
|
|
0x66,
|
|
0x31,
|
|
0x68,
|
|
0x65,
|
|
0x69,
|
|
0x63,
|
|
]);
|
|
|
|
class _NamedXFile extends XFile {
|
|
final String _name;
|
|
|
|
_NamedXFile(super.bytes, this._name) : super.fromData();
|
|
|
|
@override
|
|
String get name => _name;
|
|
}
|
|
|
|
final _gifBytes = Uint8List.fromList([
|
|
...ascii.encode('GIF89a'),
|
|
0x02,
|
|
0x00,
|
|
0x02,
|
|
0x00,
|
|
0x80,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0xff,
|
|
0xff,
|
|
0xff,
|
|
0x21,
|
|
0xfe,
|
|
0x05,
|
|
...ascii.encode('hello'),
|
|
0x00,
|
|
0x21,
|
|
0xff,
|
|
0x0b,
|
|
...ascii.encode('NETSCAPE2.0'),
|
|
0x03,
|
|
0x01,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x21,
|
|
0xf9,
|
|
0x04,
|
|
0x00,
|
|
0x0a,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x2c,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x02,
|
|
0x00,
|
|
0x02,
|
|
0x00,
|
|
0x00,
|
|
0x02,
|
|
0x02,
|
|
0x44,
|
|
0x01,
|
|
0x00,
|
|
0x3b,
|
|
]);
|
|
|
|
final _apngBytes = Uint8List.fromList([
|
|
0x89,
|
|
0x50,
|
|
0x4e,
|
|
0x47,
|
|
0x0d,
|
|
0x0a,
|
|
0x1a,
|
|
0x0a,
|
|
..._testPngChunk('acTL', [0, 0, 0, 2, 0, 0, 0, 0]),
|
|
..._testPngChunk('IEND', const []),
|
|
]);
|
|
|
|
List<int> _testPngChunk(String type, List<int> payload) {
|
|
return [
|
|
payload.length >> 24 & 0xff,
|
|
payload.length >> 16 & 0xff,
|
|
payload.length >> 8 & 0xff,
|
|
payload.length & 0xff,
|
|
...ascii.encode(type),
|
|
...payload,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
];
|
|
}
|
|
|
|
final _staticPngWithActlPayloadBytes = Uint8List.fromList([
|
|
0x89,
|
|
0x50,
|
|
0x4e,
|
|
0x47,
|
|
0x0d,
|
|
0x0a,
|
|
0x1a,
|
|
0x0a,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x04,
|
|
0x49,
|
|
0x44,
|
|
0x41,
|
|
0x54,
|
|
0x61,
|
|
0x63,
|
|
0x54,
|
|
0x4c,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x49,
|
|
0x45,
|
|
0x4e,
|
|
0x44,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
]);
|
|
|
|
final _animatedWebpBytes = Uint8List.fromList([
|
|
0x52,
|
|
0x49,
|
|
0x46,
|
|
0x46,
|
|
0x16,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x57,
|
|
0x45,
|
|
0x42,
|
|
0x50,
|
|
0x56,
|
|
0x50,
|
|
0x38,
|
|
0x58,
|
|
0x0a,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x02,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
]);
|
|
|
|
const _mediaUploadPlatformChannel = MethodChannel('buzz/media_upload');
|
|
|
|
void _setMockMediaUploadPlatformHandler(
|
|
Future<Object?> Function(MethodCall call)? handler,
|
|
) {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(_mediaUploadPlatformChannel, handler);
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
setUpAll(() {
|
|
_setMockMediaUploadPlatformHandler((call) async {
|
|
switch (call.method) {
|
|
case 'sanitizeImageForUpload':
|
|
final arguments = call.arguments as Map<Object?, Object?>;
|
|
return arguments['bytes'] as Uint8List;
|
|
case 'transcodeImageToJpeg':
|
|
return _jpegBytes;
|
|
default:
|
|
return null;
|
|
}
|
|
});
|
|
});
|
|
|
|
tearDownAll(() {
|
|
_setMockMediaUploadPlatformHandler(null);
|
|
});
|
|
|
|
group('MediaGetAuthService', () {
|
|
test('signs relay media get requests with a server-scoped token', () {
|
|
final keychain = nostr.Keys.generate();
|
|
final service = MediaGetAuthService(
|
|
baseUrl: 'https://Relay.Example:443',
|
|
nsec: keychain.nsec,
|
|
now: () => DateTime.fromMillisecondsSinceEpoch(1700000000000),
|
|
);
|
|
|
|
final headers = service.headersFor(
|
|
'https://relay.example:443/media/${'a' * 64}.jpg',
|
|
);
|
|
|
|
final authHeader = headers['Authorization'];
|
|
expect(authHeader, startsWith('Nostr '));
|
|
final encoded = authHeader!.substring('Nostr '.length);
|
|
final decoded = utf8.decode(
|
|
base64Url.decode(base64Url.normalize(encoded)),
|
|
);
|
|
final authEvent = jsonDecode(decoded) as Map<String, dynamic>;
|
|
expect(authEvent['kind'], 24242);
|
|
expect(authEvent['pubkey'], keychain.public);
|
|
expect(authEvent['content'], 'Get buzz-media');
|
|
expect(authEvent['tags'], contains(equals(['t', 'get'])));
|
|
expect(authEvent['tags'], contains(equals(['server', 'relay.example'])));
|
|
expect(authEvent['tags'], contains(equals(['expiration', '1700000060'])));
|
|
});
|
|
|
|
test('does not sign non-relay or non-media URLs', () {
|
|
final service = MediaGetAuthService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
);
|
|
|
|
expect(
|
|
service.headersFor('https://evil.example/media/${'a' * 64}.jpg'),
|
|
isEmpty,
|
|
);
|
|
expect(service.headersFor('https://relay.example/avatar.png'), isEmpty);
|
|
});
|
|
|
|
test('normalizes default ports and rejects path-prefix lookalikes', () {
|
|
final service = MediaGetAuthService(
|
|
baseUrl: 'https://Relay.Example:443',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
);
|
|
|
|
expect(
|
|
service.headersFor('https://relay.example/media/${'a' * 64}.jpg'),
|
|
isNotEmpty,
|
|
);
|
|
expect(
|
|
service.headersFor('https://relay.example/media-evil/${'a' * 64}.jpg'),
|
|
isEmpty,
|
|
);
|
|
expect(
|
|
service.headersFor('ftp://relay.example/media/${'a' * 64}.jpg'),
|
|
isEmpty,
|
|
);
|
|
});
|
|
|
|
test('does not sign without a key', () {
|
|
final service = MediaGetAuthService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: null,
|
|
);
|
|
|
|
expect(
|
|
service.headersFor('https://relay.example/media/${'a' * 64}.jpg'),
|
|
isEmpty,
|
|
);
|
|
});
|
|
|
|
test('does not throw or sign when the stored key is invalid', () {
|
|
final service = MediaGetAuthService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: 'not-an-nsec',
|
|
);
|
|
|
|
expect(
|
|
service.headersFor('https://relay.example/media/${'a' * 64}.jpg'),
|
|
isEmpty,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('MediaUploadService', () {
|
|
test('signs Blossom auth and uploads gallery image bytes', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
|
|
http.Request? capturedRequest;
|
|
final client = http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.png',
|
|
'sha256':
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
'size': 16,
|
|
'type': 'image/png',
|
|
'uploaded': 1,
|
|
'thumb': 'https://relay.example/media/test.thumb.jpg',
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example:8443',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_pngBytes, name: 'tiny.png'),
|
|
now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000),
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/png');
|
|
expect(capturedRequest, isNotNull);
|
|
expect(
|
|
capturedRequest!.url.toString(),
|
|
'https://relay.example:8443/upload',
|
|
);
|
|
expect(capturedRequest!.headers['Content-Type'], 'image/png');
|
|
expect(capturedRequest!.headers['X-SHA-256'], isNotEmpty);
|
|
expect(capturedRequest!.bodyBytes, _pngBytes);
|
|
|
|
final authHeader = capturedRequest!.headers['Authorization'];
|
|
expect(authHeader, isNotNull);
|
|
expect(authHeader, startsWith('Nostr '));
|
|
final encoded = authHeader!.substring('Nostr '.length);
|
|
final decoded = utf8.decode(
|
|
base64Url.decode(base64Url.normalize(encoded)),
|
|
);
|
|
final authEvent = jsonDecode(decoded) as Map<String, dynamic>;
|
|
final tags = (authEvent['tags'] as List<dynamic>)
|
|
.map((tag) => (tag as List<dynamic>).cast<String>())
|
|
.toList();
|
|
|
|
expect(authEvent['kind'], 24242);
|
|
expect(authEvent['pubkey'], keychain.public);
|
|
expect(tags, anyElement(equals(<String>['t', 'upload'])));
|
|
expect(
|
|
tags,
|
|
anyElement(
|
|
equals(<String>['x', capturedRequest!.headers['X-SHA-256']!]),
|
|
),
|
|
);
|
|
expect(tags, anyElement(equals(<String>['expiration', '1700000060'])));
|
|
expect(
|
|
tags,
|
|
anyElement(equals(<String>['server', 'relay.example:8443'])),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'retries the legacy upload route when the standard route is absent',
|
|
() async {
|
|
final requests = <http.Request>[];
|
|
final client = http_testing.MockClient((request) async {
|
|
requests.add(request);
|
|
if (request.url.path == '/upload') {
|
|
return http.Response('not found', HttpStatus.notFound);
|
|
}
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.png',
|
|
'sha256': request.headers['X-SHA-256'],
|
|
'size': _pngBytes.length,
|
|
'type': 'image/png',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
);
|
|
|
|
await service.uploadBytes(_pngBytes, mimeType: 'image/png');
|
|
|
|
expect(requests.map((request) => request.url.path), [
|
|
'/upload',
|
|
'/media/upload',
|
|
]);
|
|
expect(requests[1].bodyBytes, requests[0].bodyBytes);
|
|
expect(requests[0].headers['Authorization'], startsWith('Nostr '));
|
|
expect(requests[1].headers['Authorization'], startsWith('Nostr '));
|
|
expect(
|
|
requests[1].headers['X-SHA-256'],
|
|
requests[0].headers['X-SHA-256'],
|
|
);
|
|
},
|
|
);
|
|
|
|
for (final statusCode in [
|
|
HttpStatus.unsupportedMediaType,
|
|
HttpStatus.unprocessableEntity,
|
|
]) {
|
|
test(
|
|
'maps $statusCode media policy responses to friendly copy',
|
|
() async {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: http_testing.MockClient(
|
|
(request) async => http.Response(
|
|
'{"error":"media contains metadata"}',
|
|
statusCode,
|
|
),
|
|
),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
);
|
|
|
|
await expectLater(
|
|
service.uploadBytes(_pngBytes, mimeType: 'image/png'),
|
|
throwsA(
|
|
isA<MediaPolicyUploadException>().having(
|
|
(error) => error.toString(),
|
|
'message',
|
|
"We couldn't prepare this image for upload.",
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
test('preserves video policy response details', () async {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: http_testing.MockClient(
|
|
(request) async => http.Response(
|
|
'{"error":"unsupported video codec"}',
|
|
HttpStatus.unprocessableEntity,
|
|
),
|
|
),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
);
|
|
|
|
await expectLater(
|
|
service.uploadBytes(Uint8List(0), mimeType: 'video/mp4'),
|
|
throwsA(
|
|
isA<Exception>()
|
|
.having(
|
|
(error) => error,
|
|
'type',
|
|
isNot(isA<MediaPolicyUploadException>()),
|
|
)
|
|
.having(
|
|
(error) => error.toString(),
|
|
'message',
|
|
contains('unsupported video codec'),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'checks clipboard image availability through the platform channel',
|
|
() async {
|
|
final invokedMethods = <String>[];
|
|
_setMockMediaUploadPlatformHandler((call) async {
|
|
invokedMethods.add(call.method);
|
|
if (call.method == 'clipboardHasImage') return true;
|
|
return null;
|
|
});
|
|
addTearDown(() {
|
|
_setMockMediaUploadPlatformHandler((call) async {
|
|
switch (call.method) {
|
|
case 'sanitizeImageForUpload':
|
|
final arguments = call.arguments as Map<Object?, Object?>;
|
|
return arguments['bytes'] as Uint8List;
|
|
case 'transcodeImageToJpeg':
|
|
return _jpegBytes;
|
|
default:
|
|
return null;
|
|
}
|
|
});
|
|
});
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: null,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
);
|
|
|
|
expect(await service.clipboardHasImage(), isTrue);
|
|
expect(invokedMethods, ['clipboardHasImage']);
|
|
},
|
|
);
|
|
|
|
test('reads clipboard image through the platform channel', () async {
|
|
final invokedMethods = <String>[];
|
|
_setMockMediaUploadPlatformHandler((call) async {
|
|
invokedMethods.add(call.method);
|
|
if (call.method == 'readClipboardImage') return _pngBytes;
|
|
if (call.method == 'sanitizeImageForUpload') {
|
|
final arguments = call.arguments as Map<Object?, Object?>;
|
|
return arguments['bytes'] as Uint8List;
|
|
}
|
|
return null;
|
|
});
|
|
addTearDown(() {
|
|
_setMockMediaUploadPlatformHandler((call) async {
|
|
switch (call.method) {
|
|
case 'sanitizeImageForUpload':
|
|
final arguments = call.arguments as Map<Object?, Object?>;
|
|
return arguments['bytes'] as Uint8List;
|
|
case 'transcodeImageToJpeg':
|
|
return _jpegBytes;
|
|
default:
|
|
return null;
|
|
}
|
|
});
|
|
});
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: http_testing.MockClient(
|
|
(request) async => http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/clipboard.png',
|
|
'sha256':
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
'size': 16,
|
|
'type': 'image/png',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
),
|
|
),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
);
|
|
|
|
final descriptor = await service.readAndUploadClipboardImage();
|
|
|
|
expect(invokedMethods.first, 'readClipboardImage');
|
|
expect(descriptor.type, 'image/png');
|
|
});
|
|
|
|
test('sanitizes and uploads GIF clipboard bytes', () async {
|
|
http.Request? capturedRequest;
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/clipboard.gif',
|
|
'sha256':
|
|
'3333333333333333333333333333333333333333333333333333333333333333',
|
|
'size': request.bodyBytes.length,
|
|
'type': 'image/gif',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
}),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
readClipboardImage: () async => _gifBytes,
|
|
);
|
|
|
|
final descriptor = await service.readAndUploadClipboardImage();
|
|
|
|
expect(descriptor.type, 'image/gif');
|
|
expect(capturedRequest, isNotNull);
|
|
expect(capturedRequest!.headers['Content-Type'], 'image/gif');
|
|
expect(
|
|
ascii.decode(capturedRequest!.bodyBytes, allowInvalid: true),
|
|
isNot(contains('hello')),
|
|
);
|
|
});
|
|
|
|
test('rejects empty clipboard image bytes', () async {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: null,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
readClipboardImage: () async => Uint8List(0),
|
|
);
|
|
|
|
expect(
|
|
service.readAndUploadClipboardImage,
|
|
throwsA(
|
|
isA<Exception>().having(
|
|
(error) => error.toString(),
|
|
'message',
|
|
contains('Unable to read pasted image'),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('returns null when the gallery picker is cancelled', () async {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: null,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
);
|
|
|
|
final result = await service.pickAndUploadImage();
|
|
expect(result, isNull);
|
|
});
|
|
|
|
test('uses a bracketed IPv6 server tag in Blossom auth', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
|
|
http.Request? capturedRequest;
|
|
final client = http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'http://[::1]:3000/media/test.png',
|
|
'sha256':
|
|
'2222222222222222222222222222222222222222222222222222222222222222',
|
|
'size': 16,
|
|
'type': 'image/png',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'http://[::1]:3000',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_pngBytes, name: 'tiny.png'),
|
|
);
|
|
|
|
await service.pickAndUploadImage();
|
|
|
|
expect(capturedRequest, isNotNull);
|
|
final authHeader = capturedRequest!.headers['Authorization'];
|
|
expect(authHeader, isNotNull);
|
|
final encoded = authHeader!.substring('Nostr '.length);
|
|
final decoded = utf8.decode(
|
|
base64Url.decode(base64Url.normalize(encoded)),
|
|
);
|
|
final authEvent = jsonDecode(decoded) as Map<String, dynamic>;
|
|
final tags = (authEvent['tags'] as List<dynamic>)
|
|
.map((tag) => (tag as List<dynamic>).cast<String>())
|
|
.toList();
|
|
|
|
expect(tags, anyElement(equals(<String>['server', '[::1]:3000'])));
|
|
});
|
|
|
|
test('transcodes HEIC gallery files on iOS before upload', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
final previousPlatform = debugDefaultTargetPlatformOverride;
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
addTearDown(() {
|
|
debugDefaultTargetPlatformOverride = previousPlatform;
|
|
});
|
|
|
|
Uint8List? transcodedInput;
|
|
http.Request? capturedRequest;
|
|
final client = http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.jpg',
|
|
'sha256':
|
|
'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210',
|
|
'size': _jpegBytes.length,
|
|
'type': 'image/jpeg',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_heicBytes, name: 'photo.heic'),
|
|
transcodeImageToJpeg: (bytes) async {
|
|
transcodedInput = bytes;
|
|
return _jpegBytes;
|
|
},
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/jpeg');
|
|
expect(transcodedInput, _heicBytes);
|
|
expect(capturedRequest, isNotNull);
|
|
expect(capturedRequest!.headers['Content-Type'], 'image/jpeg');
|
|
expect(capturedRequest!.bodyBytes, _jpegBytes);
|
|
});
|
|
|
|
test('sanitizes iOS JPEG gallery files before upload', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
final previousPlatform = debugDefaultTargetPlatformOverride;
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
addTearDown(() {
|
|
debugDefaultTargetPlatformOverride = previousPlatform;
|
|
});
|
|
|
|
Uint8List? sanitizedInput;
|
|
String? sanitizedMimeType;
|
|
http.Request? capturedRequest;
|
|
final client = http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.jpg',
|
|
'sha256':
|
|
'abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
|
'size': _jpegBytes.length,
|
|
'type': 'image/jpeg',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_jpegBytes, name: 'photo.jpg'),
|
|
sanitizeImageBytes: (bytes, mimeType) async {
|
|
sanitizedInput = bytes;
|
|
sanitizedMimeType = mimeType;
|
|
return _jpegBytes;
|
|
},
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/jpeg');
|
|
expect(sanitizedInput, _jpegBytes);
|
|
expect(sanitizedMimeType, 'image/jpeg');
|
|
expect(capturedRequest, isNotNull);
|
|
expect(capturedRequest!.headers['Content-Type'], 'image/jpeg');
|
|
expect(capturedRequest!.bodyBytes, _jpegBytes);
|
|
});
|
|
|
|
test('transcodes HEIC gallery files on Android before upload', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
final previousPlatform = debugDefaultTargetPlatformOverride;
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
|
addTearDown(() {
|
|
debugDefaultTargetPlatformOverride = previousPlatform;
|
|
});
|
|
|
|
Uint8List? transcodedInput;
|
|
http.Request? capturedRequest;
|
|
final client = http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.jpg',
|
|
'sha256':
|
|
'1234512345123451234512345123451234512345123451234512345123451234',
|
|
'size': _jpegBytes.length,
|
|
'type': 'image/jpeg',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_heicBytes, name: 'photo.heic'),
|
|
transcodeImageToJpeg: (bytes) async {
|
|
transcodedInput = bytes;
|
|
return _jpegBytes;
|
|
},
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/jpeg');
|
|
expect(transcodedInput, _heicBytes);
|
|
expect(capturedRequest, isNotNull);
|
|
expect(capturedRequest!.headers['Content-Type'], 'image/jpeg');
|
|
expect(capturedRequest!.bodyBytes, _jpegBytes);
|
|
});
|
|
|
|
test('sanitizes Android JPEG gallery files before upload', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
final previousPlatform = debugDefaultTargetPlatformOverride;
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
|
addTearDown(() {
|
|
debugDefaultTargetPlatformOverride = previousPlatform;
|
|
});
|
|
|
|
Uint8List? sanitizedInput;
|
|
String? sanitizedMimeType;
|
|
http.Request? capturedRequest;
|
|
final client = http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.jpg',
|
|
'sha256':
|
|
'abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
|
'size': _jpegBytes.length,
|
|
'type': 'image/jpeg',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_jpegBytes, name: 'photo.jpg'),
|
|
sanitizeImageBytes: (bytes, mimeType) async {
|
|
sanitizedInput = bytes;
|
|
sanitizedMimeType = mimeType;
|
|
return _jpegBytes;
|
|
},
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/jpeg');
|
|
expect(sanitizedInput, _jpegBytes);
|
|
expect(sanitizedMimeType, 'image/jpeg');
|
|
expect(capturedRequest, isNotNull);
|
|
expect(capturedRequest!.headers['Content-Type'], 'image/jpeg');
|
|
expect(capturedRequest!.bodyBytes, _jpegBytes);
|
|
});
|
|
|
|
test('sanitizes Android PNG gallery files before upload', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
final previousPlatform = debugDefaultTargetPlatformOverride;
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
|
addTearDown(() {
|
|
debugDefaultTargetPlatformOverride = previousPlatform;
|
|
});
|
|
|
|
Uint8List? sanitizedInput;
|
|
String? sanitizedMimeType;
|
|
http.Request? capturedRequest;
|
|
final client = http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.png',
|
|
'sha256':
|
|
'9999999999999999999999999999999999999999999999999999999999999999',
|
|
'size': _pngBytes.length,
|
|
'type': 'image/png',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_pngBytes, name: 'photo.png'),
|
|
sanitizeImageBytes: (bytes, mimeType) async {
|
|
sanitizedInput = bytes;
|
|
sanitizedMimeType = mimeType;
|
|
return _pngBytes;
|
|
},
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/png');
|
|
expect(sanitizedInput, _pngBytes);
|
|
expect(sanitizedMimeType, 'image/png');
|
|
expect(capturedRequest, isNotNull);
|
|
expect(capturedRequest!.headers['Content-Type'], 'image/png');
|
|
expect(capturedRequest!.bodyBytes, _pngBytes);
|
|
});
|
|
|
|
test('sanitizes and uploads GIF gallery files', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
http.Request? capturedRequest;
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/animated.gif',
|
|
'sha256':
|
|
'4444444444444444444444444444444444444444444444444444444444444444',
|
|
'size': request.bodyBytes.length,
|
|
'type': 'image/gif',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
}),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_gifBytes, name: 'animated.gif'),
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/gif');
|
|
expect(capturedRequest!.headers['Content-Type'], 'image/gif');
|
|
expect(
|
|
ascii.decode(capturedRequest!.bodyBytes, allowInvalid: true),
|
|
isNot(contains('hello')),
|
|
);
|
|
});
|
|
|
|
test('sanitizes and uploads animated PNG gallery files', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
http.Request? capturedRequest;
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/animated.png',
|
|
'sha256':
|
|
'5555555555555555555555555555555555555555555555555555555555555555',
|
|
'size': request.bodyBytes.length,
|
|
'type': 'image/png',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
}),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_apngBytes, name: 'animated.png'),
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/png');
|
|
expect(capturedRequest!.bodyBytes, _apngBytes);
|
|
});
|
|
|
|
test('uploads static PNG when acTL appears only in chunk payload', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
|
|
http.Request? capturedRequest;
|
|
final client = http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/static.png',
|
|
'sha256':
|
|
'1111111111111111111111111111111111111111111111111111111111111111',
|
|
'size': _staticPngWithActlPayloadBytes.length,
|
|
'type': 'image/png',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_staticPngWithActlPayloadBytes, name: 'static.png'),
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/png');
|
|
expect(capturedRequest, isNotNull);
|
|
expect(capturedRequest!.headers['Content-Type'], 'image/png');
|
|
expect(capturedRequest!.bodyBytes, _staticPngWithActlPayloadBytes);
|
|
});
|
|
|
|
test('sanitizes and uploads animated WebP gallery files', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
http.Request? capturedRequest;
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/animated.webp',
|
|
'sha256':
|
|
'6666666666666666666666666666666666666666666666666666666666666666',
|
|
'size': request.bodyBytes.length,
|
|
'type': 'image/webp',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
}),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_animatedWebpBytes, name: 'animated.webp'),
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/webp');
|
|
expect(capturedRequest!.bodyBytes, _animatedWebpBytes);
|
|
});
|
|
|
|
test('rejects unsupported gallery files before upload', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => XFile.fromData(
|
|
Uint8List.fromList(utf8.encode('not an image')),
|
|
name: 'note.txt',
|
|
),
|
|
);
|
|
|
|
expect(
|
|
service.pickAndUploadImage(),
|
|
throwsA(
|
|
isA<Exception>().having(
|
|
(error) => error.toString(),
|
|
'message',
|
|
contains('unsupported file type'),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('rejects empty generic file attachments before upload', () async {
|
|
var uploadRequested = false;
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: http_testing.MockClient((request) async {
|
|
uploadRequested = true;
|
|
return http.Response('', HttpStatus.ok);
|
|
}),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
pickAttachmentFile: () async =>
|
|
XFile.fromData(Uint8List(0), name: 'empty.txt'),
|
|
);
|
|
|
|
await expectLater(
|
|
service.pickAndUploadFile(),
|
|
throwsA(
|
|
isA<Exception>().having(
|
|
(error) => error.toString(),
|
|
'message',
|
|
contains('File is empty'),
|
|
),
|
|
),
|
|
);
|
|
expect(uploadRequested, isFalse);
|
|
});
|
|
|
|
test('sanitizes generic filenames to relay imeta constraints', () async {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: http_testing.MockClient((request) async {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.bin',
|
|
'sha256':
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
'size': request.bodyBytes.length,
|
|
'type': 'application/octet-stream',
|
|
'uploaded': 1,
|
|
}),
|
|
HttpStatus.ok,
|
|
);
|
|
}),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
pickAttachmentFile: () async => _NamedXFile(
|
|
Uint8List.fromList([1]),
|
|
'folder\\draft\u0000${List.filled(200, 'é').join()}.txt',
|
|
),
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadFile();
|
|
final filename = descriptor!.filename!;
|
|
|
|
expect(filename, startsWith('draft'));
|
|
expect(filename, isNot(contains('\u0000')));
|
|
expect(filename, isNot(contains('/')));
|
|
expect(filename, isNot(contains('\\')));
|
|
expect(utf8.encode(filename).length, lessThanOrEqualTo(255));
|
|
});
|
|
});
|
|
|
|
group('uploadVoiceNote', () {
|
|
test('keeps ordinary audio attachments on inline audio markdown', () {
|
|
const descriptor = BlobDescriptor(
|
|
url: 'https://relay.example/media/meeting.m4a',
|
|
sha256:
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
size: 3,
|
|
type: 'audio/mp4',
|
|
uploaded: 1,
|
|
filename: 'meeting.m4a',
|
|
);
|
|
|
|
expect(
|
|
descriptor.toMarkdownImage(),
|
|
'',
|
|
);
|
|
});
|
|
|
|
test(
|
|
'removes generated Android package when fast-start rewrite fails',
|
|
() async {
|
|
final previousPlatform = debugDefaultTargetPlatformOverride;
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
|
final sourceDirectory = await Directory.systemTemp.createTemp(
|
|
'voice_note_rewrite_source_',
|
|
);
|
|
final generatedDirectory = await Directory.systemTemp.createTemp(
|
|
'voice_note_rewrite_generated_',
|
|
);
|
|
final source = File('${sourceDirectory.path}/recording.m4a');
|
|
final generated = File('${generatedDirectory.path}/packaged.mp4');
|
|
await source.writeAsBytes(const [1, 2, 3]);
|
|
await generated.writeAsBytes(const [4, 5, 6]);
|
|
_setMockMediaUploadPlatformHandler((call) async {
|
|
if (call.method == 'packageVoiceNoteForUpload') {
|
|
return generated.path;
|
|
}
|
|
return null;
|
|
});
|
|
addTearDown(() async {
|
|
debugDefaultTargetPlatformOverride = previousPlatform;
|
|
_setMockMediaUploadPlatformHandler((call) async {
|
|
switch (call.method) {
|
|
case 'sanitizeImageForUpload':
|
|
final arguments = call.arguments as Map<Object?, Object?>;
|
|
return arguments['bytes'] as Uint8List;
|
|
case 'transcodeImageToJpeg':
|
|
return _jpegBytes;
|
|
default:
|
|
return null;
|
|
}
|
|
});
|
|
if (await sourceDirectory.exists()) {
|
|
await sourceDirectory.delete(recursive: true);
|
|
}
|
|
if (await generatedDirectory.exists()) {
|
|
await generatedDirectory.delete(recursive: true);
|
|
}
|
|
});
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
);
|
|
|
|
await expectLater(
|
|
service.uploadVoiceNote(
|
|
XFile(source.path, mimeType: 'audio/mp4'),
|
|
duration: const Duration(seconds: 3),
|
|
),
|
|
throwsFormatException,
|
|
);
|
|
|
|
expect(await source.exists(), isTrue);
|
|
expect(await generated.exists(), isFalse);
|
|
expect(generatedDirectory.listSync().whereType<File>(), isEmpty);
|
|
},
|
|
);
|
|
|
|
test('uploads the packaged voice note as a video MP4 audio card', () async {
|
|
final sourceDirectory = await Directory.systemTemp.createTemp(
|
|
'voice_note_source_',
|
|
);
|
|
final packagedDirectory = await Directory.systemTemp.createTemp(
|
|
'voice_note_packaged_',
|
|
);
|
|
final source = File('${sourceDirectory.path}/voice-note-test.m4a');
|
|
final packaged = File('${packagedDirectory.path}/voice-note-test.mp4');
|
|
await source.writeAsBytes(const [1, 2, 3]);
|
|
await packaged.writeAsBytes(const [4, 5, 6]);
|
|
var packagedSourcePath = '';
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: http_testing.MockClient((request) async {
|
|
expect(request.headers['Content-Type'], 'video/mp4');
|
|
expect(request.bodyBytes, const [4, 5, 6]);
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/voice-note.mp4',
|
|
'sha256':
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
'size': request.bodyBytes.length,
|
|
'type': 'video/mp4',
|
|
'uploaded': 1,
|
|
'duration': 3.0,
|
|
}),
|
|
HttpStatus.ok,
|
|
);
|
|
}),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
packageVoiceNoteForUpload: (path) async {
|
|
packagedSourcePath = path;
|
|
return packaged.path;
|
|
},
|
|
);
|
|
|
|
try {
|
|
final descriptor = await service.uploadVoiceNote(
|
|
XFile(source.path, mimeType: 'audio/mp4'),
|
|
duration: const Duration(milliseconds: 3250),
|
|
);
|
|
|
|
expect(packagedSourcePath, source.path);
|
|
expect(descriptor.type, 'video/mp4');
|
|
expect(descriptor.filename, 'voice-note-test.mp4');
|
|
expect(descriptor.duration, 3.0);
|
|
expect(descriptor.toImetaTag(), contains('duration 3.0'));
|
|
expect(
|
|
descriptor.toMarkdownImage(),
|
|
'[voice-note-test.mp4](${descriptor.url})',
|
|
);
|
|
expect(await packaged.exists(), isFalse);
|
|
} finally {
|
|
await sourceDirectory.delete(recursive: true);
|
|
await packagedDirectory.delete(recursive: true);
|
|
}
|
|
});
|
|
});
|
|
|
|
group('pickAndUploadVideo', () {
|
|
// Helper: build ftyp header bytes for a given brand.
|
|
Uint8List buildFtypHeader(String brand) {
|
|
final bytes = Uint8List(32);
|
|
bytes[3] = 32;
|
|
bytes[4] = 0x66;
|
|
bytes[5] = 0x74;
|
|
bytes[6] = 0x79;
|
|
bytes[7] = 0x70;
|
|
final brandBytes = ascii.encode(brand);
|
|
for (var i = 0; i < 4 && i < brandBytes.length; i++) {
|
|
bytes[8 + i] = brandBytes[i];
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
// Helper: write bytes to a temp file, return its XFile.
|
|
Future<(XFile, File)> writeTempVideo(Uint8List bytes, String name) async {
|
|
final dir = await Directory.systemTemp.createTemp('video_test_');
|
|
final file = File('${dir.path}/$name');
|
|
await file.writeAsBytes(bytes);
|
|
return (XFile(file.path), file);
|
|
}
|
|
|
|
test('rebuilds an existing MP4 container before upload', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
var transcodeCalled = false;
|
|
final client = http_testing.MockClient((request) async {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.mp4',
|
|
'sha256':
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
'size': 32,
|
|
'type': 'video/mp4',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final mp4Bytes = buildFtypHeader('isom');
|
|
final (xfile, tempFile) = await writeTempVideo(mp4Bytes, 'clip.mp4');
|
|
try {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => xfile,
|
|
pickGalleryImage: () async => null,
|
|
transcodeVideoToMp4: (path) async {
|
|
transcodeCalled = true;
|
|
final outDir = await Directory.systemTemp.createTemp('transcode_');
|
|
final outFile = File('${outDir.path}/out.mp4');
|
|
await outFile.writeAsBytes(buildFtypHeader('isom'));
|
|
return outFile.path;
|
|
},
|
|
now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000),
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadVideo();
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'video/mp4');
|
|
expect(transcodeCalled, isTrue);
|
|
} finally {
|
|
await tempFile.parent.delete(recursive: true);
|
|
}
|
|
});
|
|
|
|
test('transcodes non-MP4 container before uploading', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
var transcodeCalled = false;
|
|
final client = http_testing.MockClient((request) async {
|
|
expect(request.headers['Content-Type'], 'video/mp4');
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.mp4',
|
|
'sha256':
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
'size': 32,
|
|
'type': 'video/mp4',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
// QuickTime container ('qt ' brand) — needs transcoding.
|
|
final movBytes = buildFtypHeader('qt ');
|
|
final (xfile, tempFile) = await writeTempVideo(movBytes, 'clip.mov');
|
|
try {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => xfile,
|
|
pickGalleryImage: () async => null,
|
|
transcodeVideoToMp4: (path) async {
|
|
transcodeCalled = true;
|
|
// Mock transcoding: write an "MP4" file
|
|
final outDir = await Directory.systemTemp.createTemp('transcode_');
|
|
final outFile = File('${outDir.path}/out.mp4');
|
|
await outFile.writeAsBytes(buildFtypHeader('isom'));
|
|
return outFile.path;
|
|
},
|
|
now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000),
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadVideo();
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'video/mp4');
|
|
expect(transcodeCalled, isTrue);
|
|
} finally {
|
|
await tempFile.parent.delete(recursive: true);
|
|
}
|
|
});
|
|
|
|
test(
|
|
'stops before reading a transcode completed after cancellation',
|
|
() async {
|
|
final transcodeStarted = Completer<void>();
|
|
final transcodeFinished = Completer<String>();
|
|
final cancellationToken = UploadCancellationToken();
|
|
var uploadRequested = false;
|
|
final (xfile, sourceFile) = await writeTempVideo(
|
|
buildFtypHeader('qt '),
|
|
'clip.mov',
|
|
);
|
|
final outputDirectory = await Directory.systemTemp.createTemp(
|
|
'cancelled_transcode_',
|
|
);
|
|
final outputFile = File('${outputDirectory.path}/out.mp4');
|
|
final outputHandle = await outputFile.open(mode: FileMode.write);
|
|
await outputHandle.truncate(101 * 1024 * 1024);
|
|
await outputHandle.close();
|
|
|
|
try {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: http_testing.MockClient((_) async {
|
|
uploadRequested = true;
|
|
return http.Response('', HttpStatus.internalServerError);
|
|
}),
|
|
pickGalleryVideo: () async => xfile,
|
|
pickGalleryImage: () async => null,
|
|
transcodeVideoToMp4: (_) {
|
|
transcodeStarted.complete();
|
|
return transcodeFinished.future;
|
|
},
|
|
);
|
|
|
|
final upload = service.uploadVideo(
|
|
xfile,
|
|
cancellationToken: cancellationToken,
|
|
);
|
|
final expectation = expectLater(
|
|
upload,
|
|
throwsA(isA<UploadCancelledException>()),
|
|
);
|
|
await transcodeStarted.future;
|
|
cancellationToken.cancel();
|
|
transcodeFinished.complete(outputFile.path);
|
|
await expectation;
|
|
|
|
expect(uploadRequested, isFalse);
|
|
expect(await outputFile.exists(), isFalse);
|
|
} finally {
|
|
await sourceFile.parent.delete(recursive: true);
|
|
await outputDirectory.delete(recursive: true);
|
|
}
|
|
},
|
|
);
|
|
|
|
test(
|
|
'uploads a generated poster and links it from the video imeta',
|
|
() async {
|
|
final keychain = nostr.Keys.generate();
|
|
final requestTypes = <String>[];
|
|
final client = http_testing.MockClient((request) async {
|
|
final type = request.headers['Content-Type']!;
|
|
requestTypes.add(type);
|
|
final isPoster = type == 'image/jpeg';
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': isPoster
|
|
? 'https://relay.example/media/poster.jpg'
|
|
: 'https://relay.example/media/test.mp4',
|
|
'sha256':
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
'size': 32,
|
|
'type': type,
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final (xfile, tempFile) = await writeTempVideo(
|
|
buildFtypHeader('qt '),
|
|
'clip.mov',
|
|
);
|
|
try {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: keychain.nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => xfile,
|
|
pickGalleryImage: () async => null,
|
|
transcodeVideoToMp4: (path) async {
|
|
final outDir = await Directory.systemTemp.createTemp(
|
|
'transcode_',
|
|
);
|
|
final outFile = File('${outDir.path}/out.mp4');
|
|
await outFile.writeAsBytes(buildFtypHeader('isom'));
|
|
return outFile.path;
|
|
},
|
|
generateVideoPoster: (_) async => _jpegBytes,
|
|
sanitizeImageBytes: (bytes, _) async => bytes,
|
|
now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000),
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadVideo();
|
|
|
|
expect(descriptor?.image, 'https://relay.example/media/poster.jpg');
|
|
expect(
|
|
descriptor?.toImetaTag(),
|
|
contains('image https://relay.example/media/poster.jpg'),
|
|
);
|
|
expect(requestTypes, ['video/mp4', 'image/jpeg']);
|
|
} finally {
|
|
await tempFile.parent.delete(recursive: true);
|
|
}
|
|
},
|
|
);
|
|
|
|
test(
|
|
'falls back to the picked video when the exported poster fails',
|
|
() async {
|
|
final sourcePaths = <String>[];
|
|
final client = http_testing.MockClient((request) async {
|
|
final type = request.headers['Content-Type']!;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': type == 'image/jpeg'
|
|
? 'https://relay.example/media/poster.jpg'
|
|
: 'https://relay.example/media/test.mp4',
|
|
'sha256':
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
'size': request.bodyBytes.length,
|
|
'type': type,
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final (xfile, tempFile) = await writeTempVideo(
|
|
buildFtypHeader('qt '),
|
|
'clip.mov',
|
|
);
|
|
try {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => xfile,
|
|
pickGalleryImage: () async => null,
|
|
transcodeVideoToMp4: (path) async {
|
|
final outDir = await Directory.systemTemp.createTemp(
|
|
'transcode_',
|
|
);
|
|
final outFile = File('${outDir.path}/out.mp4');
|
|
await outFile.writeAsBytes(buildFtypHeader('isom'));
|
|
return outFile.path;
|
|
},
|
|
generateVideoPoster: (path) async {
|
|
sourcePaths.add(path);
|
|
if (sourcePaths.length == 1) throw Exception('frame unavailable');
|
|
return _jpegBytes;
|
|
},
|
|
sanitizeImageBytes: (bytes, _) async => bytes,
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadVideo();
|
|
|
|
expect(sourcePaths, hasLength(2));
|
|
expect(sourcePaths.last, xfile.path);
|
|
expect(descriptor?.image, 'https://relay.example/media/poster.jpg');
|
|
} finally {
|
|
await tempFile.parent.delete(recursive: true);
|
|
}
|
|
},
|
|
);
|
|
|
|
test('returns null when video picker is cancelled', () async {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: null,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
);
|
|
|
|
final result = await service.pickAndUploadVideo();
|
|
expect(result, isNull);
|
|
});
|
|
|
|
test('rejects videos over 100MB', () async {
|
|
// Create a temp file with 101MB of zeros.
|
|
final dir = await Directory.systemTemp.createTemp('video_size_test_');
|
|
final file = File('${dir.path}/huge.mp4');
|
|
final raf = await file.open(mode: FileMode.write);
|
|
await raf.truncate(101 * 1024 * 1024);
|
|
await raf.close();
|
|
|
|
try {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: null,
|
|
pickGalleryVideo: () async => XFile(file.path),
|
|
pickGalleryImage: () async => null,
|
|
);
|
|
|
|
await expectLater(
|
|
() => service.pickAndUploadVideo(),
|
|
throwsA(
|
|
isA<Exception>().having(
|
|
(e) => e.toString(),
|
|
'message',
|
|
contains('too large'),
|
|
),
|
|
),
|
|
);
|
|
} finally {
|
|
await dir.delete(recursive: true);
|
|
}
|
|
});
|
|
});
|
|
}
|