import 'dart:convert'; import 'dart:typed_data'; import 'package:bip340/bip340.dart' as bip340; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:swarm/src/nip44.dart'; import 'package:swarm/swarm.dart'; import 'package:test/test.dart'; import 'fake_relay_connection.dart'; // Two well-known secp256k1 key pairs (k=1 and k=2). const _sec1 = '0000000000000000000000000000000000000000000000000000000000000001'; const _sec2 = '0000000000000000000000000000000000000000000000000000000000000002'; const _kServicePubkey = '7de7d9f8c0aad59b0e325eef5e1c7d777a3b4c1b3df8ac71c72dae763a161435'; const _kAppName = 'test-app'; const _kAppSecret = 'test-secret-1234'; const _kUploadUrl = 'https://storage.example.com/presigned/object'; const _kCdnUrl = 'https://cdn.otherwhere.app/test/pubkey/uuid.jpg'; void main() { final pub1 = bip340.getPublicKey(_sec1); final pub2 = bip340.getPublicKey(_sec2); // --------------------------------------------------------------------------- group('NIP-44', () { test('conversation key is symmetric', () { final ck1 = nip44ConversationKey(_sec1, pub2); final ck2 = nip44ConversationKey(_sec2, pub1); expect(ck1, equals(ck2)); }); test('encrypt/decrypt round-trip (sec1 → pub2)', () { const plaintext = 'hello upload service'; final payload = nip44Encrypt(plaintext, _sec1, pub2); expect(nip44Decrypt(payload, _sec2, pub1), equals(plaintext)); }); test('encrypt/decrypt round-trip (sec2 → pub1)', () { const plaintext = 'symmetric direction test'; final payload = nip44Encrypt(plaintext, _sec2, pub1); expect(nip44Decrypt(payload, _sec1, pub2), equals(plaintext)); }); test('tampered MAC throws', () { final payload = nip44Encrypt('tamper me', _sec1, pub2); final bytes = base64.decode(payload); bytes[bytes.length - 1] ^= 0xff; expect( () => nip44Decrypt(base64.encode(bytes), _sec2, pub1), throwsException, ); }); test('unknown version byte throws', () { final payload = nip44Encrypt('v check', _sec1, pub2); final bytes = base64.decode(payload); bytes[0] = 0x01; expect( () => nip44Decrypt(base64.encode(bytes), _sec2, pub1), throwsException, ); }); }); // --------------------------------------------------------------------------- group('FileUploadActor — registration', () { late FakeRelayConnection fakeRelay; late FileUploadActor actor; setUp(() { fakeRelay = FakeRelayConnection()..autoRespond = false; actor = FileUploadActor( privateKey: _sec1, appName: _kAppName, appSecret: _kAppSecret, relayUrl: 'ws://test-relay', connectionFactory: (_) => fakeRelay, ); }); tearDown(() => fakeRelay.dispose()); test('returns UploaderRegistered', () async { expect( await actor.handle(const RegisterForUpload()), isA(), ); }); test('sends one EVENT to the relay', () async { await actor.handle(const RegisterForUpload()); final eventMsgs = fakeRelay.sentMessages .where((m) => (jsonDecode(m) as List)[0] == 'EVENT') .toList(); expect(eventMsgs, hasLength(1)); }); test('event kind is 5392', () async { await actor.handle(const RegisterForUpload()); final event = _extractEvent(fakeRelay); expect(event['kind'], equals(5392)); }); test('event has correct p and app tags', () async { await actor.handle(const RegisterForUpload()); final tags = _tagsMap(_extractEvent(fakeRelay)); expect(tags['p'], equals(_kServicePubkey)); expect(tags['app'], equals(_kAppName)); }); test('event signature verifies', () async { await actor.handle(const RegisterForUpload()); final event = _extractEvent(fakeRelay); expect( bip340.verify( event['pubkey'] as String, event['id'] as String, event['sig'] as String, ), isTrue, ); }); test('content decrypts to app secret', () async { await actor.handle(const RegisterForUpload()); final content = _extractEvent(fakeRelay)['content'] as String; // Decrypt using ECDH(userPriv, servicePub) — same as server-side // ECDH(servicePriv, userPub) by symmetry. expect( nip44Decrypt(content, _sec1, _kServicePubkey), equals(_kAppSecret), ); }); }); // --------------------------------------------------------------------------- group('FileUploadActor — upload', () { FileUploadActor makeActor(http.Client client) => FileUploadActor( privateKey: _sec1, appName: _kAppName, appSecret: _kAppSecret, apiBaseUrl: 'https://api.test', httpClient: client, ); MockClient makeClient({ int requestStatus = 200, int putStatus = 200, }) => MockClient((request) async { if (request.url.path == '/upload/request') { if (requestStatus != 200) { return http.Response('error', requestStatus); } return http.Response( jsonEncode({ 'upload_url': _kUploadUrl, 'cdn_url': _kCdnUrl, }), 200, ); } if (request.url.toString() == _kUploadUrl) { return http.Response('', putStatus); } return http.Response('not found', 404); }); test('returns FileUploaded with CDN url', () async { final result = await makeActor(makeClient()).handle( UploadFile(bytes: Uint8List.fromList([1, 2, 3]), contentType: 'image/jpeg'), ); expect(result, isA()); expect((result as FileUploaded).cdnUrl, equals(_kCdnUrl)); }); test('POST body contains app and content_type', () async { http.Request? captured; final client = MockClient((request) async { if (request.url.path == '/upload/request') { captured = request; return http.Response( jsonEncode({'upload_url': _kUploadUrl, 'cdn_url': _kCdnUrl}), 200, ); } return http.Response('', 200); }); await makeActor(client).handle( UploadFile(bytes: Uint8List(1), contentType: 'image/png'), ); final body = jsonDecode(captured!.body) as Map; expect(body['app'], equals(_kAppName)); expect(body['content_type'], equals('image/png')); }); test('NIP-98 Authorization token is valid signed kind-27235 event', () async { http.Request? captured; final client = MockClient((request) async { if (request.url.path == '/upload/request') { captured = request; return http.Response( jsonEncode({'upload_url': _kUploadUrl, 'cdn_url': _kCdnUrl}), 200, ); } return http.Response('', 200); }); await makeActor(client).handle( UploadFile(bytes: Uint8List(1), contentType: 'image/jpeg'), ); final authHeader = captured!.headers['Authorization']!; expect(authHeader, startsWith('Nostr ')); final event = jsonDecode( utf8.decode(base64.decode(authHeader.substring(6))), ) as Map; expect(event['kind'], equals(27235)); expect(event['content'], equals('')); final tags = _tagsMap(event); expect(tags['u'], equals('https://api.test/upload/request')); expect(tags['method'], equals('POST')); expect( bip340.verify( event['pubkey'] as String, event['id'] as String, event['sig'] as String, ), isTrue, ); }); test('PUT uses presigned URL with correct headers and body', () async { http.Request? putRequest; final client = MockClient((request) async { if (request.url.path == '/upload/request') { return http.Response( jsonEncode({'upload_url': _kUploadUrl, 'cdn_url': _kCdnUrl}), 200, ); } putRequest = request; return http.Response('', 200); }); final bytes = Uint8List.fromList([0xff, 0xd8, 0xff, 0xe0]); await makeActor(client).handle( UploadFile(bytes: bytes, contentType: 'image/jpeg'), ); expect(putRequest!.method, equals('PUT')); expect(putRequest!.url.toString(), equals(_kUploadUrl)); expect(putRequest!.headers['Content-Type'], equals('image/jpeg')); expect(putRequest!.bodyBytes, equals(bytes)); }); test('PUT 204 is treated as success', () async { final result = await makeActor(makeClient(putStatus: 204)).handle( UploadFile(bytes: Uint8List(1), contentType: 'image/jpeg'), ); expect(result, isA()); }); test('non-200 upload request throws', () async { for (final status in [401, 403, 429]) { await expectLater( makeActor(makeClient(requestStatus: status)).handle( UploadFile(bytes: Uint8List(1), contentType: 'image/jpeg'), ), throwsA(isA()), ); } }); test('failed PUT throws', () async { await expectLater( makeActor(makeClient(putStatus: 500)).handle( UploadFile(bytes: Uint8List(1), contentType: 'image/jpeg'), ), throwsA(isA()), ); }); test('file over 10 MB throws before making any request', () async { var requestMade = false; final client = MockClient((_) async { requestMade = true; return http.Response('', 200); }); await expectLater( makeActor(client).handle( UploadFile( bytes: Uint8List(10 * 1024 * 1024 + 1), contentType: 'image/jpeg', ), ), throwsA(isA()), ); expect(requestMade, isFalse); }); }); } // --------------------------------------------------------------------------- // Helpers Map _extractEvent(FakeRelayConnection relay) { final raw = relay.sentMessages .firstWhere((m) => (jsonDecode(m) as List)[0] == 'EVENT'); return (jsonDecode(raw) as List)[1] as Map; } Map _tagsMap(Map event) => Map.fromEntries( (event['tags'] as List).map( (t) => MapEntry((t as List)[0] as String, t[1] as String), ), );