Files
IKAR/tool/verify_avatar.dart
sevenhill 12b692f4a9
Tests / test (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Fix avatar URLs, photo IDs and profile update notifications
2026-10-07 22:34:40 +03:00

184 lines
6.4 KiB
Dart

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<void> main(List<String> args) async {
final label = args.isEmpty ? 'after' : args.first;
final base = Uri.parse('https://ikar.kusoft.xyz');
final client = IkarNetwork.client();
final checks = <String>[];
live.Device? owner;
final report = <String, dynamic>{'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<Map> upload(List<int> 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<List<int>>(
[],
(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<void>();
live.check(
privacyResponse.statusCode == 200,
'Could not restrict avatar privacy',
);
final privateImage = await (await client.getUrl(url)).close();
await privateImage.drain<void>();
live.check(
privateImage.statusCode == 200,
'Owner cannot display private avatar',
);
final anonymousImage = await (await client.getUrl(
url.replace(query: ''),
)).close();
await anonymousImage.drain<void>();
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<void>();
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<void>();
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);
}
}