// // MessageRouterTests.swift // bitchatTests // // Tests for MessageRouter transport selection and outbox behavior. // import Testing import Foundation import BitFoundation @testable import bitchat struct MessageRouterTests { @Test @MainActor func sendPrivate_usesReachableTransport() async { let peerID = PeerID(str: "0000000000000001") let transportA = MockTransport() let transportB = MockTransport() transportB.reachablePeers.insert(peerID) let router = MessageRouter(transports: [transportA, transportB]) router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m1") #expect(transportA.sentPrivateMessages.isEmpty) #expect(transportB.sentPrivateMessages.count == 1) } @Test @MainActor func sendPrivate_queuesThenFlushesWhenReachable() async { let peerID = PeerID(str: "0000000000000002") let transport = MockTransport() let router = MessageRouter(transports: [transport]) router.sendPrivate("Queued", to: peerID, recipientNickname: "Peer", messageID: "m2") #expect(transport.sentPrivateMessages.isEmpty) transport.reachablePeers.insert(peerID) router.flushOutbox(for: peerID) #expect(transport.sentPrivateMessages.count == 1) } @Test @MainActor func sendPrivate_prefersConnectedTransportOverEarlierReachableOne() async { let peerID = PeerID(str: "0000000000000005") let reachableOnly = MockTransport() reachableOnly.reachablePeers.insert(peerID) let connected = MockTransport() connected.connectedPeers.insert(peerID) connected.reachablePeers.insert(peerID) let router = MessageRouter(transports: [reachableOnly, connected]) router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m5") #expect(reachableOnly.sentPrivateMessages.isEmpty) #expect(connected.sentPrivateMessages.count == 1) } @Test @MainActor func sendPrivate_reachableOnlySendRetainsUntilDeliveryAck() async { let peerID = PeerID(str: "0000000000000006") let transport = MockTransport() transport.reachablePeers.insert(peerID) let router = MessageRouter(transports: [transport]) router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m6") #expect(transport.sentPrivateMessages.count == 1) // No ack yet: a flush retries over the weak signal. router.flushOutbox(for: peerID) #expect(transport.sentPrivateMessages.count == 2) // Ack clears the retained copy; later flushes stop resending. router.markDelivered("m6") router.flushOutbox(for: peerID) #expect(transport.sentPrivateMessages.count == 2) } @Test @MainActor func peerBoundDeliveryAckCannotClearAnotherPeersRetainedMessage() async { let intendedPeer = PeerID(str: "0000000000000023") let otherPeer = PeerID(str: "0000000000000024") let transport = MockTransport() transport.reachablePeers = [intendedPeer, otherPeer] let router = MessageRouter(transports: [transport]) router.sendPrivate( "Secret", to: intendedPeer, recipientNickname: "Intended", messageID: "peer-bound-ack" ) #expect(transport.sentPrivateMessages.count == 1) // Even a receipt arriving over another authenticated conversation // must not terminalize the intended peer's retained retry. router.markDelivered("peer-bound-ack", from: [otherPeer]) router.flushOutbox(for: intendedPeer) #expect(transport.sentPrivateMessages.count == 2) router.markDelivered("peer-bound-ack", from: [intendedPeer]) router.flushOutbox(for: intendedPeer) #expect(transport.sentPrivateMessages.count == 2) } @Test @MainActor func sendPrivate_connectedSecureSendRetainsUntilDeliveryAck() async { let peerID = PeerID(str: "0000000000000007") let transport = MockTransport() transport.connectedPeers.insert(peerID) transport.reachablePeers.insert(peerID) transport.securePeers = [peerID] let router = MessageRouter(transports: [transport]) router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m7") #expect(transport.sentPrivateMessages.count == 1) // A newly authenticated/replacement session retries the retained // message instead of losing the first ciphertext to a stale session. router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID]) #expect(transport.sentPrivateMessages.count == 2) router.markDelivered("m7") router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID]) #expect(transport.sentPrivateMessages.count == 2) } @Test @MainActor func authenticationRetry_matchesStableOutboxAliasWithoutDoubleSending() async { let shortPeerID = PeerID(str: "0000000000000019") let stablePeerID = PeerID(hexData: Data(repeating: 0x19, count: 32)) let transport = MockTransport() transport.connectedPeers.insert(stablePeerID) transport.securePeers = [stablePeerID] let router = MessageRouter(transports: [transport]) router.sendPrivate("Hello", to: stablePeerID, recipientNickname: "Peer", messageID: "alias-retry") router.retrySecurePrivateMessagesAfterAuthentication(for: [shortPeerID, stablePeerID, stablePeerID]) #expect(transport.sentPrivateMessages.map(\.messageID) == ["alias-retry", "alias-retry"]) #expect(transport.sentPrivateMessages.allSatisfy { $0.peerID == stablePeerID }) } @Test @MainActor func authenticationRetry_preservesFIFOAcrossSplitAliases() async { let shortPeerID = PeerID(str: "0000000000000022") let stablePeerID = PeerID(hexData: Data(repeating: 0x22, count: 32)) let transport = MockTransport() transport.connectedPeers = [shortPeerID, stablePeerID] transport.securePeers = [shortPeerID, stablePeerID] let clock = MutableTestClock() let router = MessageRouter(transports: [transport], now: { clock.now }) // The older message lives under the stable key, even though the auth // callback supplies the ephemeral alias first. router.sendPrivate("Older", to: stablePeerID, recipientNickname: "Peer", messageID: "fifo-old") clock.now = clock.now.addingTimeInterval(1) router.sendPrivate("Newer", to: shortPeerID, recipientNickname: "Peer", messageID: "fifo-new") transport.resetRecordings() router.retrySecurePrivateMessagesAfterAuthentication(for: [shortPeerID, stablePeerID]) #expect(transport.sentPrivateMessages.map(\.messageID) == ["fifo-old", "fifo-new"]) #expect(transport.sentPrivateMessages.map(\.peerID) == [stablePeerID, shortPeerID]) } @Test @MainActor func authenticationRetry_doesNotDuplicateNormalPendingHandshakeSend() async { let peerID = PeerID(str: "0000000000000020") let transport = MockTransport() transport.connectedPeers.insert(peerID) transport.securePeers = [] let router = MessageRouter(transports: [transport]) router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "normal-handshake") #expect(transport.sentPrivateMessages.count == 1) // BLE owns this pending send and drains it after authentication. Once // the session becomes secure, the router's targeted auth retry must // stay silent instead of producing a second copy. transport.securePeers = [peerID] router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID]) #expect(transport.sentPrivateMessages.count == 1) router.markDelivered("normal-handshake") } @Test @MainActor func authenticationRetry_scopesCollidingMessageIDsByPeer() async { let securePeer = PeerID(str: "0000000000000025") let pendingPeer = PeerID(str: "0000000000000026") let transport = MockTransport() transport.connectedPeers = [securePeer, pendingPeer] transport.securePeers = [securePeer] let router = MessageRouter(transports: [transport]) let promotedID = "collision-promoted" let clearedID = "collision-cleared" // Pending B then secure A: an ID-global marker falsely promotes B. router.sendPrivate( "pending promoted", to: pendingPeer, recipientNickname: "Pending", messageID: promotedID ) router.sendPrivate( "secure promoted", to: securePeer, recipientNickname: "Secure", messageID: promotedID ) // Secure A then pending B: an ID-global removal falsely clears A. router.sendPrivate( "secure cleared", to: securePeer, recipientNickname: "Secure", messageID: clearedID ) router.sendPrivate( "pending cleared", to: pendingPeer, recipientNickname: "Pending", messageID: clearedID ) transport.resetRecordings() transport.securePeers = [securePeer, pendingPeer] router.retrySecurePrivateMessagesAfterAuthentication(for: [pendingPeer]) #expect(transport.sentPrivateMessages.isEmpty) router.retrySecurePrivateMessagesAfterAuthentication(for: [securePeer]) #expect(transport.sentPrivateMessages.count == 2) #expect(Set(transport.sentPrivateMessages.map(\.messageID)) == [promotedID, clearedID]) #expect(transport.sentPrivateMessages.allSatisfy { $0.peerID == securePeer }) } @Test @MainActor func authenticationRetry_doesNotDuplicateMessageRequeuedByBLEForHandshake() async { let peerID = PeerID(str: "0000000000000021") let transport = MockTransport() transport.connectedPeers.insert(peerID) transport.securePeers = [peerID] let router = MessageRouter(transports: [transport]) router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "session-lost") #expect(transport.sentPrivateMessages.count == 1) // The session disappears before a normal outbox flush. That send is // now owned by BLE's pending-handshake queue, so it clears the // router's secure-auth retry marker. transport.securePeers = [] router.flushOutbox(for: peerID) #expect(transport.sentPrivateMessages.count == 2) transport.securePeers = [peerID] router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID]) #expect(transport.sentPrivateMessages.count == 2) router.markDelivered("session-lost") } @Test @MainActor func sendPrivate_fastDeliveryAckCannotRaceAheadOfRetention() async { let peerID = PeerID(str: "0000000000000017") let transport = MockTransport() transport.connectedPeers.insert(peerID) transport.securePeers = [peerID] let router = MessageRouter(transports: [transport]) transport.onSendPrivateMessage = { messageID in router.markDelivered(messageID) } router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "fast-ack") #expect(transport.sentPrivateMessages.map(\.messageID) == ["fast-ack"]) transport.onSendPrivateMessage = nil router.flushOutbox(for: peerID) #expect(transport.sentPrivateMessages.map(\.messageID) == ["fast-ack"]) } @Test @MainActor func flushOutbox_synchronousAckDoesNotResurrectSnapshotEntry() async { let peerID = PeerID(str: "0000000000000018") let transport = MockTransport() transport.connectedPeers.insert(peerID) transport.securePeers = [peerID] let router = MessageRouter(transports: [transport]) router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "flush-fast-ack") transport.onSendPrivateMessage = { messageID in router.markDelivered(messageID) } router.flushOutbox(for: peerID) #expect(transport.sentPrivateMessages.count == 2) transport.onSendPrivateMessage = nil router.flushOutbox(for: peerID) #expect(transport.sentPrivateMessages.count == 2) } @Test @MainActor func flushOutbox_dropsUnackedMessageAfterAttemptCap() async { let peerID = PeerID(str: "0000000000000008") let transport = MockTransport() transport.reachablePeers.insert(peerID) let router = MessageRouter(transports: [transport]) router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m8") for _ in 0..<10 { router.flushOutbox(for: peerID) } // Initial send plus capped resends; never unbounded. #expect(transport.sentPrivateMessages.count == 8) } // MARK: - Drop visibility (onMessageDropped) @Test @MainActor func flushOutbox_attemptCapDropInvokesOnMessageDropped() async { let peerID = PeerID(str: "0000000000000009") let transport = MockTransport() transport.reachablePeers.insert(peerID) let router = MessageRouter(transports: [transport]) var dropped: [(messageID: String, peerID: PeerID)] = [] router.onMessageDropped = { dropped.append(($0, $1)) } router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m9") for _ in 0..<10 { router.flushOutbox(for: peerID) } #expect(dropped.count == 1) #expect(dropped.first?.messageID == "m9") #expect(dropped.first?.peerID == peerID) } @Test @MainActor func flushOutbox_ttlExpiryInvokesOnMessageDroppedAndDoesNotResend() async { let peerID = PeerID(str: "000000000000000a") let transport = MockTransport() let clock = MutableTestClock() let router = MessageRouter(transports: [transport], now: { clock.now }) var dropped: [String] = [] router.onMessageDropped = { messageID, _ in dropped.append(messageID) } // No reachable transport: the message is queued, never sent. router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m10") #expect(transport.sentPrivateMessages.isEmpty) // Past the 24h TTL the flush must drop it (visibly), not send it. clock.now = clock.now.addingTimeInterval(24 * 60 * 60 + 1) transport.reachablePeers.insert(peerID) router.flushOutbox(for: peerID) #expect(dropped == ["m10"]) #expect(transport.sentPrivateMessages.isEmpty) // The drop is final: nothing is retained for later flushes. router.flushOutbox(for: peerID) #expect(dropped == ["m10"]) } @Test @MainActor func cleanupExpiredMessages_invokesOnMessageDroppedForExpiredOnly() async { let peerID = PeerID(str: "000000000000000b") let transport = MockTransport() let clock = MutableTestClock() let router = MessageRouter(transports: [transport], now: { clock.now }) var dropped: [String] = [] router.onMessageDropped = { messageID, _ in dropped.append(messageID) } router.sendPrivate("Old", to: peerID, recipientNickname: "Peer", messageID: "m11-old") clock.now = clock.now.addingTimeInterval(24 * 60 * 60 - 60) router.sendPrivate("Fresh", to: peerID, recipientNickname: "Peer", messageID: "m11-fresh") clock.now = clock.now.addingTimeInterval(120) router.cleanupExpiredMessages() #expect(dropped == ["m11-old"]) // The fresh message survived and still flushes once reachable. transport.reachablePeers.insert(peerID) router.flushOutbox(for: peerID) #expect(transport.sentPrivateMessages.map(\.messageID) == ["m11-fresh"]) } @Test @MainActor func enqueue_perPeerOverflowEvictionInvokesOnMessageDropped() async { let peerID = PeerID(str: "000000000000000c") let transport = MockTransport() let router = MessageRouter(transports: [transport]) var dropped: [String] = [] router.onMessageDropped = { messageID, _ in dropped.append(messageID) } // No reachable transport: everything queues. The cap is 100 per peer, // so the 101st enqueue evicts the oldest. for i in 0...100 { router.sendPrivate("Hello \(i)", to: peerID, recipientNickname: "Peer", messageID: "q\(i)") } #expect(dropped == ["q0"]) } @Test @MainActor func sendReadReceipt_usesReachableTransport() async { let peerID = PeerID(str: "0000000000000003") let transport = MockTransport() transport.reachablePeers.insert(peerID) let router = MessageRouter(transports: [transport]) let receipt = ReadReceipt(originalMessageID: "m3", readerID: transport.myPeerID, readerNickname: "Me") router.sendReadReceipt(receipt, to: peerID) #expect(transport.sentReadReceipts.count == 1) } @Test @MainActor func sendFavoriteNotification_usesConnectedOrReachable() async { let peerID = PeerID(str: "0000000000000004") let transport = MockTransport() transport.reachablePeers.insert(peerID) let router = MessageRouter(transports: [transport]) router.sendFavoriteNotification(to: peerID, isFavorite: true) #expect(transport.sentFavoriteNotifications.count == 1) } // MARK: - Courier deposits private static func snapshot(_ peerID: PeerID, key: Data, verified: Bool) -> TransportPeerSnapshot { TransportPeerSnapshot( peerID: peerID, nickname: "peer", isConnected: true, noisePublicKey: key, lastSeen: Date(), isVerified: verified ) } /// Directory that resolves one offline recipient and treats a fixed key /// set as mutual favorites. private static func directory(recipient: PeerID, recipientKey: Data, favoriteKeys: Set = []) -> CourierDirectory { CourierDirectory( noiseKey: { peerID in peerID == recipient ? recipientKey : nil }, isTrustedCourier: { favoriteKeys.contains($0) } ) } @Test @MainActor func sendPrivate_depositsWithVerifiedStrangerWhenNoFavoriteAround() async { let recipient = PeerID(str: "00000000000000aa") let recipientKey = Data(repeating: 0xBB, count: 32) let courier = PeerID(str: "00000000000000cc") let courierKey = Data(repeating: 0xCC, count: 32) let transport = MockTransport() transport.connectedPeers.insert(courier) transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)]) let router = MessageRouter( transports: [transport], courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey) ) router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv1") #expect(transport.sentCourierMessages.count == 1) #expect(transport.sentCourierMessages.first?.couriers == [courier]) } @Test @MainActor func sendPrivate_neverDepositsWithUnverifiedStranger() async { let recipient = PeerID(str: "00000000000000aa") let courier = PeerID(str: "00000000000000cc") let transport = MockTransport() transport.connectedPeers.insert(courier) transport.updatePeerSnapshots([Self.snapshot(courier, key: Data(repeating: 0xCC, count: 32), verified: false)]) let router = MessageRouter( transports: [transport], courierDirectory: Self.directory(recipient: recipient, recipientKey: Data(repeating: 0xBB, count: 32)) ) router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv2") #expect(transport.sentCourierMessages.isEmpty) } @Test @MainActor func sendPrivate_prefersFavoriteCouriersOverVerifiedOnes() async { let recipient = PeerID(str: "00000000000000aa") let recipientKey = Data(repeating: 0xBB, count: 32) let favorite = PeerID(str: "00000000000000f0") let favoriteKey = Data(repeating: 0xF0, count: 32) var snapshots = [Self.snapshot(favorite, key: favoriteKey, verified: false)] let transport = MockTransport() transport.connectedPeers.insert(favorite) // Three verified strangers compete for the three courier slots. for byte: UInt8 in [0xC1, 0xC2, 0xC3] { let peer = PeerID(str: String(format: "00000000000000%02x", byte)) transport.connectedPeers.insert(peer) snapshots.append(Self.snapshot(peer, key: Data(repeating: byte, count: 32), verified: true)) } transport.updatePeerSnapshots(snapshots) let router = MessageRouter( transports: [transport], courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey, favoriteKeys: [favoriteKey]) ) router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv3") let couriers = transport.sentCourierMessages.first?.couriers ?? [] #expect(couriers.count == 3) #expect(couriers.contains(favorite)) } /// Residual gap after the rotation-heal containment: a replayed "direct" /// announce (TTL is unsigned) can bind an absent victim's peer ID to the /// replayer's link, leaving the victim "connected" on a link whose Noise /// handshake can never complete. The connected fast-path must not trust /// that outright: the send still goes out (a genuine link finishes the /// handshake), but a copy is retained and a sealed copy goes to couriers /// so nothing is silently lost. @Test @MainActor func sendPrivate_connectedWithoutSecureSessionRetainsAndDepositsWithCourier() async { let victim = PeerID(str: "00000000000000aa") let victimKey = Data(repeating: 0xBB, count: 32) let courier = PeerID(str: "00000000000000cc") let courierKey = Data(repeating: 0xCC, count: 32) let transport = MockTransport() transport.connectedPeers.insert(victim) transport.connectedPeers.insert(courier) transport.securePeers = [] // no established Noise session with anyone transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)]) let router = MessageRouter( transports: [transport], courierDirectory: Self.directory(recipient: victim, recipientKey: victimKey) ) router.sendPrivate("Hello", to: victim, recipientNickname: "Peer", messageID: "cs1") // The send is still attempted (it kicks the handshake on a genuine // link) but not trusted outright: a courier gets a sealed copy now … #expect(transport.sentPrivateMessages.map(\.messageID) == ["cs1"]) #expect(transport.sentCourierMessages.count == 1) #expect(transport.sentCourierMessages.first?.messageID == "cs1") #expect(transport.sentCourierMessages.first?.recipientNoiseKey == victimKey) #expect(transport.sentCourierMessages.first?.couriers == [courier]) // … and the retained copy keeps flushing until a delivery ack — a // flush over the insecure link must resend without dropping it. router.flushOutbox(for: victim) router.flushOutbox(for: victim) #expect(transport.sentPrivateMessages.count == 3) router.markDelivered("cs1") router.flushOutbox(for: victim) #expect(transport.sentPrivateMessages.count == 3) } /// Flushes over a connected-but-insecure link never count toward the /// attempt-cap drop: the message was actually transmitted over a live /// link, so a peer whose Noise handshake stalls across reconnect flapping /// must not burn through the cap and lose the store-and-forward copy the /// secure-session gate exists to preserve. Retention stays bounded by /// the 24h outbox TTL and the per-peer FIFO cap; an ack clears it. @Test @MainActor func flushOutbox_connectedInsecureFlushesNeverDropTheRetainedCopy() async { let peerID = PeerID(str: "00000000000000ac") let transport = MockTransport() transport.connectedPeers.insert(peerID) transport.securePeers = [] // handshake never completes let router = MessageRouter(transports: [transport]) var dropped: [String] = [] router.onMessageDropped = { messageID, _ in dropped.append(messageID) } router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "ci1") // Well past maxSendAttempts (8): every flush resends, none drops. for _ in 0..<10 { router.flushOutbox(for: peerID) } #expect(dropped.isEmpty) #expect(transport.sentPrivateMessages.count == 11) // The copy is still retained and an ack still clears it. router.markDelivered("ci1") router.flushOutbox(for: peerID) #expect(transport.sentPrivateMessages.count == 11) } @Test @MainActor func authenticationRetry_capsActualSecureTransmissions() async { let peerID = PeerID(str: "00000000000000ad") let transport = MockTransport() transport.connectedPeers.insert(peerID) transport.securePeers = [peerID] let router = MessageRouter(transports: [transport]) var dropped: [String] = [] router.onMessageDropped = { messageID, _ in dropped.append(messageID) } router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "secure-retry") for _ in 0..<10 { router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID]) } #expect(dropped == ["secure-retry"]) #expect(transport.sentPrivateMessages.count == 8) } /// With an established secure session the connected fast-path sends /// immediately and never leaks to couriers, but retains a local encrypted /// outbox copy until the peer confirms receipt. @Test @MainActor func sendPrivate_connectedWithSecureSessionRetainsLocallyWithoutCourier() async { let peerID = PeerID(str: "00000000000000ab") let peerKey = Data(repeating: 0xAB, count: 32) let courier = PeerID(str: "00000000000000cc") let transport = MockTransport() transport.connectedPeers.insert(peerID) transport.connectedPeers.insert(courier) transport.securePeers = [peerID] transport.updatePeerSnapshots([Self.snapshot(courier, key: Data(repeating: 0xCC, count: 32), verified: true)]) let router = MessageRouter( transports: [transport], courierDirectory: Self.directory(recipient: peerID, recipientKey: peerKey) ) router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "cs2") #expect(transport.sentPrivateMessages.map(\.messageID) == ["cs2"]) #expect(transport.sentCourierMessages.isEmpty) router.flushOutbox(for: peerID) #expect(transport.sentPrivateMessages.count == 2) #expect(transport.sentCourierMessages.isEmpty) router.markDelivered("cs2") router.flushOutbox(for: peerID) #expect(transport.sentPrivateMessages.count == 2) } @Test @MainActor func courierBecameAvailable_retriesDepositOnceWithoutDoubleBurn() async { let recipient = PeerID(str: "00000000000000aa") let recipientKey = Data(repeating: 0xBB, count: 32) let courier = PeerID(str: "00000000000000cc") let courierKey = Data(repeating: 0xCC, count: 32) let transport = MockTransport() let router = MessageRouter( transports: [transport], courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey) ) // Nobody around at send time: the message just queues. router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cr1") #expect(transport.sentCourierMessages.isEmpty) // A verified courier appears later: the deposit retries. transport.connectedPeers.insert(courier) transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)]) router.courierBecameAvailable(courier) #expect(transport.sentCourierMessages.count == 1) #expect(transport.sentCourierMessages.first?.couriers == [courier]) // The same courier reconnecting does not receive the same mail twice. router.courierBecameAvailable(courier) #expect(transport.sentCourierMessages.count == 1) } @Test @MainActor func enqueueReplacementCarriesOverDepositedCourierKeys() async { // Re-sending a queued message ID replaces the outbox entry; the // replacement must inherit which couriers already carry the message, // or the deposit retry re-burns the same courier slots (duplicate // sealed copies to the same peer). let recipient = PeerID(str: "00000000000000aa") let recipientKey = Data(repeating: 0xBB, count: 32) let courier = PeerID(str: "00000000000000cc") let courierKey = Data(repeating: 0xCC, count: 32) let transport = MockTransport() transport.connectedPeers.insert(courier) transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)]) let router = MessageRouter( transports: [transport], courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey) ) router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "ck1") #expect(transport.sentCourierMessages.count == 1) // Same message ID re-sent (e.g. a resend while still queued): the // courier already carrying it must not receive a second copy. router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "ck1") #expect(transport.sentCourierMessages.count == 1) } @Test @MainActor func courierBecameAvailable_ignoresTheRecipientThemselves() async { let recipient = PeerID(str: "00000000000000aa") let recipientKey = Data(repeating: 0xBB, count: 32) let transport = MockTransport() let router = MessageRouter( transports: [transport], courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey) ) router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cr2") // The recipient connecting is a flush, not a courier opportunity. transport.connectedPeers.insert(recipient) transport.updatePeerSnapshots([Self.snapshot(recipient, key: recipientKey, verified: true)]) router.courierBecameAvailable(recipient) #expect(transport.sentCourierMessages.isEmpty) } @Test @MainActor func bridgeDepositCarriesOnlyAfterConfirmedSendAndRetriesFailure() async { let recipient = PeerID(str: "00000000000000ba") let recipientKey = Data(repeating: 0xBA, count: 32) let transport = MockTransport() let router = MessageRouter( transports: [transport], courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey) ) var completions: [@MainActor (Bool) -> Void] = [] router.bridgeCourierDeposit = { _, _, _, completion in completions.append(completion) } var carried: [String] = [] router.onMessageCarried = { messageID, _ in carried.append(messageID) } router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "bridge-ack") #expect(completions.count == 1) #expect(carried.isEmpty) // Sweeps while the WebSocket write is pending must not duplicate it. router.retryBridgeCourierDeposits() #expect(completions.count == 1) completions.removeFirst()(false) #expect(carried.isEmpty) // A failed socket write releases the in-flight slot for a real retry. router.retryBridgeCourierDeposits() #expect(completions.count == 1) completions.removeFirst()(true) #expect(carried == ["bridge-ack"]) } @Test @MainActor func bridgeDepositsScopeCollidingMessageIDsByRecipient() async { let firstRecipient = PeerID(str: "00000000000000b1") let secondRecipient = PeerID(str: "00000000000000b2") let firstKey = Data(repeating: 0xB1, count: 32) let secondKey = Data(repeating: 0xB2, count: 32) let recipientKeys = [ firstRecipient: firstKey, secondRecipient: secondKey ] let router = MessageRouter( transports: [MockTransport()], courierDirectory: CourierDirectory( noiseKey: { recipientKeys[$0] }, isTrustedCourier: { _ in false } ) ) var requestedKeys: [Data] = [] var completions: [@MainActor (Bool) -> Void] = [] router.bridgeCourierDeposit = { _, _, recipientKey, completion in requestedKeys.append(recipientKey) completions.append(completion) } var carriedPeers: [PeerID] = [] router.onMessageCarried = { _, peerID in carriedPeers.append(peerID) } router.sendPrivate( "First", to: firstRecipient, recipientNickname: "First", messageID: "bridge-collision" ) router.sendPrivate( "Second", to: secondRecipient, recipientNickname: "Second", messageID: "bridge-collision" ) #expect(completions.count == 2) #expect(Set(requestedKeys) == [firstKey, secondKey]) completions.forEach { $0(true) } #expect(Set(carriedPeers) == [firstRecipient, secondRecipient]) } // MARK: - Outbox persistence @Test @MainActor func queuedMessagesSurviveRouterRestart() async { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("router-outbox-\(UUID().uuidString).sealed") defer { try? FileManager.default.removeItem(at: fileURL) } let keychain = MockKeychain() let peerID = PeerID(str: "00000000000000dd") let transport = MockTransport() let router = MessageRouter( transports: [transport], outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) ) router.sendPrivate("Survive", to: peerID, recipientNickname: "Peer", messageID: "p1") #expect(transport.sentPrivateMessages.isEmpty) // "App restart": a fresh router over the same store, peer now around. let transport2 = MockTransport() transport2.reachablePeers.insert(peerID) let router2 = MessageRouter( transports: [transport2], outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) ) router2.flushOutbox(for: peerID) #expect(transport2.sentPrivateMessages.map(\.messageID) == ["p1"]) } @Test @MainActor func deliveredMessagesDoNotResurrectAfterRestart() async { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("router-outbox-\(UUID().uuidString).sealed") defer { try? FileManager.default.removeItem(at: fileURL) } let keychain = MockKeychain() let peerID = PeerID(str: "00000000000000de") let transport = MockTransport() let router = MessageRouter( transports: [transport], outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) ) router.sendPrivate("Once", to: peerID, recipientNickname: "Peer", messageID: "p2") router.markDelivered("p2") let transport2 = MockTransport() transport2.reachablePeers.insert(peerID) let router2 = MessageRouter( transports: [transport2], outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) ) router2.flushOutbox(for: peerID) #expect(transport2.sentPrivateMessages.isEmpty) } @Test @MainActor func scopedDeliveryAckClearsOnlySelectedPeerWhenMessageIDsCollide() async { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("router-outbox-scoped-ack-\(UUID().uuidString).sealed") defer { try? FileManager.default.removeItem(at: fileURL) } let keychain = MockKeychain() let acknowledgedPeer = PeerID(str: "00000000000000d1") let otherPeer = PeerID(str: "00000000000000d2") let transport = MockTransport() let router = MessageRouter( transports: [transport], outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) ) router.sendPrivate("For acknowledged peer", to: acknowledgedPeer, recipientNickname: "One", messageID: "shared-id") router.sendPrivate("For other peer", to: otherPeer, recipientNickname: "Two", messageID: "shared-id") #expect(router.markDelivered("shared-id", for: [acknowledgedPeer])) transport.reachablePeers.formUnion([acknowledgedPeer, otherPeer]) router.flushOutbox(for: acknowledgedPeer) router.flushOutbox(for: otherPeer) #expect(transport.sentPrivateMessages.map(\.peerID) == [otherPeer]) let persisted = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() #expect(persisted[acknowledgedPeer] == nil) #expect(persisted[otherPeer]?.map(\.messageID) == ["shared-id"]) } @Test @MainActor func scopedAckWhileColdLoadIsLockedPreventsOnlyTargetPeerResurrection() async { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("router-locked-scoped-ack-\(UUID().uuidString).sealed") defer { try? FileManager.default.removeItem(at: fileURL) } let keychain = MockKeychain() let acknowledgedPeer = PeerID(str: "00000000000000d3") let otherPeer = PeerID(str: "00000000000000d4") let durable = MessageOutboxStore.QueuedMessage( content: "Queued before reboot", nickname: "Peer", messageID: "shared-locked-id", timestamp: Date() ) MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([ acknowledgedPeer: [durable], otherPeer: [durable] ]) var protectedDataUnavailable = true let restoredStore = MessageOutboxStore( keychain: keychain, fileURL: fileURL, readData: { url in if protectedDataUnavailable { throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) } return try Data(contentsOf: url) } ) let transport = MockTransport() transport.reachablePeers.formUnion([acknowledgedPeer, otherPeer]) let router = MessageRouter(transports: [transport], outboxStore: restoredStore) router.markDelivered( "shared-locked-id", from: [acknowledgedPeer] ) protectedDataUnavailable = false restoredStore.retryDeferredLoad() await Task.yield() await Task.yield() #expect(transport.sentPrivateMessages.map(\.peerID) == [otherPeer]) let persisted = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() #expect(persisted[acknowledgedPeer] == nil) #expect(persisted[otherPeer]?.map(\.messageID) == ["shared-locked-id"]) } @Test @MainActor func protectedDataRecoveryMergesDurableAndLockedWakeMessagesIntoRouter() async { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("router-protected-data-\(UUID().uuidString).sealed") defer { try? FileManager.default.removeItem(at: fileURL) } let keychain = MockKeychain() let peerID = PeerID(str: "00000000000000df") let durable = MessageOutboxStore.QueuedMessage( content: "Before reboot", nickname: "Peer", messageID: "durable", timestamp: Date() ) MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]]) var protectedDataUnavailable = true let restoredStore = MessageOutboxStore( keychain: keychain, fileURL: fileURL, readData: { url in if protectedDataUnavailable { throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) } return try Data(contentsOf: url) } ) let transport = MockTransport() let router = MessageRouter(transports: [transport], outboxStore: restoredStore) router.sendPrivate("During wake", to: peerID, recipientNickname: "Peer", messageID: "wake") transport.reachablePeers.insert(peerID) protectedDataUnavailable = false restoredStore.retryDeferredLoad() // Recovery is delivered to the main actor without blocking the // protected-data notification callback. await Task.yield() await Task.yield() #expect(Set(transport.sentPrivateMessages.map(\.messageID)) == ["durable", "wake"]) } @Test @MainActor func ackWhileColdLoadIsLockedDoesNotResurrectUnseenDurableMessage() async { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("router-locked-ack-\(UUID().uuidString).sealed") defer { try? FileManager.default.removeItem(at: fileURL) } let keychain = MockKeychain() let peerID = PeerID(str: "00000000000000e0") let durable = MessageOutboxStore.QueuedMessage( content: "Already delivered", nickname: "Peer", messageID: "acked-while-locked", timestamp: Date() ) MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]]) var protectedDataUnavailable = true let restoredStore = MessageOutboxStore( keychain: keychain, fileURL: fileURL, readData: { url in if protectedDataUnavailable { throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) } return try Data(contentsOf: url) } ) let transport = MockTransport() transport.reachablePeers.insert(peerID) let router = MessageRouter(transports: [transport], outboxStore: restoredStore) // The router's locked cold-load view is empty, but the ack still has // to suppress the durable message hidden on disk. router.markDelivered("acked-while-locked") protectedDataUnavailable = false restoredStore.retryDeferredLoad() await Task.yield() await Task.yield() #expect(transport.sentPrivateMessages.isEmpty) #expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty) } @Test @MainActor func ackAfterRecoveryCaptureBeforeMainActorMergeCannotResurrectMessage() async { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("router-recovery-gap-ack-\(UUID().uuidString).sealed") defer { try? FileManager.default.removeItem(at: fileURL) } let keychain = MockKeychain() let peerID = PeerID(str: "00000000000000e1") let durable = MessageOutboxStore.QueuedMessage( content: "Captured then acked", nickname: "Peer", messageID: "recovery-gap-ack", timestamp: Date() ) MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]]) var protectedDataUnavailable = true let restoredStore = MessageOutboxStore( keychain: keychain, fileURL: fileURL, readData: { url in if protectedDataUnavailable { throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) } return try Data(contentsOf: url) } ) let transport = MockTransport() transport.reachablePeers.insert(peerID) let router = MessageRouter(transports: [transport], outboxStore: restoredStore) protectedDataUnavailable = false restoredStore.retryDeferredLoad() // captures recovery and queues MainActor merge router.markDelivered("recovery-gap-ack") // runs before that queued Task await Task.yield() await Task.yield() #expect(transport.sentPrivateMessages.isEmpty) #expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty) } @Test @MainActor func enqueueAfterRecoveryCapturePreservesUnseenDurableAndNewMessages() async { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("router-recovery-gap-enqueue-\(UUID().uuidString).sealed") defer { try? FileManager.default.removeItem(at: fileURL) } let keychain = MockKeychain() let peerID = PeerID(str: "00000000000000e2") let durable = MessageOutboxStore.QueuedMessage( content: "Before unlock", nickname: "Peer", messageID: "recovery-gap-durable", timestamp: Date() ) MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]]) var protectedDataUnavailable = true let restoredStore = MessageOutboxStore( keychain: keychain, fileURL: fileURL, readData: { url in if protectedDataUnavailable { throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) } return try Data(contentsOf: url) } ) let transport = MockTransport() let router = MessageRouter(transports: [transport], outboxStore: restoredStore) protectedDataUnavailable = false restoredStore.retryDeferredLoad() // queues merge of the durable message router.sendPrivate("During gap", to: peerID, recipientNickname: "Peer", messageID: "recovery-gap-new") transport.reachablePeers.insert(peerID) await Task.yield() await Task.yield() #expect(Set(transport.sentPrivateMessages.map(\.messageID)) == ["recovery-gap-durable", "recovery-gap-new"]) let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() #expect(Set(relaunched[peerID]?.map(\.messageID) ?? []) == ["recovery-gap-durable", "recovery-gap-new"]) } @Test @MainActor func removingKnownWakeMessageInRecoveryGapPreservesOnlyUnseenDurableState() async { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("router-recovery-gap-known-removal-\(UUID().uuidString).sealed") defer { try? FileManager.default.removeItem(at: fileURL) } let keychain = MockKeychain() let peerID = PeerID(str: "00000000000000e3") let durable = MessageOutboxStore.QueuedMessage( content: "Unseen durable", nickname: "Peer", messageID: "recovery-gap-unseen", timestamp: Date() ) MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]]) var protectedDataUnavailable = true let restoredStore = MessageOutboxStore( keychain: keychain, fileURL: fileURL, readData: { url in if protectedDataUnavailable { throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) } return try Data(contentsOf: url) } ) let transport = MockTransport() let router = MessageRouter(transports: [transport], outboxStore: restoredStore) router.sendPrivate("Known wake", to: peerID, recipientNickname: "Peer", messageID: "recovery-gap-known") protectedDataUnavailable = false restoredStore.retryDeferredLoad() // captures unseen durable + known wake // A secure direct retry followed by its delivery ack removes the wake // message before recovery's MainActor merge. It must remain removed, // while the unseen durable message still arrives through the pending // recovery claim. transport.connectedPeers.insert(peerID) transport.securePeers = [peerID] router.flushOutbox(for: peerID) router.markDelivered("recovery-gap-known") await Task.yield() await Task.yield() let sentIDs = transport.sentPrivateMessages.map(\.messageID) #expect(sentIDs.filter { $0 == "recovery-gap-known" }.count == 1) #expect(sentIDs.filter { $0 == "recovery-gap-unseen" }.count == 1) } @Test @MainActor func failedRecoveryWriteStillPreservesUnseenDurableStateAcrossGapRemoval() async { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("router-recovery-write-failure-\(UUID().uuidString).sealed") defer { try? FileManager.default.removeItem(at: fileURL) } let keychain = MockKeychain() let peerID = PeerID(str: "00000000000000e4") let durable = MessageOutboxStore.QueuedMessage( content: "Unseen before unlock", nickname: "Peer", messageID: "recovery-write-failure-unseen", timestamp: Date() ) MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]]) var protectedDataUnavailable = true var failWrites = false var injectedFailureCount = 0 let restoredStore = MessageOutboxStore( keychain: keychain, fileURL: fileURL, readData: { url in if protectedDataUnavailable { throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) } return try Data(contentsOf: url) }, writeData: { data, url, options in if failWrites { injectedFailureCount += 1 throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteNoPermissionError) } try data.write(to: url, options: options) } ) let transport = MockTransport() let router = MessageRouter(transports: [transport], outboxStore: restoredStore) router.sendPrivate( "Known wake", to: peerID, recipientNickname: "Peer", messageID: "recovery-write-failure-known" ) // The first post-unlock save reads durable D and knows router state W, // but its D+W write fails. A later retry must retain the original // unseen-D classification instead of mistaking the cached union for // a fully router-known snapshot. protectedDataUnavailable = false failWrites = true router.sendPrivate( "Known wake", to: peerID, recipientNickname: "Peer", messageID: "recovery-write-failure-known" ) failWrites = false #expect(injectedFailureCount == 1) restoredStore.retryDeferredLoad() // persists D+W and queues recovery transport.connectedPeers.insert(peerID) transport.securePeers = [peerID] router.flushOutbox(for: peerID) router.markDelivered("recovery-write-failure-known") // removes W before queued callback // The gap save may remove W, but it must leave unseen D durable until // MessageRouter receives the pending recovery callback. let gapSnapshot = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() #expect(gapSnapshot[peerID]?.map(\.messageID) == ["recovery-write-failure-unseen"]) await Task.yield() await Task.yield() let sentIDs = transport.sentPrivateMessages.map(\.messageID) #expect(sentIDs.filter { $0 == "recovery-write-failure-known" }.count == 1) #expect(sentIDs.filter { $0 == "recovery-write-failure-unseen" }.count == 1) } } /// Mutable wall clock injected into `MessageRouter` so TTL expiry is testable /// without real waiting. private final class MutableTestClock { var now = Date(timeIntervalSince1970: 1_700_000_000) }