mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
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.
297 lines
12 KiB
Swift
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"])
|
|
}
|
|
}
|