Files
bitchat/bitchatTests/Services/MessageOutboxStoreTests.swift
T
7341696280 Expand store-and-forward: open couriers, spray-and-wait, persistent outbox, 6h public history (#1372)
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>
2026-07-06 19:33:16 +02:00

93 lines
3.5 KiB
Swift

//
// MessageOutboxStoreTests.swift
// bitchatTests
//
// Tests for the encrypted-at-rest outbox persistence.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
struct MessageOutboxStoreTests {
private func makeTempURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("outbox-\(UUID().uuidString).sealed")
}
private func makeMessage(_ id: String, content: String = "hello") -> MessageOutboxStore.QueuedMessage {
MessageOutboxStore.QueuedMessage(
content: content,
nickname: "peer",
messageID: id,
timestamp: Date(timeIntervalSince1970: 1_750_000_000),
sendAttempts: 2,
depositedCourierKeys: [Data(repeating: 0xC1, count: 32)]
)
}
@Test func roundTripAcrossInstances() {
let fileURL = makeTempURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "0000000000000001")
let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
store.save([peerID: [makeMessage("m1")]])
// Same keychain (encryption key) reads it back, fields intact.
let reloaded = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
#expect(reloaded[peerID]?.count == 1)
#expect(reloaded[peerID]?.first?.messageID == "m1")
#expect(reloaded[peerID]?.first?.sendAttempts == 2)
#expect(reloaded[peerID]?.first?.depositedCourierKeys.count == 1)
}
@Test func contentIsNotPlaintextOnDisk() throws {
let fileURL = makeTempURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let store = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL)
store.save([PeerID(str: "0000000000000001"): [makeMessage("m1", content: "very secret message")]])
let raw = try Data(contentsOf: fileURL)
#expect(!raw.isEmpty)
// Sealed bytes must not contain the message plaintext.
#expect(raw.range(of: Data("very secret message".utf8)) == nil)
}
@Test func loadWithoutKeyReturnsEmpty() {
let fileURL = makeTempURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let store = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL)
store.save([PeerID(str: "0000000000000001"): [makeMessage("m1")]])
// A different keychain (fresh device / wiped key) cannot read the file.
let other = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL)
#expect(other.load().isEmpty)
}
@Test func wipeRemovesFileAndKey() {
let fileURL = makeTempURL()
let keychain = MockKeychain()
let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
store.save([PeerID(str: "0000000000000001"): [makeMessage("m1")]])
#expect(FileManager.default.fileExists(atPath: fileURL.path))
store.wipe()
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
#expect(store.load().isEmpty)
}
@Test func savingEmptyOutboxRemovesFile() {
let fileURL = makeTempURL()
let keychain = MockKeychain()
let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
let peerID = PeerID(str: "0000000000000001")
store.save([peerID: [makeMessage("m1")]])
store.save([peerID: []])
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
}
}