540 lines
17 KiB
Dart
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();
|
|
}
|
|
}
|
|
}
|