file upload actor

This commit is contained in:
randogoth 2026-06-08 11:08:00 +03:00
parent fca846eb43
commit f55083ea1b
6 changed files with 682 additions and 0 deletions

View file

@ -0,0 +1,323 @@
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),
),
);