meshcore-open/test/services/retry_and_protocol_test.dart

619 lines
17 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:meshcore_open/models/contact.dart';
import 'package:meshcore_open/models/message.dart';
import 'package:meshcore_open/services/message_retry_service.dart';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Replicates the SHA-256 computation from [MessageRetryService.computeExpectedAckHash]
/// so tests can cross-check without calling the real implementation twice.
int _manualAckHash(
int timestampSeconds,
int attemptMasked, // already masked to 0x03
String text,
Uint8List senderPubKey,
) {
final textBytes = utf8.encode(text);
final buffer = Uint8List(4 + 1 + textBytes.length + senderPubKey.length);
int offset = 0;
buffer[offset++] = timestampSeconds & 0xFF;
buffer[offset++] = (timestampSeconds >> 8) & 0xFF;
buffer[offset++] = (timestampSeconds >> 16) & 0xFF;
buffer[offset++] = (timestampSeconds >> 24) & 0xFF;
buffer[offset++] = attemptMasked & 0xFF;
buffer.setRange(offset, offset + textBytes.length, textBytes);
offset += textBytes.length;
buffer.setRange(offset, offset + senderPubKey.length, senderPubKey);
final hash = sha256.convert(buffer);
final bytes = Uint8List.fromList(hash.bytes.sublist(0, 4));
return (bytes[3] << 24) | (bytes[2] << 16) | (bytes[1] << 8) | bytes[0];
}
Uint8List _makeKey(int seed) {
final key = Uint8List(32);
for (int i = 0; i < 32; i++) {
key[i] = (seed + i) & 0xFF;
}
return key;
}
Uint8List _makeRecipientKey() {
final key = Uint8List(32);
for (int i = 0; i < 32; i++) {
key[i] = (0xAA + i) & 0xFF;
}
return key;
}
Contact _makeContact({
required Uint8List publicKey,
int pathLength = -1,
List<int> path = const [],
}) {
return Contact(
publicKey: publicKey,
name: 'Test',
type: 1,
pathLength: pathLength,
path: Uint8List.fromList(path),
lastSeen: DateTime.now(),
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
// Fixed inputs reused across groups
const int fixedTs = 1700000000;
const String fixedText = 'Hello mesh';
final Uint8List fixedKey = _makeKey(0x11);
final Uint8List recipientKey = _makeRecipientKey();
// -------------------------------------------------------------------------
group('computeExpectedAckHash — attempt masking', () {
test('attempts 03 all produce different hashes', () {
final hashes = List.generate(
4,
(i) => MessageRetryService.computeExpectedAckHash(
fixedTs,
i,
fixedText,
fixedKey,
),
);
// All four must be pairwise distinct
for (int i = 0; i < hashes.length; i++) {
for (int j = i + 1; j < hashes.length; j++) {
expect(
hashes[i],
isNot(equals(hashes[j])),
reason: 'attempt $i and attempt $j should produce different hashes',
);
}
}
});
test('attempt 4 produces same hash as attempt 0 (4 & 0x03 == 0)', () {
final hash0 = MessageRetryService.computeExpectedAckHash(
fixedTs,
0,
fixedText,
fixedKey,
);
final hash4 = MessageRetryService.computeExpectedAckHash(
fixedTs,
4,
fixedText,
fixedKey,
);
expect(hash4, equals(hash0));
});
test('attempt 5 produces same hash as attempt 1 (5 & 0x03 == 1)', () {
final hash1 = MessageRetryService.computeExpectedAckHash(
fixedTs,
1,
fixedText,
fixedKey,
);
final hash5 = MessageRetryService.computeExpectedAckHash(
fixedTs,
5,
fixedText,
fixedKey,
);
expect(hash5, equals(hash1));
});
test('attempt 7 produces same hash as attempt 3 (7 & 0x03 == 3)', () {
final hash3 = MessageRetryService.computeExpectedAckHash(
fixedTs,
3,
fixedText,
fixedKey,
);
final hash7 = MessageRetryService.computeExpectedAckHash(
fixedTs,
7,
fixedText,
fixedKey,
);
expect(hash7, equals(hash3));
});
test('same inputs always produce the same hash (deterministic)', () {
final first = MessageRetryService.computeExpectedAckHash(
fixedTs,
2,
fixedText,
fixedKey,
);
final second = MessageRetryService.computeExpectedAckHash(
fixedTs,
2,
fixedText,
fixedKey,
);
expect(first, equals(second));
});
test('hash matches manual SHA-256 computation', () {
for (int attempt = 0; attempt < 4; attempt++) {
final actual = MessageRetryService.computeExpectedAckHash(
fixedTs,
attempt,
fixedText,
fixedKey,
);
final expected = _manualAckHash(fixedTs, attempt, fixedText, fixedKey);
expect(
actual,
equals(expected),
reason: 'mismatch at attempt $attempt',
);
}
});
test('different timestamps produce different hashes', () {
final hashA = MessageRetryService.computeExpectedAckHash(
1700000000,
0,
fixedText,
fixedKey,
);
final hashB = MessageRetryService.computeExpectedAckHash(
1700000001,
0,
fixedText,
fixedKey,
);
expect(hashA, isNot(equals(hashB)));
});
test('different texts produce different hashes', () {
final hashA = MessageRetryService.computeExpectedAckHash(
fixedTs,
0,
'Hello mesh',
fixedKey,
);
final hashB = MessageRetryService.computeExpectedAckHash(
fixedTs,
0,
'Hello mesh!',
fixedKey,
);
expect(hashA, isNot(equals(hashB)));
});
test('different sender keys produce different hashes', () {
final keyA = _makeKey(0x01);
final keyB = _makeKey(0x02);
final hashA = MessageRetryService.computeExpectedAckHash(
fixedTs,
0,
fixedText,
keyA,
);
final hashB = MessageRetryService.computeExpectedAckHash(
fixedTs,
0,
fixedText,
keyB,
);
expect(hashA, isNot(equals(hashB)));
});
});
// -------------------------------------------------------------------------
group('buildSendTextMsgFrame — attempt encoding', () {
// Frame layout: [cmd(1)][txtType(1)][attempt(1)][timestamp(4)][pubKeyPrefix(6)][text][null(1)]
// So byte index 2 carries the raw attempt & 0xFF.
test('attempt 0 → byte[2] is 0', () {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 0,
timestampSeconds: fixedTs,
);
expect(frame[2], equals(0));
});
test('attempt 3 → byte[2] is 3', () {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 3,
timestampSeconds: fixedTs,
);
expect(frame[2], equals(3));
});
test('attempt 4 → byte[2] is 4 (raw value, not clamped to 3)', () {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 4,
timestampSeconds: fixedTs,
);
expect(frame[2], equals(4));
});
test('attempt 255 → byte[2] is 255', () {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 255,
timestampSeconds: fixedTs,
);
expect(frame[2], equals(255));
});
test('attempt 256 → byte[2] is 255 (clamped, not wrapped)', () {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 256,
timestampSeconds: fixedTs,
);
expect(frame[2], equals(255));
});
test('byte[0] is cmdSendTxtMsg (2)', () {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 0,
timestampSeconds: fixedTs,
);
expect(frame[0], equals(cmdSendTxtMsg));
});
test('byte[1] is txtTypePlain (0)', () {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 0,
timestampSeconds: fixedTs,
);
expect(frame[1], equals(txtTypePlain));
});
test('timestamp bytes[3..6] are little-endian encoded', () {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 0,
timestampSeconds: fixedTs,
);
final decoded =
frame[3] | (frame[4] << 8) | (frame[5] << 16) | (frame[6] << 24);
expect(decoded, equals(fixedTs));
});
test(
'pub key prefix (bytes 7..12) matches first 6 bytes of recipient key',
() {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 0,
timestampSeconds: fixedTs,
);
expect(frame.sublist(7, 13), equals(recipientKey.sublist(0, 6)));
},
);
test('frame is null-terminated after text', () {
final frame = buildSendTextMsgFrame(
recipientKey,
'hi',
attempt: 0,
timestampSeconds: fixedTs,
);
expect(frame.last, equals(0));
});
});
// -------------------------------------------------------------------------
group(
'ACK hash consistency between computeExpectedAckHash and firmware behavior',
() {
// The firmware reads the raw attempt byte from the frame, then masks it
// with & 3 when computing the ACK hash. Flutter does the same masking
// inside computeExpectedAckHash. So the two sides must agree.
test('attempt 4: flutter hash (4 & 3 = 0) equals hash for attempt 0', () {
// Flutter sends raw byte 4 in the frame, but computes hash with 4&3=0.
// Firmware reads 4, masks to 0, computes same hash → they match.
final hashFor4 = MessageRetryService.computeExpectedAckHash(
fixedTs,
4,
fixedText,
fixedKey,
);
final hashFor0 = MessageRetryService.computeExpectedAckHash(
fixedTs,
0,
fixedText,
fixedKey,
);
expect(hashFor4, equals(hashFor0));
// Also confirm the frame byte is raw 4, not 0
final frame = buildSendTextMsgFrame(
recipientKey,
fixedText,
attempt: 4,
timestampSeconds: fixedTs,
);
expect(frame[2], equals(4), reason: 'frame carries raw attempt byte');
});
test(
'attempt 3: flutter hash equals hash computed directly for attempt 3',
() {
// 3 & 3 == 3, so no wrapping — both sides agree.
final hashFor3 = MessageRetryService.computeExpectedAckHash(
fixedTs,
3,
fixedText,
fixedKey,
);
final hashFor3Direct = _manualAckHash(
fixedTs,
3,
fixedText,
fixedKey,
);
expect(hashFor3, equals(hashFor3Direct));
final frame = buildSendTextMsgFrame(
recipientKey,
fixedText,
attempt: 3,
timestampSeconds: fixedTs,
);
expect(frame[2], equals(3));
},
);
test(
'attempt 3 and attempt 4 produce DIFFERENT hashes (3&3=3 vs 4&3=0)',
() {
final hash3 = MessageRetryService.computeExpectedAckHash(
fixedTs,
3,
fixedText,
fixedKey,
);
final hash4 = MessageRetryService.computeExpectedAckHash(
fixedTs,
4,
fixedText,
fixedKey,
);
expect(hash3, isNot(equals(hash4)));
},
);
test('attempt 8 (8&3=0) produces the same hash as attempt 0', () {
final hash8 = MessageRetryService.computeExpectedAckHash(
fixedTs,
8,
fixedText,
fixedKey,
);
final hash0 = MessageRetryService.computeExpectedAckHash(
fixedTs,
0,
fixedText,
fixedKey,
);
expect(hash8, equals(hash0));
});
test(
'hash cycle repeats every 4 attempts (modular arithmetic holds)',
() {
for (int base = 0; base < 4; base++) {
final hashBase = MessageRetryService.computeExpectedAckHash(
fixedTs,
base,
fixedText,
fixedKey,
);
final hashPlus4 = MessageRetryService.computeExpectedAckHash(
fixedTs,
base + 4,
fixedText,
fixedKey,
);
final hashPlus8 = MessageRetryService.computeExpectedAckHash(
fixedTs,
base + 8,
fixedText,
fixedKey,
);
expect(
hashPlus4,
equals(hashBase),
reason: 'attempt ${base + 4} should match attempt $base',
);
expect(
hashPlus8,
equals(hashBase),
reason: 'attempt ${base + 8} should match attempt $base',
);
}
},
);
},
);
// -------------------------------------------------------------------------
group('_AckHashMapping.attemptIndex — indirect verification via public API', () {
// _AckHashMapping is private; we validate its purpose indirectly: that
// computeExpectedAckHash records the correct per-attempt hash so that the
// right hash is matched when an ACK arrives.
test('each attempt index 03 produces a distinct 4-byte hash', () {
final hashes = <String, int>{};
for (int attempt = 0; attempt < 4; attempt++) {
final hash = MessageRetryService.computeExpectedAckHash(
fixedTs,
attempt,
fixedText,
fixedKey,
);
final hex = hash.toRadixString(16).padLeft(8, '0');
expect(
hashes.containsKey(hex),
isFalse,
reason: 'attempt $attempt collides with attempt ${hashes[hex]}',
);
hashes[hex] = attempt;
}
expect(hashes.length, equals(4));
});
test(
'attempt index wraps: hash for attempt 4 matches stored hash for attempt 0',
() {
final storedHash = MessageRetryService.computeExpectedAckHash(
fixedTs,
0,
fixedText,
fixedKey,
);
// Simulates firmware reading raw attempt=4 and masking to 0 for hash.
final firmwareComputedHash = _manualAckHash(
fixedTs,
4 & 0x03, // firmware masks here
fixedText,
fixedKey,
);
expect(firmwareComputedHash, equals(storedHash));
},
);
test(
'attempt index 1 and 5 map to the same slot — ACK from either retry is matched',
() {
final hashForAttempt1 = MessageRetryService.computeExpectedAckHash(
fixedTs,
1,
fixedText,
fixedKey,
);
final hashForAttempt5 = MessageRetryService.computeExpectedAckHash(
fixedTs,
5,
fixedText,
fixedKey,
);
// Both should produce the identical bytes, confirming the service
// would record and match the correct attempt index.
expect(hashForAttempt5, equals(hashForAttempt1));
},
);
});
group('sendMessageWithRetry — auto path fallback', () {
test(
'preserves the contact path when auto-selection returns null',
() async {
final retryService = MessageRetryService();
Message? addedMessage;
final contact = _makeContact(
publicKey: recipientKey,
pathLength: 2,
path: const [0x10, 0x20],
);
retryService.initialize(
RetryServiceConfig(
sendMessage: (_, _, _, _) {},
addMessage: (_, message) => addedMessage = message,
updateMessage: (_) {},
clearContactPath: (_) {},
setContactPath: (_, _, _) {},
selectRetryPath: (_, _, _, _) => null,
),
);
await retryService.sendMessageWithRetry(
contact: contact,
text: 'hello',
);
expect(addedMessage, isNotNull);
expect(addedMessage!.pathLength, equals(2));
expect(
addedMessage!.pathBytes,
equals(Uint8List.fromList([0x10, 0x20])),
);
},
);
test('uses flood when contact is in flood mode', () async {
final retryService = MessageRetryService();
Message? addedMessage;
final contact = _makeContact(
publicKey: recipientKey,
pathLength: -1,
path: const [],
);
retryService.initialize(
RetryServiceConfig(
sendMessage: (_, _, _, _) {},
addMessage: (_, message) => addedMessage = message,
updateMessage: (_) {},
clearContactPath: (_) {},
setContactPath: (_, _, _) {},
),
);
await retryService.sendMessageWithRetry(contact: contact, text: 'hello');
expect(addedMessage, isNotNull);
expect(addedMessage!.pathLength, equals(-1));
expect(addedMessage!.pathBytes, isEmpty);
});
});
}