324 lines
10 KiB
Dart
324 lines
10 KiB
Dart
|
|
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<UploaderRegistered>(),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
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<FileUploaded>());
|
||
|
|
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<String, dynamic>;
|
||
|
|
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<String, dynamic>;
|
||
|
|
|
||
|
|
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<FileUploaded>());
|
||
|
|
});
|
||
|
|
|
||
|
|
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<Exception>()),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('failed PUT throws', () async {
|
||
|
|
await expectLater(
|
||
|
|
makeActor(makeClient(putStatus: 500)).handle(
|
||
|
|
UploadFile(bytes: Uint8List(1), contentType: 'image/jpeg'),
|
||
|
|
),
|
||
|
|
throwsA(isA<Exception>()),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
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<Exception>()),
|
||
|
|
);
|
||
|
|
expect(requestMade, isFalse);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Helpers
|
||
|
|
|
||
|
|
Map<String, dynamic> _extractEvent(FakeRelayConnection relay) {
|
||
|
|
final raw = relay.sentMessages
|
||
|
|
.firstWhere((m) => (jsonDecode(m) as List)[0] == 'EVENT');
|
||
|
|
return (jsonDecode(raw) as List)[1] as Map<String, dynamic>;
|
||
|
|
}
|
||
|
|
|
||
|
|
Map<String, String> _tagsMap(Map<String, dynamic> event) =>
|
||
|
|
Map.fromEntries(
|
||
|
|
(event['tags'] as List).map(
|
||
|
|
(t) => MapEntry((t as List)[0] as String, t[1] as String),
|
||
|
|
),
|
||
|
|
);
|