Files
buzz/mobile/test/shared/relay/media_upload_test.dart
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

1566 lines
47 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', '1700000600'])));
});
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', '1700000300'])));
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('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);
}
});
});
}