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
184 lines
6.4 KiB
Dart
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);
|
|
}
|
|
}
|