import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:image/image.dart' as img; import 'package:ikar/core/transport/ikar_network.dart'; import 'live_ikar_smoke.dart' as live; Future main(List args) async { final label = args.isEmpty ? 'after' : args.first; final base = Uri.parse('https://ikar.kusoft.xyz'); final client = IkarNetwork.client(); final checks = []; live.Device? owner; final report = {'passed': false, 'checks': checks}; try { final stamp = DateTime.now().microsecondsSinceEpoch; owner = await live.Device.create( base, 'node', '+999$stamp', 'Avatar verification', ); final input = img.decodePng( await File('assets/ikar_icon.png').readAsBytes(), )!; final jpeg = img.encodeJpg(img.copyResize(input, width: 128, height: 128)); Future upload(List bytes) async { final slot = await owner!.ok(80, {'count': 1, 'profile': true}); final request = await client.postUrl(Uri.parse(slot['url'])); request.headers.contentType = ContentType('image', 'jpeg'); request.contentLength = bytes.length; request.add(bytes); final response = await request.close(); live.check(response.statusCode == 200, 'Avatar upload failed'); final token = jsonDecode(await utf8.decodeStream(response))['photoToken']; final pushed = owner.transport.events .firstWhere((event) => event['opcode'] == 159) .timeout(const Duration(seconds: 10)); final result = await owner.ok(16, { 'photoToken': token, 'avatarType': 'USER_AVATAR', }); final push = await pushed; report['profilePushWrapped'] = push['payload']?['profile'] is Map; return result['profile']['contact'] as Map; } final first = await upload(jpeg); final url = Uri.parse(first['baseUrl'] as String); report['absoluteAvatarUrl'] = url.isAbsolute; report['photoIdReturned'] = first['photoId'] is int; live.check( url.isAbsolute && url.scheme == 'https', 'Server returned a relative avatar URL', ); live.check(first['photoId'] is int, 'Server omitted avatar photo ID'); live.check( report['profilePushWrapped'] == true, 'Profile push has incompatible structure', ); checks.add( 'upload commits absolute HTTPS URL, photo ID and profile update push', ); final image = await (await client.getUrl(url)).close(); final fetched = await image.fold>( [], (bytes, chunk) => bytes..addAll(chunk), ); live.check( image.statusCode == 200 && sha256.convert(fetched) == sha256.convert(jpeg), 'Avatar download differs from uploaded photo', ); checks.add('avatar image renders through a normal HTTP image loader'); final history = await owner.ok(39, {'contactId': owner.id}); live.check( history['ids'][0] == first['photoId'] && Uri.parse(history['urls'][0]).isAbsolute, 'Avatar gallery differs from profile', ); final login = await owner.ok(19, {}); live.check( login['profile']['contact']['photoId'] == first['photoId'], 'Avatar did not survive profile reload', ); checks.add('avatar gallery and profile reload preserve current photo ID'); final privacyRequest = await client.putUrl( base.resolve('/api/users/me/privacy'), ); privacyRequest.headers.set( 'Authorization', 'Bearer ${owner.transport.token}', ); privacyRequest.headers.contentType = ContentType.json; privacyRequest.write( jsonEncode({ 'phoneNumberVisibility': 'nobody', 'lastSeenVisibility': 'nobody', 'profilePhotoVisibility': 'nobody', }), ); final privacyResponse = await privacyRequest.close(); await privacyResponse.drain(); live.check( privacyResponse.statusCode == 200, 'Could not restrict avatar privacy', ); final privateImage = await (await client.getUrl(url)).close(); await privateImage.drain(); live.check( privateImage.statusCode == 200, 'Owner cannot display private avatar', ); final anonymousImage = await (await client.getUrl( url.replace(query: ''), )).close(); await anonymousImage.drain(); live.check( anonymousImage.statusCode == 404, 'Private avatar exposed anonymously', ); final scopedRequest = await client.getUrl(base.resolve('/api/users/me')); scopedRequest.headers.set( 'Authorization', 'Bearer ${url.queryParameters['access_token']}', ); final scopedResponse = await scopedRequest.close(); await scopedResponse.drain(); live.check( scopedResponse.statusCode == 401, 'Avatar token granted broader API access', ); checks.add( 'private avatar renders for owner, stays hidden anonymously, and token is resource scoped', ); final nextJpeg = img.encodeJpg(img.copyRotate(input, angle: 90)); final replacement = await upload(nextJpeg); live.check( replacement['photoId'] != first['photoId'] && replacement['baseUrl'] != first['baseUrl'], 'Replacing avatar reused the old image cache key', ); final oldImage = await (await client.getUrl(url)).close(); await oldImage.drain(); live.check( oldImage.statusCode == 404, 'Replaced avatar is still available', ); checks.add( 'replacement changes photo ID and URL and removes previous image', ); final removed = await owner.ok(43, {'photoId': replacement['photoId']}); live.check( removed['profile']['contact']['baseUrl'] == null && removed['profile']['contact']['photoId'] == null, 'Avatar deletion failed', ); live.check( (await owner.ok(39, {'contactId': owner.id}))['urls'].isEmpty, 'Deleted avatar remains in gallery', ); checks.add('avatar deletion clears profile and gallery'); report['passed'] = true; } catch (error) { report['error'] = error.toString(); rethrow; } finally { report['timestamp'] = DateTime.now().toUtc().toIso8601String(); await File( 'artifacts/avatar-$label.json', ).writeAsString(const JsonEncoder.withIndent(' ').convert(report)); stdout.writeln(jsonEncode(report)); if (owner != null) { try { await owner.ok(199, {}); } finally { await owner.close(); } } client.close(force: true); } }