diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 7f554189..b30b2df0 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -3290,6 +3290,16 @@ class MeshCoreConnector extends ChangeNotifier { @visibleForTesting void setSelfPublicKeyForTest(Uint8List key) => _selfPublicKey = key; + /// Wires the channel message store the way the SELF_INFO handshake does + /// (device scoping + PSK resolver), so integration tests (#1064) exercise + /// the real PSK-keyed persistence path. Call after [setSelfPublicKeyForTest]. + @visibleForTesting + void wireMessageStoreForTest() { + _channelMessageStore.setPublicKeyHex = selfPublicKeyHex; + _channelMessageStore.channelPskResolver = (index) => + _findChannelByIndex(index)?.pskHex; + } + @visibleForTesting static bool shouldResetStateAfterTcpConnectAbort({ required MeshCoreConnectionState state, @@ -4068,6 +4078,20 @@ class MeshCoreConnector extends ChangeNotifier { @visibleForTesting void Function(Uint8List data)? sendFrameOverrideForTest; + /// Observes [_maybeNotifyChannelMessage]'s decision: the slot, the display + /// label, and the PSK + mode the notification was evaluated against (null + /// psk/mode = suppressed because the slot was unconfirmed). Lets tests + /// assert notify routing (#1064) without reaching the NotificationService + /// singleton. + @visibleForTesting + void Function( + int channelIndex, + String channelName, + String? pskHex, + ChannelNotifyMode? mode, + )? + channelNotifyProbeForTest; + @visibleForTesting void setConnectedForTest() => _state = MeshCoreConnectionState.connected; @@ -9401,12 +9425,19 @@ class MeshCoreConnector extends ChangeNotifier { 'unconfirmed, its notify mode cannot be resolved', tag: 'ChannelSync', ); + channelNotifyProbeForTest?.call(channelIndex, label, null, null); return; } final mode = _appSettingsService!.channelNotifyMode( devicePrefix: selfPublicKeyHex, pskHex: slotChannel?.pskHex, ); + channelNotifyProbeForTest?.call( + channelIndex, + label, + slotChannel?.pskHex, + mode, + ); switch (mode) { case ChannelNotifyMode.off: return; @@ -9453,6 +9484,20 @@ class MeshCoreConnector extends ChangeNotifier { } final parsed = ChannelMessage.fromFrame(frame); if (parsed != null && parsed.channelIndex != null) { + // #1062 fail-closed, slot-level: the list is synced but THIS slot never + // confirmed. Filing by index would attach the message to an unknown + // identity and persist it under the legacy index key, so re-hold until + // a sync confirms the slot (found by the #1064 integration tests). + if (isConnected && + _unconfirmedChannelSlots.contains(parsed.channelIndex)) { + _holdChannelFrame( + _heldChannelMsgFrames, + frame, + 'message', + bounded: false, + ); + return; + } // Device-composed (outgoing) messages legitimately carry our own name and // no path; don't let the self-echo guard drop them (#429 part B). if (!parsed.isOutgoing && diff --git a/test/connector/channel_slot_resync_integration_test.dart b/test/connector/channel_slot_resync_integration_test.dart new file mode 100644 index 00000000..b7b633a1 --- /dev/null +++ b/test/connector/channel_slot_resync_integration_test.dart @@ -0,0 +1,291 @@ +// #1064 (Epic C of #1061): end-to-end slot-shuffle scenarios across Epic A +// (#1062, fail-closed sync + ingress barrier) and Epic B (#1063, per-slot +// state never crosses identities). The first test is the #1060 bug-validation +// scenario: a wipe + config re-import rearranged the device's channel slots, +// #wardriving notified with its mode off, and #test messages rendered inside +// the private channel. + +import 'dart:typed_data'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/models/app_settings.dart'; +import 'package:meshcore_open/models/channel.dart'; +import 'package:meshcore_open/models/channel_message.dart'; +import 'package:meshcore_open/services/app_settings_service.dart'; +import 'package:meshcore_open/services/message_retry_service.dart'; +import 'package:meshcore_open/services/path_history_service.dart'; +import 'package:meshcore_open/services/storage_service.dart'; +import 'package:meshcore_open/storage/drift/blob_store.dart'; +import 'package:meshcore_open/storage/drift/offband_database.dart'; +import 'package:meshcore_open/storage/prefs_manager.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Uint8List _channelInfoFrame({ + required int index, + required String name, + required int pskSeed, +}) { + final frame = Uint8List(50); + frame[0] = respCodeChannelInfo; + frame[1] = index; + final nameBytes = name.codeUnits; + for (var i = 0; i < nameBytes.length && i < 31; i++) { + frame[2 + i] = nameBytes[i]; + } + for (var i = 0; i < 16; i++) { + frame[34 + i] = (pskSeed + i) & 0xFF; + } + return frame; +} + +Uint8List _v3ChannelMsgFrame({required int channelIdx, String text = 'A: hi'}) { + return Uint8List.fromList([ + respCodeChannelMsgRecvV3, + 0, 0, 0, + channelIdx, + 1, // path_len: 1 hop, width 1 + txtTypePlain, + 0x40, 0xE2, 0x01, 0x00, + ...text.codeUnits, + ]); +} + +Channel _channel(int index, String name, int pskSeed) { + final psk = Uint8List.fromList( + List.generate(16, (i) => (pskSeed + i) & 0xFF), + ); + return Channel(index: index, name: name, psk: psk); +} + +typedef _NotifyDecision = ({ + int channelIndex, + String channelName, + String? pskHex, + ChannelNotifyMode? mode, +}); + +Future _settle([int turns = 25]) async { + for (var i = 0; i < turns; i++) { + await Future.delayed(Duration.zero); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late OffbandDatabase db; + late AppSettingsService settings; + late MeshCoreConnector c; + late List<_NotifyDecision> notifyDecisions; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + PrefsManager.reset(); + await PrefsManager.initialize(); + db = OffbandDatabase(NativeDatabase.memory()); + BlobStore.overrideForTest(BlobStore(db)); + + settings = AppSettingsService(); + await settings.loadSettings(); + + c = MeshCoreConnector(); + c.initialize( + retryService: MessageRetryService(), + pathHistoryService: PathHistoryService(StorageService()), + appSettingsService: settings, + ); + c.sendFrameOverrideForTest = (_) {}; + c.setConnectedForTest(); + c.setSelfPublicKeyForTest( + Uint8List.fromList(List.generate(32, (i) => i)), + ); + c.wireMessageStoreForTest(); + + notifyDecisions = []; + c.channelNotifyProbeForTest = (index, name, pskHex, mode) { + notifyDecisions.add(( + channelIndex: index, + channelName: name, + pskHex: pskHex, + mode: mode, + )); + }; + }); + + tearDown(() async { + BlobStore.clearTestOverride(); + await db.close(); + }); + + Future syncWith(List deviceArrangement, {int? max}) async { + await c.getChannels( + force: true, + maxChannels: max ?? deviceArrangement.length, + ); + for (final ch in deviceArrangement) { + c.handleFrameForTest( + _channelInfoFrame(index: ch.index, name: ch.name, pskSeed: ch.psk[0]), + ); + await _settle(2); + } + await _settle(); + } + + test('#1060 bug validation: wipe + re-import with shuffled slots', () async { + // Before the wipe this radio had: slot0 #wardriving (notify OFF), + // slot1 #test, slot2 the private channel (an old conversation on screen). + final wardriving = _channel(0, 'wardriving', 40); + final testCh = _channel(1, 'test', 50); + final private = _channel(2, 'Private', 60); + c.seedCachedChannelsForTest([wardriving, testCh, private]); + await settings.setChannelNotifyMode( + devicePrefix: c.selfPublicKeyHex, + pskHex: wardriving.pskHex, + channelName: wardriving.name, + mode: ChannelNotifyMode.off, + ); + c.channelMessagesForTest[2] = [ + ChannelMessage( + senderName: 'friend', + text: 'old private talk', + timestamp: DateTime.fromMillisecondsSinceEpoch(1000), + isOutgoing: false, + channelIndex: 2, + messageId: 'old-private', + ), + ]; + + // The re-imported config put the same channels in DIFFERENT slots. + await syncWith([ + _channel(0, 'test', 50), + _channel(1, 'Private', 60), + _channel(2, 'wardriving', 40), + ]); + + // The list matches the device, and every slot's change was surfaced. + expect(c.channels.map((ch) => ch.name), ['test', 'Private', 'wardriving']); + expect(c.slotIdentityChanges, hasLength(3)); + expect(c.unconfirmedChannelSlots, isEmpty); + + // The old private conversation did not stay behind in slot 2, which + // #wardriving now occupies. + expect(c.channelMessagesForTest[2] ?? const [], isEmpty); + + // A #test message files under #test's slot and is evaluated against + // #test's PSK (default mode), not against whatever used to live there. + c.handleFrameForTest(_v3ChannelMsgFrame(channelIdx: 0, text: 'B: to test')); + await _settle(); + expect(c.channelMessagesForTest[0]!.single.text, 'to test'); + expect( + c.channelMessagesForTest[1] ?? const [], + isEmpty, + reason: '#test traffic must not render inside the private channel', + ); + final testDecision = notifyDecisions.single; + expect(testDecision.channelName, 'test'); + expect(testDecision.pskHex, testCh.pskHex); + expect(testDecision.mode, ChannelNotifyMode.all); + + // A #wardriving message follows #wardriving's OFF mode to its NEW slot: + // the #1060 complaint was notifications firing with the mode off. + notifyDecisions.clear(); + c.handleFrameForTest(_v3ChannelMsgFrame(channelIdx: 2, text: 'B: war')); + await _settle(); + expect(c.channelMessagesForTest[2]!.single.text, 'war'); + final warDecision = notifyDecisions.single; + expect(warDecision.pskHex, wardriving.pskHex); + expect( + warDecision.mode, + ChannelNotifyMode.off, + reason: 'the OFF mode belongs to the PSK and must ride along to slot 2', + ); + }); + + test('manual reorder on the device: history follows the channel, ' + 'not the slot', () async { + // First sync: Alpha in slot 0, Beta in slot 1. A message lands in Alpha + // and is persisted under Alpha's PSK identity. + await syncWith([_channel(0, 'Alpha', 10), _channel(1, 'Beta', 30)]); + c.handleFrameForTest( + _v3ChannelMsgFrame(channelIdx: 0, text: 'A: hello alpha'), + ); + await _settle(); + expect(c.channelMessagesForTest[0]!.single.text, 'hello alpha'); + // Let the unawaited persistence land before the reorder. + await Future.delayed(const Duration(milliseconds: 50)); + + // The operator swaps the slots on the device; the client re-syncs. + await syncWith([_channel(0, 'Beta', 30), _channel(1, 'Alpha', 10)]); + await Future.delayed(const Duration(milliseconds: 50)); + await _settle(); + + expect(c.channels.map((ch) => ch.name), ['Beta', 'Alpha']); + expect( + c.channelMessagesForTest[0] ?? const [], + isEmpty, + reason: "Alpha's conversation must not render under Beta", + ); + expect( + (c.channelMessagesForTest[1] ?? const []).map( + (m) => m.text, + ), + ['hello alpha'], + reason: "Alpha's PSK-keyed history reloads at its new slot", + ); + }); + + test('flaky sync mid-reconnect: held traffic files only after a ' + 'confirming sync, against the right PSK', () async { + final alpha = _channel(0, 'Alpha', 10); + final beta = _channel(1, 'Beta', 30); + c.seedCachedChannelsForTest([alpha, beta]); + await settings.setChannelNotifyMode( + devicePrefix: c.selfPublicKeyHex, + pskHex: beta.pskHex, + channelName: beta.name, + mode: ChannelNotifyMode.off, + ); + + // The sync answers slot 0, a message for slot 1 arrives mid-sync, and + // slot 1 never answers. + await c.getChannels(maxChannels: 2); + c.handleFrameForTest( + _channelInfoFrame(index: 0, name: 'Alpha', pskSeed: 10), + ); + await _settle(2); + c.handleFrameForTest(_v3ChannelMsgFrame(channelIdx: 1, text: 'B: held')); + for (var i = 0; i <= 3; i++) { + c.handleChannelSyncTimeoutForTest(1); + await _settle(2); + } + await _settle(); + + expect(c.unconfirmedChannelSlots, {1}); + expect( + c.channelMessagesForTest[1], + isNull, + reason: 'a message for an unconfirmed slot must not file by raw index', + ); + expect( + notifyDecisions.where((d) => d.channelIndex == 1 && d.mode != null), + isEmpty, + reason: 'no notify decision can be made for an unconfirmed slot', + ); + + // The reconnect-style retry confirms both slots; the held message now + // files, and Beta's OFF mode is evaluated against Beta's own PSK. + notifyDecisions.clear(); + await syncWith([alpha, beta]); + + expect(c.unconfirmedChannelSlots, isEmpty); + expect(c.channels.map((ch) => ch.name), ['Alpha', 'Beta']); + expect(c.channelMessagesForTest[1]!.single.text, 'held'); + final decision = notifyDecisions.single; + expect(decision.channelIndex, 1); + expect(decision.pskHex, beta.pskHex); + expect(decision.mode, ChannelNotifyMode.off); + }); +}