Files
bitchat/bitchatTests/Services/MessageOutboxStoreTests.swift
jackandGitHub 820c933958 Harden PTT audio and the 1.7.1 release (#1423)
Centralize PTT and voice audio-session ownership, harden courier/bridge/outbox delivery and recovery, correct location and delivery-state races, add privacy/release metadata, and ship reproducible universal Arti slices with Release CI coverage.

Validated by the full iOS suite, repeated audio/fragment/performance regressions, BitFoundation tests, strict lint and dead-code analysis, universal iOS Release builds, and iOS/macOS archives.
2026-07-10 14:04:09 +02:00

297 lines
12 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 permanentlyMissingDeviceKeyDiscardsOrphanAndNewSavesSurviveRelaunch() {
let fileURL = makeTempURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "0000000000000001")
MessageOutboxStore(keychain: keychain, fileURL: fileURL)
.save([peerID: [makeMessage("orphaned")]])
#expect(FileManager.default.fileExists(atPath: fileURL.path))
// Models a device restore: Application Support brought the sealed
// file across, but its AfterFirstUnlockThisDeviceOnly key cannot.
keychain.deleteAll(service: "chat.bitchat.outbox")
let restored = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
#expect(restored.load().isEmpty)
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
restored.save([peerID: [makeMessage("fresh")]])
let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
#expect(relaunched[peerID]?.map(\.messageID) == ["fresh"])
}
@Test func temporarilyLockedKeyDoesNotDiscardDurableSnapshot() {
let fileURL = makeTempURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "0000000000000001")
MessageOutboxStore(keychain: keychain, fileURL: fileURL)
.save([peerID: [makeMessage("durable")]])
let durableBytes = try? Data(contentsOf: fileURL)
keychain.simulatedGenericReadError = .deviceLocked
let restored = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
#expect(restored.load().isEmpty)
#expect((try? Data(contentsOf: fileURL)) == durableBytes)
keychain.simulatedGenericReadError = nil
let recovered = restored.retryDeferredLoad()
#expect(recovered?[peerID]?.map(\.messageID) == ["durable"])
}
@Test @MainActor
func panicWipeInvalidatesQueuedRecoveryCallback() async {
let fileURL = makeTempURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "0000000000000001")
MessageOutboxStore(keychain: keychain, fileURL: fileURL)
.save([peerID: [makeMessage("durable")]])
var protectedDataUnavailable = true
let restored = MessageOutboxStore(
keychain: keychain,
fileURL: fileURL,
readData: { url in
if protectedDataUnavailable {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
}
return try Data(contentsOf: url)
}
)
#expect(restored.load().isEmpty)
var deliveredRecoveries: [MessageOutboxStore.Snapshot] = []
restored.setRecoveryHandler { deliveredRecoveries.append($0) }
protectedDataUnavailable = false
restored.retryDeferredLoad() // queues the handler onto MainActor
restored.wipe() // invalidates it before the queued Task runs
await Task.yield()
#expect(deliveredRecoveries.isEmpty)
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
}
@Test @MainActor
func wipeBetweenRecoveryUnlockAndNotificationDropsRecoveredSnapshot() async {
let fileURL = makeTempURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "0000000000000001")
MessageOutboxStore(keychain: keychain, fileURL: fileURL)
.save([peerID: [makeMessage("durable")]])
var protectedDataUnavailable = true
var gapAction: (() -> Void)?
let restored = MessageOutboxStore(
keychain: keychain,
fileURL: fileURL,
readData: { url in
if protectedDataUnavailable {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
}
return try Data(contentsOf: url)
},
beforeRecoveryNotification: { gapAction?() }
)
#expect(restored.load().isEmpty)
var deliveredRecoveries: [MessageOutboxStore.Snapshot] = []
restored.setRecoveryHandler { deliveredRecoveries.append($0) }
gapAction = { restored.wipe() }
protectedDataUnavailable = false
restored.retryDeferredLoad()
await Task.yield()
#expect(deliveredRecoveries.isEmpty)
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
}
@Test func deferredRemovalTombstoneFiltersUnseenDurableMessage() {
let fileURL = makeTempURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "0000000000000001")
MessageOutboxStore(keychain: keychain, fileURL: fileURL)
.save([peerID: [makeMessage("durable")]])
var protectedDataUnavailable = true
let restored = MessageOutboxStore(
keychain: keychain,
fileURL: fileURL,
readData: { url in
if protectedDataUnavailable {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
}
return try Data(contentsOf: url)
}
)
#expect(restored.load().isEmpty)
restored.recordRemoval(messageID: "durable")
restored.save([:])
protectedDataUnavailable = false
let recovered = restored.retryDeferredLoad()
#expect(recovered?.isEmpty == true)
#expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).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))
}
@Test func protectedDataReadFailureDefersWriteAndMergesOnRecovery() {
let fileURL = makeTempURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "0000000000000001")
let seed = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
seed.save([peerID: [makeMessage("durable")]])
let durableBytes = try? Data(contentsOf: fileURL)
var protectedDataUnavailable = true
let restored = MessageOutboxStore(
keychain: keychain,
fileURL: fileURL,
readData: { url in
if protectedDataUnavailable {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
}
return try Data(contentsOf: url)
}
)
#expect(restored.load().isEmpty)
restored.save([peerID: [makeMessage("during-wake")]])
// The unreadable durable snapshot was not replaced by the partial
// in-memory outbox from the locked restoration.
#expect((try? Data(contentsOf: fileURL)) == durableBytes)
protectedDataUnavailable = false
let recovered = restored.retryDeferredLoad()
#expect(Set(recovered?[peerID]?.map(\.messageID) ?? []) == ["durable", "during-wake"])
let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
#expect(Set(relaunched[peerID]?.map(\.messageID) ?? []) == ["durable", "during-wake"])
}
@Test func lockedWakeRemovalDoesNotResurrectPendingMessageOnRecovery() {
let fileURL = makeTempURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "0000000000000001")
MessageOutboxStore(keychain: keychain, fileURL: fileURL)
.save([peerID: [makeMessage("durable")]])
var protectedDataUnavailable = true
let restored = MessageOutboxStore(
keychain: keychain,
fileURL: fileURL,
readData: { url in
if protectedDataUnavailable {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
}
return try Data(contentsOf: url)
}
)
#expect(restored.load().isEmpty)
restored.save([peerID: [makeMessage("wake")]])
// A later delivery ack produces the complete empty in-memory view.
restored.save([:])
protectedDataUnavailable = false
let recovered = restored.retryDeferredLoad()
#expect(recovered?[peerID]?.map(\.messageID) == ["durable"])
}
}