From 55ee1df0bcdf76458264b09ae9a77baee05da901 Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 25 Jul 2026 19:21:59 +0200 Subject: [PATCH] Stabilize synchronous Noise restart tests --- bitchatTests/BLEServiceCoreTests.swift | 25 ++++- .../Integration/IntegrationTests.swift | 15 ++- .../Integration/TestNetworkHelper.swift | 24 ++++- bitchatTests/Noise/NoiseProtocolTests.swift | 75 +++++++++---- .../NoiseEncryptionServiceTests.swift | 102 ++++++++++++------ 5 files changed, 178 insertions(+), 63 deletions(-) diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 85f8494a..5848e2c6 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -645,14 +645,25 @@ struct BLEServiceCoreTests { let mallory = NoiseEncryptionService(keychain: MockKeychain()) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - let message1 = try ble._test_noiseInitiateHandshake(with: alicePeerID) + // Establish BLE as responder so the following inbound reconnect is + // not intentionally coalesced by the initiator-completion grace path. + let message1 = try alice.initiateHandshake(with: ble.myPeerID) let message2 = try #require( - try alice.processHandshakeMessage(from: ble.myPeerID, message: message1) + try ble._test_noiseProcessHandshakeMessage( + from: alicePeerID, + message: message1 + ) ) let message3 = try #require( - try ble._test_noiseProcessHandshakeMessage(from: alicePeerID, message: message2) + try alice.processHandshakeMessage( + from: ble.myPeerID, + message: message2 + ) + ) + _ = try ble._test_noiseProcessHandshakeMessage( + from: alicePeerID, + message: message3 ) - _ = try alice.processHandshakeMessage(from: ble.myPeerID, message: message3) await ble._test_drainNoiseMessagePipeline() #expect(ble.canDeliverSecurely(to: alicePeerID)) @@ -674,6 +685,9 @@ struct BLEServiceCoreTests { { outbound.snapshot().contains { $0.type == MessageType.noiseHandshake.rawValue + && PeerID(hexData: $0.senderID) == ble.myPeerID + && $0.payload.count + != NoiseSecurityConstants.xxInitialMessageSize } }, timeout: TestConstants.longTimeout @@ -682,6 +696,9 @@ struct BLEServiceCoreTests { let forgedMessage2 = try #require( outbound.snapshot().first { $0.type == MessageType.noiseHandshake.rawValue + && PeerID(hexData: $0.senderID) == ble.myPeerID + && $0.payload.count + != NoiseSecurityConstants.xxInitialMessageSize }?.payload ) #expect(!ble.canDeliverSecurely(to: alicePeerID)) diff --git a/bitchatTests/Integration/IntegrationTests.swift b/bitchatTests/Integration/IntegrationTests.swift index 11ba1602..e07f0fae 100644 --- a/bitchatTests/Integration/IntegrationTests.swift +++ b/bitchatTests/Integration/IntegrationTests.swift @@ -12,6 +12,7 @@ import Testing @testable import BitFoundation // to avoid unnecessary public's @testable import bitchat +@Suite("Integration Tests", .serialized) struct IntegrationTests { private var helper = TestNetworkHelper() @@ -272,8 +273,18 @@ struct IntegrationTests { // Re-establish Noise handshake explicitly via managers do { let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID) - let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)! - let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)! + let m2 = try #require( + try helper.noiseManagers["Alice"]!.handleIncomingHandshake( + from: helper.nodes["Bob"]!.peerID, + message: m1 + ) + ) + let m3 = try #require( + try helper.noiseManagers["Bob"]!.handleIncomingHandshake( + from: helper.nodes["Alice"]!.peerID, + message: m2 + ) + ) _ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3) } catch { Issue.record("Failed to re-establish Noise session after restart: \(error)") diff --git a/bitchatTests/Integration/TestNetworkHelper.swift b/bitchatTests/Integration/TestNetworkHelper.swift index d9e0b3a9..0131739c 100644 --- a/bitchatTests/Integration/TestNetworkHelper.swift +++ b/bitchatTests/Integration/TestNetworkHelper.swift @@ -8,6 +8,7 @@ import Foundation import CryptoKit +import Testing @testable import BitFoundation // to avoid unnecessary public's @testable import bitchat @@ -27,9 +28,14 @@ final class TestNetworkHelper { node.mockNickname = name nodes[name] = node - // Create/replace Noise manager for this node + // This synchronous helper directly drives all three XX messages and + // has no transport callback loop for delayed collision recovery. let key = Curve25519.KeyAgreement.PrivateKey() - noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain) + noiseManagers[name] = NoiseSessionManager( + localStaticKey: key, + keychain: mockKeychain, + recentInitiatorCompletionGracePeriod: 0 + ) return node } @@ -108,8 +114,18 @@ final class TestNetworkHelper { let peer2ID = nodes[node2]?.peerID else { return } let msg1 = try manager1.initiateHandshake(with: peer2ID) - let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)! - let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)! + let msg2 = try #require( + try manager2.handleIncomingHandshake( + from: peer1ID, + message: msg1 + ) + ) + let msg3 = try #require( + try manager1.handleIncomingHandshake( + from: peer2ID, + message: msg2 + ) + ) _ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3) } } diff --git a/bitchatTests/Noise/NoiseProtocolTests.swift b/bitchatTests/Noise/NoiseProtocolTests.swift index f7e8e6be..ac8ef93d 100644 --- a/bitchatTests/Noise/NoiseProtocolTests.swift +++ b/bitchatTests/Noise/NoiseProtocolTests.swift @@ -357,8 +357,18 @@ struct NoiseProtocolTests { @Test func peerRestartDetection() throws { // Establish initial sessions - let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) - let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) + // This test explicitly drives the three synchronous XX messages and + // does not exercise the transport's delayed collision recovery. + let aliceManager = NoiseSessionManager( + localStaticKey: aliceKey, + keychain: mockKeychain, + recentInitiatorCompletionGracePeriod: 0 + ) + let bobManager = NoiseSessionManager( + localStaticKey: bobKey, + keychain: mockKeychain, + recentInitiatorCompletionGracePeriod: 0 + ) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) @@ -377,15 +387,24 @@ struct NoiseProtocolTests { let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID) // Alice should accept the new handshake (clearing old session) - let newHandshake2 = try aliceManager.handleIncomingHandshake( - from: alicePeerID, message: newHandshake1) - #expect(newHandshake2 != nil) + let newHandshake2 = try #require( + try aliceManager.handleIncomingHandshake( + from: alicePeerID, + message: newHandshake1 + ) + ) // Complete the new handshake - let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake( - from: bobPeerID, message: newHandshake2!) - #expect(newHandshake3 != nil) - _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!) + let newHandshake3 = try #require( + try bobManagerRestarted.handleIncomingHandshake( + from: bobPeerID, + message: newHandshake2 + ) + ) + _ = try aliceManager.handleIncomingHandshake( + from: alicePeerID, + message: newHandshake3 + ) // Should be able to exchange messages with new sessions let testMessage = Data("After restart".utf8) @@ -543,8 +562,18 @@ struct NoiseProtocolTests { @Test func nonceDesynchronizationCausesRehandshake() throws { // Test that nonce desynchronization leads to proper re-handshake - let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) - let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) + // This test explicitly drives the three synchronous XX messages and + // does not exercise the transport's delayed collision recovery. + let aliceManager = NoiseSessionManager( + localStaticKey: aliceKey, + keychain: mockKeychain, + recentInitiatorCompletionGracePeriod: 0 + ) + let bobManager = NoiseSessionManager( + localStaticKey: bobKey, + keychain: mockKeychain, + recentInitiatorCompletionGracePeriod: 0 + ) // Establish sessions try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) @@ -572,15 +601,25 @@ struct NoiseProtocolTests { let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID) // Alice should accept despite having a "valid" (but desynced) session - let rehandshake2 = try aliceManager.handleIncomingHandshake( - from: alicePeerID, message: rehandshake1) - #expect(rehandshake2 != nil, "Alice should accept handshake to fix desync") + let rehandshake2 = try #require( + try aliceManager.handleIncomingHandshake( + from: alicePeerID, + message: rehandshake1 + ), + "Alice should accept handshake to fix desync" + ) // Complete handshake - let rehandshake3 = try bobManager.handleIncomingHandshake( - from: bobPeerID, message: rehandshake2!) - #expect(rehandshake3 != nil) - _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!) + let rehandshake3 = try #require( + try bobManager.handleIncomingHandshake( + from: bobPeerID, + message: rehandshake2 + ) + ) + _ = try aliceManager.handleIncomingHandshake( + from: alicePeerID, + message: rehandshake3 + ) // Verify communication works again let testResynced = Data("Resynced".utf8) diff --git a/bitchatTests/Services/NoiseEncryptionServiceTests.swift b/bitchatTests/Services/NoiseEncryptionServiceTests.swift index 3769fe64..f150c39f 100644 --- a/bitchatTests/Services/NoiseEncryptionServiceTests.swift +++ b/bitchatTests/Services/NoiseEncryptionServiceTests.swift @@ -666,15 +666,17 @@ struct NoiseEncryptionServiceTests { @Test("Claim gives an attempt a full on-wire timeout window") func handshakeClaimRearmsDeadline() async throws { + let timeoutInterval: TimeInterval = 1 let service = NoiseEncryptionService( keychain: MockKeychain(), - ordinaryHandshakeTimeout: 0.08 + ordinaryHandshakeTimeout: timeoutInterval ) let peerID = PeerID(str: "1021324354657687") let recorder = HandshakeStartRecorder() service.onHandshakeRecoveryRequired = { [weak service] request in + let firedAt = DispatchTime.now().uptimeNanoseconds service?.cancelHandshakeRecovery(request) - recorder.recordTimeout() + recorder.recordTimeout(at: firedAt) } let attempt = try #require( @@ -683,19 +685,24 @@ struct NoiseEncryptionServiceTests { retryOnTimeout: true ) ) - try? await Task.sleep(nanoseconds: 50_000_000) - #expect( - service.claimHandshakeInitiation(attempt, for: peerID) - == attempt.payload - ) - try? await Task.sleep(nanoseconds: 45_000_000) - #expect(service.hasSession(with: peerID)) - #expect(recorder.timeoutCount == 0) + try? await Task.sleep(nanoseconds: 500_000_000) + let claimed = service.claimHandshakeInitiation(attempt, for: peerID) + let claimedAt = DispatchTime.now().uptimeNanoseconds + #expect(claimed == attempt.payload) let expired = await TestHelpers.waitUntil( { recorder.timeoutCount == 1 }, - timeout: 1 + timeout: 5 ) #expect(expired) + let firedAt = try #require(recorder.firstTimeoutUptimeNanoseconds) + try #require(firedAt >= claimedAt) + let elapsed = TimeInterval(firedAt - claimedAt) / 1_000_000_000 + // A non-rearmed deadline would fire roughly 0.5 seconds after the + // claim. Measure on the timeout queue instead of relying on a task to + // resume inside a narrow pre-deadline window under parallel CI load. + #expect(elapsed >= timeoutInterval * 0.75) + #expect(!service.hasSession(with: peerID)) + #expect(recorder.timeoutCount == 1) } @Test("Duplicate spoofed message one cannot extend rollback or repause during cooldown") @@ -723,7 +730,8 @@ struct NoiseEncryptionServiceTests { message: spoofedMessage1 ) ) - try? await Task.sleep(nanoseconds: 35_000_000) + // Exercise replacement before yielding: the test runner may resume a + // short sleep after the fixed responder deadline under parallel load. _ = try #require( try bob.processHandshakeMessage( from: alicePeerID, @@ -820,26 +828,20 @@ struct NoiseEncryptionServiceTests { @Test("Deterministic responder recovers once from an always-yield peer") func yieldedResponderRecoversFromLegacyDoubleYield() async throws { + let timeoutInterval: TimeInterval = 1 let endpoints = orderedServices( - ordinaryHandshakeTimeout: 0.08, - ordinaryResponderHandshakeTimeout: 0.08 + ordinaryHandshakeTimeout: timeoutInterval, + ordinaryResponderHandshakeTimeout: timeoutInterval ) let modern = endpoints.higher let legacy = endpoints.lower let recovery = HandshakeStartRecorder() - modern.onHandshakeRecoveryRequired = { [weak modern] request in - guard let modern else { return } - do { - recovery.recordTimeout() - recovery.record( - message: try claimPreparedRecoveryPayload( - modern, - request: request - ) - ) - } catch { - recovery.record(error: error) - } + modern.onHandshakeRecoveryRequired = { request in + // Preparing here would arm the retry before the test task can + // forward message 1. Record the token so preparation and the + // simulated on-wire exchange remain synchronous. + recovery.recordTimeout() + recovery.record(request: request) } let modernAttempt = try #require( @@ -903,12 +905,18 @@ struct NoiseEncryptionServiceTests { // Expected; this side did not own retry intent. } - let didRecover = await TestHelpers.waitUntil( - { recovery.messages.count == 1 }, - timeout: 1 + let recoveryRequested = await TestHelpers.waitUntil( + { recovery.requests.count == 1 }, + timeout: 5 + ) + #expect(recoveryRequested) + let recoveryRequest = try #require(recovery.requests.first) + let retryMessage1 = try #require( + try claimPreparedRecoveryPayload( + modern, + request: recoveryRequest + ) ) - #expect(didRecover) - let retryMessage1 = try #require(recovery.messages.first) let retryMessage2 = try #require( try legacy.processHandshakeMessage( from: endpoints.higherPeerID, @@ -925,9 +933,10 @@ struct NoiseEncryptionServiceTests { from: endpoints.higherPeerID, message: retryMessage3 ) - try? await Task.sleep(nanoseconds: 120_000_000) + try? await Task.sleep( + nanoseconds: UInt64(timeoutInterval * 1_200_000_000) + ) #expect(recovery.timeoutCount == 1) - #expect(recovery.errorCount == 0) let ciphertext = try modern.encrypt( Data("legacy converged".utf8), for: endpoints.lowerPeerID @@ -1503,8 +1512,10 @@ private final class HandshakeInitiationRecorder: @unchecked Sendable { private final class HandshakeStartRecorder: @unchecked Sendable { private let lock = NSLock() private var storedMessages: [Data] = [] + private var storedRequests: [NoiseHandshakeRecoveryRequest] = [] private var storedErrorCount = 0 private var storedTimeoutCount = 0 + private var storedTimeoutUptimes: [UInt64] = [] var messages: [Data] { lock.lock() @@ -1512,6 +1523,12 @@ private final class HandshakeStartRecorder: @unchecked Sendable { return storedMessages } + var requests: [NoiseHandshakeRecoveryRequest] { + lock.lock() + defer { lock.unlock() } + return storedRequests + } + var errorCount: Int { lock.lock() defer { lock.unlock() } @@ -1524,6 +1541,12 @@ private final class HandshakeStartRecorder: @unchecked Sendable { return storedTimeoutCount } + var firstTimeoutUptimeNanoseconds: UInt64? { + lock.lock() + defer { lock.unlock() } + return storedTimeoutUptimes.first + } + func record(message: Data?) { guard let message else { return } lock.lock() @@ -1531,15 +1554,24 @@ private final class HandshakeStartRecorder: @unchecked Sendable { lock.unlock() } + func record(request: NoiseHandshakeRecoveryRequest) { + lock.lock() + storedRequests.append(request) + lock.unlock() + } + func record(error _: Error) { lock.lock() storedErrorCount += 1 lock.unlock() } - func recordTimeout() { + func recordTimeout( + at uptimeNanoseconds: UInt64 = DispatchTime.now().uptimeNanoseconds + ) { lock.lock() storedTimeoutCount += 1 + storedTimeoutUptimes.append(uptimeNanoseconds) lock.unlock() } }