Files
IKAR/tool/live_ikar_smoke.dart
T

540 lines
17 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../lib/backend/ikar_transport.dart';
import '../lib/core/transport/ikar_network.dart';
void check(bool condition, String name) {
if (!condition) throw StateError(name);
}
class Device {
Device(this.transport, this.process, this.lines);
final IkarTransport transport;
final Process process;
final StreamIterator<String> lines;
int id = 0;
String refresh = '';
static Future<Device> create(
Uri base,
String node,
String phone,
String name,
) async {
final process = await Process.start(node, ['tool/smoke_key.cjs']);
final lines = StreamIterator(
process.stdout.transform(utf8.decoder).transform(const LineSplitter()),
);
final device = Device(IkarTransport(base), process, lines);
try {
await lines.moveNext();
final identity = jsonDecode(lines.current) as Map;
final challenge = await device.ok(17, {'phone': phone, ...identity});
process.stdin.writeln(challenge['signaturePayload']);
await lines.moveNext();
final verified = await device.ok(18, {
'token': challenge['token'],
'deviceSignature': lines.current,
});
final registration = verified['tokenAttrs']['REGISTER'];
device.transport.token = registration['token'];
final registered = await device.ok(23, {
'firstName': name,
'lastName': 'Live test',
});
device.transport.token = registered['token'];
device.refresh = registered['refreshToken'];
device.id = registered['accountId'] as int;
await device.ok(19, {});
await device.transport.connectEvents();
return device;
} catch (_) {
await device.close();
rethrow;
}
}
Future<Map<String, dynamic>> ok(
int opcode,
Map<dynamic, dynamic> payload,
) async {
final response = await transport.request(opcode, payload);
check(response['cmd'] == 1, 'Opcode $opcode: ${response['payload']}');
return Map<String, dynamic>.from(response['payload'] as Map);
}
Future<void> close() async {
await transport.close();
await lines.cancel();
process.kill();
}
}
Map decodeCall(String vcp) {
final block = base64Decode(vcp.substring(vcp.indexOf(':') + 1));
var index = 1, length = block[0] >> 4;
if (length == 15) {
int byte;
do {
byte = block[index++];
length += byte;
} while (byte == 255);
}
return jsonDecode(utf8.decode(block.sublist(index, index + length))) as Map;
}
Future<void> main(List<String> args) async {
final base = Uri.parse(args.isEmpty ? 'https://ikar.kusoft.xyz' : args[0]);
final node = Platform.environment['IKAR_TEST_NODE'] ?? 'node';
final checks = <String>[];
final devices = <Device>[];
final client = IkarNetwork.client();
try {
final stamp = DateTime.now().millisecondsSinceEpoch.toString().substring(5);
for (var i = 0; i < 3; i++) {
devices.add(
await Device.create(base, node, '+999$stamp$i', 'IKAR test ${i + 1}'),
);
}
checks.add('device-key registration and SignalR authentication');
final a = devices[0], b = devices[1], outsider = devices[2];
final chatId = a.id ^ b.id;
final delivered = b.transport.events
.firstWhere((event) => event['opcode'] == 128)
.timeout(const Duration(seconds: 15));
final sent = await a.ok(64, {
'chatId': chatId,
'message': {
'text': 'IKAR live delivery',
'cid': DateTime.now().millisecondsSinceEpoch,
},
});
final message = sent['message'] as Map;
await delivered;
checks.add('message delivered to second client over WebSocket');
final history = await b.ok(49, {'chatId': chatId, 'count': 40});
check(
(history['messages'] as List).any((item) => item['id'] == message['id']),
'History missing sent message',
);
final forbidden = await outsider.transport.request(49, {
'chatId': chatId,
'count': 40,
});
check(forbidden['cmd'] == 3, 'Nonmember could read chat');
checks.add('persistent history and nonmember isolation');
final retryCid = DateTime.now().microsecondsSinceEpoch;
final richPayload = {
'chatId': chatId,
'message': {
'text': ' IKAR formatting ',
'cid': retryCid,
'elements': [
{'type': 'STRONG', 'from': 2, 'length': 4},
],
'link': {'type': 'REPLY', 'messageId': message['id']},
},
};
final rich = await a.ok(64, richPayload);
final retry = await a.ok(64, richPayload);
check(
retry['message']['id'] == rich['message']['id'],
'Retry created a duplicate',
);
final richHistory =
(await b.ok(49, {'chatId': chatId}))['messages'] as List;
final storedRich = richHistory.firstWhere(
(m) => m['id'] == rich['message']['id'],
);
check(
storedRich['text'] == ' IKAR formatting ' &&
(storedRich['elements'] as List).length == 1 &&
storedRich['link']['message']['id'] == message['id'],
'Formatting or reply was not retained',
);
checks.add('retry deduplication, formatting and quoted reply');
final catalog = await a.ok(27, {'type': 'STICKER', 'sync': 0});
final setId = catalog['sections'][0]['stickerSets'][0];
final set = (await a.ok(28, {
'type': 'STICKER_SET',
'ids': [setId],
}))['stickerSets'][0];
final stickers =
(await b.ok(28, {
'type': 'STICKER',
'ids': set['stickers'],
}))['stickers']
as List;
check(stickers.length == 8, 'Sticker catalog empty');
final png = await (await client.getUrl(
Uri.parse(stickers.first['url']),
)).close();
check(png.statusCode == 200, 'Sticker image unavailable');
await png.drain<void>();
final stickerMessage = await a.ok(64, {
'chatId': chatId,
'message': {
'attaches': [
{'_type': 'STICKER', 'stickerId': stickers.first['id']},
],
},
});
check(
stickerMessage['message']['attaches'][0]['stickerId'] ==
stickers.first['id'],
'Sticker not retained',
);
await a.ok(29, {'type': 'FAVORITE_STICKER_SET', 'id': setId});
check(
(await a.ok(27, {
'type': 'FAVORITE_STICKER',
'sync': 0,
}))['sections'][0]['stickerSets'].contains(setId),
'Sticker favorite not saved',
);
checks.add('own sticker catalog, image download, sending and favorites');
await a.ok(67, {
'chatId': chatId,
'messageId': message['id'],
'text': 'IKAR edited',
});
await b.ok(178, {
'chatId': chatId,
'messageId': message['id'],
'reaction': {'reactionType': 'EMOJI', 'id': '👍'},
});
await a.ok(257, {'chatId': chatId, 'value': false});
check(
(await b.transport.request(178, {
'chatId': chatId,
'messageId': message['id'],
'reaction': {'reactionType': 'EMOJI', 'id': '❤️'},
}))['cmd'] ==
3,
'Reaction policy ignored',
);
await a.ok(257, {'chatId': chatId, 'value': true});
await b.ok(179, {'chatId': chatId, 'messageId': message['id']});
checks.add('editing, reactions and reaction policy');
final session = await a.transport.refresh(a.refresh);
check(session != null, 'Refresh rejected');
check(
await a.transport.refresh(a.refresh) == null,
'Refresh replay accepted',
);
a.transport.token = session!['accessToken'];
await a.ok(19, {});
checks.add('refresh rotation and replay rejection');
final reserved = await a.ok(87, {'count': 1});
final slot = (reserved['info'] as List).first as Map;
final uploadUri = Uri.parse(
slot['url'],
).replace(queryParameters: {'filename': 'ikar-live.txt'});
final upload = await client.postUrl(uploadUri);
upload.headers.contentType = ContentType.text;
upload.write('IKAR attachment proof');
final uploadResponse = await upload.close();
check(uploadResponse.statusCode == 200, 'Upload rejected');
final uploaded = jsonDecode(await utf8.decodeStream(uploadResponse)) as Map;
final withFile = await a.ok(64, {
'chatId': chatId,
'message': {
'text': 'IKAR file',
'attaches': [
{
'_type': 'FILE',
'token': uploaded['token'],
'name': 'ikar-live.txt',
},
],
},
});
final fileMessage = withFile['message'] as Map;
final resolved = await b.ok(88, {
'chatId': chatId,
'messageId': fileMessage['id'],
});
final download = Uri.parse(resolved['url']);
final signed = await (await client.getUrl(download)).close();
check(
signed.statusCode == 200 &&
await utf8.decodeStream(signed) == 'IKAR attachment proof',
'Signed media download failed',
);
final unsigned = await (await client.getUrl(
download.replace(query: ''),
)).close();
check(unsigned.statusCode == 401, 'Media accessible without authorization');
await unsigned.drain<void>();
checks.add('upload, receiver media download and unsigned rejection');
final otherPath = download.replace(path: '/hubs/messenger');
final pathRejected = await (await client.getUrl(otherPath)).close();
check(
pathRejected.statusCode == 401,
'Media token reused outside its resource',
);
await pathRejected.drain<void>();
checks.add('media token limited to its signed resource');
final voiceSlots = await a.ok(87, {'count': 1});
final voiceUri = Uri.parse(
voiceSlots['info'][0]['url'],
).replace(queryParameters: {'filename': 'ikar-speech-test.wav'});
final voiceUpload = await client.postUrl(voiceUri);
voiceUpload.headers.contentType = ContentType('audio', 'wav');
voiceUpload.add(await File('artifacts/speech-test.wav').readAsBytes());
final voiceResponse = await voiceUpload.close();
check(voiceResponse.statusCode == 200, 'Voice upload rejected');
final voiceToken = jsonDecode(
await utf8.decodeStream(voiceResponse),
)['token'];
final voice = await a.ok(64, {
'chatId': chatId,
'message': {
'attaches': [
{
'_type': 'AUDIO',
'token': voiceToken,
'name': 'ikar-speech-test.wav',
},
],
},
});
final voiceId = voice['message']['id'];
final completed = b.transport.events
.firstWhere(
(event) =>
event['opcode'] == 293 &&
event['payload']?['messageId'] == voiceId,
)
.timeout(const Duration(seconds: 120));
final transcriptRequest = await b.ok(202, {
'chatId': chatId,
'messageId': voiceId,
});
check(
transcriptRequest['transcriptionStatus'] == 0 ||
transcriptRequest['transcriptionStatus'] == 1,
'Speech job rejected',
);
final transcript = await completed;
check(
(transcript['payload']['transcription'] as String).contains('привет'),
'Speech was not recognized',
);
check(
(await b.ok(202, {
'chatId': chatId,
'messageId': voiceId,
}))['transcriptionStatus'] ==
1,
'Speech result not persisted',
);
check(
(await outsider.transport.request(202, {
'chatId': chatId,
'messageId': voiceId,
}))['cmd'] ==
3,
'Speech leaked to nonmember',
);
checks.add(
'voice recognition on Pi, completion push and private persisted result',
);
final group = await a.ok(64, {
'message': {
'attaches': [
{
'_type': 'CONTROL',
'event': 'new',
'chatType': 'CHAT',
'title': 'IKAR live group',
'userIds': [b.id],
},
],
},
});
final groupId = group['chat']['id'];
await b.ok(64, {
'chatId': groupId,
'message': {'text': 'Member can post'},
});
final channel = await a.ok(64, {
'message': {
'attaches': [
{
'_type': 'CONTROL',
'event': 'new',
'chatType': 'CHANNEL',
'title': 'IKAR live channel',
'userIds': [b.id],
},
],
},
});
final channelId = channel['chat']['id'];
check(
(await b.transport.request(64, {
'chatId': channelId,
'message': {'text': 'Forbidden post'},
}))['cmd'] ==
3,
'Channel member can post without rights',
);
await a.ok(64, {
'chatId': channelId,
'message': {'text': 'Owner post'},
});
checks.add('groups, channels and posting rights');
final poll = await a.ok(64, {
'chatId': groupId,
'message': {
'attaches': [
{
'_type': 'POLL',
'title': 'IKAR poll',
'answers': [
{'text': 'Yes'},
{'text': 'No'},
],
},
],
},
});
final pollId = poll['message']['attaches'][0]['pollId'];
final vote = await b.ok(304, {
'chatId': groupId,
'pollId': pollId,
'answersIds': [1],
});
check(vote['state']['total'] == 1, 'Vote not counted');
checks.add('poll creation and persistent voting');
final futureText = 'IKAR due ${DateTime.now().millisecondsSinceEpoch}';
final due = b.transport.events
.firstWhere(
(event) =>
event['opcode'] == 128 &&
(event['payload']?['message']?['text'] == futureText),
)
.timeout(const Duration(seconds: 25));
await a.ok(64, {
'chatId': chatId,
'message': {
'text': futureText,
'delayedAttributes': {
'timeToFire': DateTime.now().millisecondsSinceEpoch + 7000,
},
},
});
final beforeDue = await b.ok(49, {'chatId': chatId});
check(
!(beforeDue['messages'] as List).any(
(item) => item['text'] == futureText,
),
'Queued message leaked before scheduled time',
);
await due;
checks.add('server scheduling and private queue');
final incoming = b.transport.events
.firstWhere((event) => event['opcode'] == 137)
.timeout(const Duration(seconds: 15));
final call = await a.ok(78, {
'calleeIds': [b.id],
'isVideo': true,
'conversationId': 'ikar-smoke-${DateTime.now().millisecondsSinceEpoch}',
});
final pushed = await incoming;
final params = decodeCall(pushed['payload']['vcp']);
final callerUri = Uri.parse(
jsonDecode(call['internalCallerParams'])['endpoint'],
);
final calleeUri = Uri.parse(
params['wse'],
).replace(queryParameters: {'token': params['tkn']});
final caller = await IkarNetwork.connectWebSocket(callerUri.toString());
final callee = await IkarNetwork.connectWebSocket(calleeUri.toString());
final calleeEvents = callee
.map((frame) => jsonDecode(frame as String) as Map)
.asBroadcastStream();
final callerEvents = caller
.map((frame) => jsonDecode(frame as String) as Map)
.asBroadcastStream();
final hold = callerEvents.listen((_) {});
try {
final receivedSdp = calleeEvents
.firstWhere(
(event) =>
event['notification'] == 'transmitted-data' &&
event['data']?['sdp'] != null,
)
.timeout(const Duration(seconds: 10));
caller.add(
jsonEncode({
'command': 'transmit-data',
'sequence': 1,
'data': {
'sdp': {'type': 'offer', 'sdp': 'IKAR test SDP'},
},
}),
);
check(
(await receivedSdp)['data']['sdp']['sdp'] == 'IKAR test SDP',
'SDP not relayed',
);
final receivedIce = calleeEvents
.firstWhere(
(event) =>
event['notification'] == 'transmitted-data' &&
event['data']?['candidate'] != null,
)
.timeout(const Duration(seconds: 10));
caller.add(
jsonEncode({
'command': 'transmit-data',
'sequence': 2,
'data': {
'candidate': {'candidate': 'IKAR test ICE'},
},
}),
);
await receivedIce;
caller.add(
jsonEncode({'command': 'hangup', 'sequence': 3, 'reason': 'FINISHED'}),
);
checks.add(
'video call invitation, SDP and ICE relay over authenticated WebSockets',
);
} finally {
await hold.cancel();
await caller.close();
await callee.close();
}
await a.ok(53, {'chatId': groupId, 'forAll': true});
await a.ok(53, {'chatId': channelId, 'forAll': true});
await a.ok(66, {
'chatId': chatId,
'messageIds': [message['id'], fileMessage['id']],
'forMe': false,
});
checks.add('message deletion');
final report = {
'server': base.toString(),
'tlsValidation': base.scheme == 'https',
'checks': checks,
'passed': checks.length,
'timestamp': DateTime.now().toUtc().toIso8601String(),
};
await File(
'artifacts/live-smoke.json',
).writeAsString(const JsonEncoder.withIndent(' ').convert(report));
stdout.writeln(jsonEncode(report));
} finally {
client.close(force: true);
for (final device in devices) {
try {
await device.ok(199, {});
} catch (_) {}
await device.close();
}
}
}