mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
Store-and-forward previously delivered to an out-of-range peer only if a
mutual favorite happened to be connected at send time and later met the
recipient directly, and everything except courier envelopes died with the
app process. This closes those gaps end to end:
- Persist the MessageRouter outbox to disk, sealed with a ChaChaPoly key
held only in the Keychain (no plaintext at rest); queued private
messages now survive an app kill and flush on next launch.
- Deposit retry: queued messages are re-deposited whenever a new eligible
courier connects, tracked per message so the same courier is never
double-burned, until 3 distinct couriers carry it or it expires.
- Tiered open couriering: signature-verified strangers can now carry mail
(2 envelopes/depositor into a 20-slot pool) alongside mutual favorites
(5 each); overflow evicts verified-tier mail before favorites'.
- Spray-and-wait: envelopes carry a copy budget (4, capped 8, new TLV,
wire-compatible with old clients); couriers split half their remaining
budget with each newly encountered courier so mail diffuses through a
moving crowd.
- Remote handover: a verified relayed announce now floods a copy toward
the multi-hop recipient (directed-relay treatment, 10-min per-envelope
cooldown) while the carried original stays put for a direct encounter.
- Public history: gossip-sync window for whole public messages widened
from 15 min to 6 h, matched on the receive-acceptance side, and the
message store persists to disk so devices bridge partitions and
restarts ("town crier").
- Privacy-safe local delivery counters (bare tallies, log-only) so the
store-and-forward stack is measurable on-device.
- Panic wipe now also clears the sealed outbox, gossip archive, and
counters.
- Rewrite WHITEPAPER.md to describe the app as implemented (Noise XX/X,
actual flood control, courier system, gossip sync, Nostr path); the old
document described a bloom filter, three fragment types, and a
MessageRetryService that don't exist.
1037 macOS tests pass (17 new); iOS builds.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
427 lines
17 KiB
Swift
427 lines
17 KiB
Swift
//
|
|
// 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 sendPrivate_connectedSendIsNotRetained() async {
|
|
let peerID = PeerID(str: "0000000000000007")
|
|
let transport = MockTransport()
|
|
transport.connectedPeers.insert(peerID)
|
|
transport.reachablePeers.insert(peerID)
|
|
|
|
let router = MessageRouter(transports: [transport])
|
|
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m7")
|
|
#expect(transport.sentPrivateMessages.count == 1)
|
|
|
|
router.flushOutbox(for: peerID)
|
|
#expect(transport.sentPrivateMessages.count == 1)
|
|
}
|
|
|
|
@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<Data> = []) -> 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))
|
|
}
|
|
|
|
@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 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|