mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 22:25:19 +00:00
Make panic wipe deterministic and device-bound (#1431)
* Make panic wipe deterministic and device-bound * Scope install markers to iOS * Harden panic recovery and service shutdown * Invalidate queued BLE ingress during panic * Harden panic keychain and media cleanup --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: jack <jack@deck.local>
This commit is contained in:
@@ -572,6 +572,108 @@ struct BLEServiceCoreTests {
|
||||
#expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func panicSuspension_dropsLateOutboundWorkUntilCommit() async {
|
||||
let ble = makeService()
|
||||
let outbound = OutboundPacketTap()
|
||||
ble._test_onOutboundPacket = outbound.record
|
||||
let packet = makePublicPacket(
|
||||
content: "late callback",
|
||||
sender: ble.myPeerID,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
)
|
||||
|
||||
ble.suspendForPanicReset()
|
||||
ble.sendPacket(packet)
|
||||
#expect(outbound.count(ofType: .message) == 0)
|
||||
|
||||
ble.completePanicReset(restartServices: false)
|
||||
ble.sendPacket(packet)
|
||||
#expect(outbound.count(ofType: .message) == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicSuspension_invalidatesQueuedMainActorIngress() async {
|
||||
let ble = makeService()
|
||||
let delegate = TransportEventCaptureDelegate()
|
||||
ble.eventDelegate = delegate
|
||||
let message = BitchatMessage(
|
||||
id: "pre-panic-ingress",
|
||||
sender: "Peer",
|
||||
content: "must not survive panic",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: PeerID(str: "1122334455667788")
|
||||
)
|
||||
|
||||
// The test already owns MainActor, so this task cannot run until the
|
||||
// synchronous panic boundary below has invalidated its generation.
|
||||
ble._test_emitTransportEvent(.messageReceived(message))
|
||||
ble.suspendForPanicReset()
|
||||
await Task.yield()
|
||||
#expect(delegate.messageIDs.isEmpty)
|
||||
|
||||
ble.completePanicReset(restartServices: false)
|
||||
ble._test_emitTransportEvent(.messageReceived(message))
|
||||
await Task.yield()
|
||||
#expect(delegate.messageIDs == [message.id])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicSuspension_rejectsPausedBLEReceiveBeforeMessageQueueHandoff() async {
|
||||
let ble = makeService()
|
||||
let gate = ReceivePacketHandoffGate()
|
||||
ble._test_beforeReceivePacketHandoff = gate.pause
|
||||
ble._test_onReceivePacketHandoff = gate.recordHandoff
|
||||
defer {
|
||||
gate.release()
|
||||
ble._test_beforeReceivePacketHandoff = nil
|
||||
ble._test_onReceivePacketHandoff = nil
|
||||
}
|
||||
|
||||
let sender = PeerID(str: "1122334455667788")
|
||||
let packet = makePublicPacket(
|
||||
content: "must not cross panic",
|
||||
sender: sender,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
)
|
||||
ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ gate.hasPaused },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
|
||||
// Panic closes the lifecycle before waiting for the paused bleQueue
|
||||
// callback. Releasing it afterward lets the callback enqueue its
|
||||
// messageQueue handoff, where the captured generation must be rejected
|
||||
// before packet processing starts.
|
||||
let panicIngressObserver = PanicIngressObserver(service: ble)
|
||||
let didObservePanicClosure = await withCheckedContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let didObserveClosure = panicIngressObserver.waitUntilClosed(
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
gate.release()
|
||||
continuation.resume(returning: didObserveClosure)
|
||||
}
|
||||
ble.suspendForPanicReset()
|
||||
}
|
||||
|
||||
#expect(didObservePanicClosure)
|
||||
#expect(gate.handoffCount == 0)
|
||||
|
||||
// A packet captured under the reopened lifecycle still crosses the
|
||||
// same handoff, proving the test did not merely disable the hook.
|
||||
ble.completePanicReset(restartServices: false)
|
||||
ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ gate.handoffCount == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
||||
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
||||
@@ -666,6 +768,68 @@ private final class OutboundPacketTap {
|
||||
}
|
||||
}
|
||||
|
||||
private final class ReceivePacketHandoffGate: @unchecked Sendable {
|
||||
private let condition = NSCondition()
|
||||
private var paused = false
|
||||
private var released = false
|
||||
private var recordedHandoffCount = 0
|
||||
|
||||
var hasPaused: Bool {
|
||||
condition.lock()
|
||||
defer { condition.unlock() }
|
||||
return paused
|
||||
}
|
||||
|
||||
var handoffCount: Int {
|
||||
condition.lock()
|
||||
defer { condition.unlock() }
|
||||
return recordedHandoffCount
|
||||
}
|
||||
|
||||
func pause() {
|
||||
condition.lock()
|
||||
paused = true
|
||||
condition.broadcast()
|
||||
while !released {
|
||||
condition.wait()
|
||||
}
|
||||
condition.unlock()
|
||||
}
|
||||
|
||||
func release() {
|
||||
condition.lock()
|
||||
released = true
|
||||
condition.broadcast()
|
||||
condition.unlock()
|
||||
}
|
||||
|
||||
func recordHandoff() {
|
||||
condition.lock()
|
||||
recordedHandoffCount += 1
|
||||
condition.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets a dedicated dispatch worker observe the lock-protected panic gate
|
||||
/// without treating the full BLE service as generally Sendable.
|
||||
private final class PanicIngressObserver: @unchecked Sendable {
|
||||
private let service: BLEService
|
||||
|
||||
init(service: BLEService) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
func waitUntilClosed(timeout: TimeInterval) -> Bool {
|
||||
let deadline = DispatchTime.now().uptimeNanoseconds
|
||||
+ UInt64(timeout * 1_000_000_000)
|
||||
while service._test_isPanicIngressOpen,
|
||||
DispatchTime.now().uptimeNanoseconds < deadline {
|
||||
Thread.sleep(forTimeInterval: 0.001)
|
||||
}
|
||||
return !service._test_isPanicIngressOpen
|
||||
}
|
||||
}
|
||||
|
||||
private func makeService() -> BLEService {
|
||||
let keychain = MockKeychain()
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
@@ -724,3 +888,13 @@ private final class PublicCaptureDelegate: BitchatDelegate {
|
||||
return publicMessages
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class TransportEventCaptureDelegate: TransportEventDelegate {
|
||||
private(set) var messageIDs: [String] = []
|
||||
|
||||
func didReceiveTransportEvent(_ event: TransportEvent) {
|
||||
guard case .messageReceived(let message) = event else { return }
|
||||
messageIDs.append(message.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - Mock Context
|
||||
@@ -188,6 +193,114 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func resetForPanic_cancelsEveryTransportTransferAndClearsMappings() {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
coordinator.registerTransfer(transferId: "t1", messageID: "m1")
|
||||
coordinator.registerTransfer(transferId: "t1", messageID: "m2")
|
||||
coordinator.registerTransfer(transferId: "t2", messageID: "m3")
|
||||
|
||||
coordinator.resetForPanic()
|
||||
|
||||
#expect(Set(context.cancelledTransfers) == Set(["t1", "t2"]))
|
||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func resetForPanic_waitsForActiveImageWriterBeforeReturning() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let sourceURL = try makeCoordinatorTestImageURL()
|
||||
let outputURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("panic-prepared-\(UUID().uuidString).jpg")
|
||||
let preparer = PausedImagePreparer(outputURL: outputURL)
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareImagePacket: { sourceURL in
|
||||
try preparer.prepare(sourceURL)
|
||||
}
|
||||
)
|
||||
defer {
|
||||
preparer.release()
|
||||
try? FileManager.default.removeItem(at: sourceURL)
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
}
|
||||
|
||||
coordinator.sendImage(from: sourceURL)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ preparer.hasStarted },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).asyncAfter(
|
||||
deadline: .now() + .milliseconds(100)
|
||||
) {
|
||||
preparer.release()
|
||||
}
|
||||
coordinator.resetForPanic()
|
||||
|
||||
// The synchronous reset boundary cannot return while a pre-panic
|
||||
// writer can still create output. The real panic path deletes media
|
||||
// immediately after this method returns.
|
||||
#expect(preparer.hasFinished)
|
||||
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ !FileManager.default.fileExists(atPath: outputURL.path) },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
await Task.yield()
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
#expect(context.broadcastFileSends.isEmpty)
|
||||
#expect(context.systemMessages.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func imagePreparation_doesNotRetainCoordinatorOrDeallocatedContext() async throws {
|
||||
let sourceURL = try makeCoordinatorTestImageURL()
|
||||
let outputURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("released-context-\(UUID().uuidString).jpg")
|
||||
let preparer = PausedImagePreparer(outputURL: outputURL)
|
||||
var context: MockChatMediaTransferContext? = MockChatMediaTransferContext()
|
||||
var coordinator: ChatMediaTransferCoordinator? = ChatMediaTransferCoordinator(
|
||||
context: context!,
|
||||
prepareImagePacket: { sourceURL in
|
||||
try preparer.prepare(sourceURL)
|
||||
}
|
||||
)
|
||||
weak var weakContext: MockChatMediaTransferContext?
|
||||
weak var weakCoordinator: ChatMediaTransferCoordinator?
|
||||
weakContext = context
|
||||
weakCoordinator = coordinator
|
||||
defer {
|
||||
preparer.release()
|
||||
try? FileManager.default.removeItem(at: sourceURL)
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
}
|
||||
|
||||
coordinator?.sendImage(from: sourceURL)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ preparer.hasStarted },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
|
||||
coordinator = nil
|
||||
context = nil
|
||||
#expect(weakCoordinator == nil)
|
||||
#expect(weakContext == nil)
|
||||
|
||||
preparer.release()
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ preparer.hasFinished },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ !FileManager.default.fileExists(atPath: outputURL.path) },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
@@ -207,3 +320,91 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private final class PausedImagePreparer: @unchecked Sendable {
|
||||
private let condition = NSCondition()
|
||||
private let outputURL: URL
|
||||
private var started = false
|
||||
private var released = false
|
||||
private var finished = false
|
||||
|
||||
init(outputURL: URL) {
|
||||
self.outputURL = outputURL
|
||||
}
|
||||
|
||||
var hasStarted: Bool {
|
||||
condition.lock()
|
||||
defer { condition.unlock() }
|
||||
return started
|
||||
}
|
||||
|
||||
var hasFinished: Bool {
|
||||
condition.lock()
|
||||
defer { condition.unlock() }
|
||||
return finished
|
||||
}
|
||||
|
||||
func prepare(_ _: URL) throws -> ChatPreparedImage {
|
||||
condition.lock()
|
||||
started = true
|
||||
condition.broadcast()
|
||||
while !released {
|
||||
condition.wait()
|
||||
}
|
||||
condition.unlock()
|
||||
|
||||
let data = Data("prepared image".utf8)
|
||||
try data.write(to: outputURL, options: .atomic)
|
||||
let packet = BitchatFilePacket(
|
||||
fileName: outputURL.lastPathComponent,
|
||||
fileSize: UInt64(data.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: data
|
||||
)
|
||||
|
||||
condition.lock()
|
||||
finished = true
|
||||
condition.broadcast()
|
||||
condition.unlock()
|
||||
return ChatPreparedImage(outputURL: outputURL, packet: packet)
|
||||
}
|
||||
|
||||
func release() {
|
||||
condition.lock()
|
||||
released = true
|
||||
condition.broadcast()
|
||||
condition.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private func makeCoordinatorTestImageURL() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("coordinator-image-\(UUID().uuidString).png")
|
||||
#if os(iOS)
|
||||
let image = UIGraphicsImageRenderer(size: CGSize(width: 16, height: 16))
|
||||
.image { context in
|
||||
UIColor.systemBlue.setFill()
|
||||
context.fill(CGRect(x: 0, y: 0, width: 16, height: 16))
|
||||
}
|
||||
guard let data = image.pngData() else {
|
||||
throw CoordinatorImageTestError.encodingFailed
|
||||
}
|
||||
#else
|
||||
let image = NSImage(size: NSSize(width: 16, height: 16))
|
||||
image.lockFocus()
|
||||
NSColor.systemBlue.setFill()
|
||||
NSRect(x: 0, y: 0, width: 16, height: 16).fill()
|
||||
image.unlockFocus()
|
||||
guard let tiff = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiff),
|
||||
let data = bitmap.representation(using: .png, properties: [:]) else {
|
||||
throw CoordinatorImageTestError.encodingFailed
|
||||
}
|
||||
#endif
|
||||
try data.write(to: url, options: .atomic)
|
||||
return url
|
||||
}
|
||||
|
||||
private enum CoordinatorImageTestError: Error {
|
||||
case encodingFailed
|
||||
}
|
||||
|
||||
@@ -15,8 +15,13 @@ import BitFoundation
|
||||
|
||||
/// Creates a ChatViewModel with mock dependencies for testing
|
||||
@MainActor
|
||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
let keychain = MockKeychain()
|
||||
private func makeTestableViewModel(
|
||||
keychain injectedKeychain: MockKeychain? = nil,
|
||||
panicMediaWipe: (() throws -> Void)? = nil,
|
||||
panicRecoveryOperations: PanicRecoveryOperations? = nil,
|
||||
panicNetworkLifecycle: PanicNetworkLifecycle = .noop
|
||||
) -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
let keychain = injectedKeychain ?? MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
@@ -26,7 +31,10 @@ private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: Mo
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport
|
||||
transport: transport,
|
||||
panicMediaWipe: panicMediaWipe,
|
||||
panicRecoveryOperations: panicRecoveryOperations,
|
||||
panicNetworkLifecycle: panicNetworkLifecycle
|
||||
)
|
||||
|
||||
return (viewModel, transport)
|
||||
@@ -1116,6 +1124,159 @@ struct ChatViewModelBluetoothTests {
|
||||
|
||||
struct ChatViewModelPanicTests {
|
||||
|
||||
@Test @MainActor
|
||||
func panicClearAllData_finishesMediaWipeBeforeReturning() {
|
||||
var wipeFinished = false
|
||||
let (viewModel, _) = makeTestableViewModel(panicMediaWipe: {
|
||||
wipeFinished = true
|
||||
})
|
||||
|
||||
viewModel.panicClearAllData()
|
||||
|
||||
#expect(wipeFinished)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicClearAllData_stopsNetworkBeforeWipeAndRestartsAfterCommit() {
|
||||
var events: [String] = []
|
||||
let lifecycle = PanicNetworkLifecycle(
|
||||
stop: { events.append("stop") },
|
||||
restart: { events.append("restart") }
|
||||
)
|
||||
let (viewModel, _) = makeTestableViewModel(
|
||||
panicMediaWipe: { events.append("wipe") },
|
||||
panicNetworkLifecycle: lifecycle
|
||||
)
|
||||
|
||||
let completed = viewModel.panicClearAllData()
|
||||
|
||||
#expect(completed)
|
||||
#expect(events == ["stop", "wipe", "restart"])
|
||||
#expect(viewModel.networkActivationAllowed)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicKeychainFailureKeepsRecoveryPendingAndServicesStopped() {
|
||||
let keychain = MockKeychain()
|
||||
keychain.simulatedDeleteAllResult = false
|
||||
var events: [String] = []
|
||||
let operations = PanicRecoveryOperations(
|
||||
isPending: { false },
|
||||
begin: {
|
||||
events.append("begin")
|
||||
return PanicRecoveryIntent(
|
||||
fileMarkerEstablished: true,
|
||||
externalMarkerEstablished: false
|
||||
)
|
||||
},
|
||||
wipeMedia: { _ in events.append("wipe") },
|
||||
complete: { events.append("complete") }
|
||||
)
|
||||
let lifecycle = PanicNetworkLifecycle(
|
||||
stop: { events.append("stop") },
|
||||
restart: { events.append("restart") }
|
||||
)
|
||||
let (viewModel, transport) = makeTestableViewModel(
|
||||
keychain: keychain,
|
||||
panicRecoveryOperations: operations,
|
||||
panicNetworkLifecycle: lifecycle
|
||||
)
|
||||
let startsBeforePanic = transport.startServicesCallCount
|
||||
|
||||
let completed = viewModel.panicClearAllData()
|
||||
|
||||
#expect(!completed)
|
||||
#expect(events == ["stop", "begin", "wipe"])
|
||||
#expect(keychain.deleteAllCallCount == 1)
|
||||
#expect(transport.startServicesCallCount == startsBeforePanic)
|
||||
#expect(!viewModel.networkActivationAllowed)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func pendingPanicRecoveryCompletesBeforeTransportBootstrap() {
|
||||
var events: [String] = []
|
||||
let operations = PanicRecoveryOperations(
|
||||
isPending: {
|
||||
events.append("read")
|
||||
return true
|
||||
},
|
||||
begin: {
|
||||
events.append("begin")
|
||||
return PanicRecoveryIntent(
|
||||
fileMarkerEstablished: true,
|
||||
externalMarkerEstablished: false
|
||||
)
|
||||
},
|
||||
wipeMedia: { _ in events.append("wipe") },
|
||||
complete: { events.append("complete") }
|
||||
)
|
||||
|
||||
let (viewModel, transport) = makeTestableViewModel(
|
||||
panicRecoveryOperations: operations
|
||||
)
|
||||
|
||||
#expect(events == ["read", "begin", "wipe", "complete"])
|
||||
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||
#expect(transport.startServicesCallCount == 1)
|
||||
#expect(viewModel.networkActivationAllowed)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func failedStartupRecoveryLeavesTransportAndNetworkBlocked() {
|
||||
enum WipeFailure: Error { case failed }
|
||||
var completedMarker = false
|
||||
let operations = PanicRecoveryOperations(
|
||||
isPending: { true },
|
||||
begin: {
|
||||
PanicRecoveryIntent(
|
||||
fileMarkerEstablished: true,
|
||||
externalMarkerEstablished: false
|
||||
)
|
||||
},
|
||||
wipeMedia: { _ in throw WipeFailure.failed },
|
||||
complete: { completedMarker = true }
|
||||
)
|
||||
|
||||
let (viewModel, transport) = makeTestableViewModel(
|
||||
panicRecoveryOperations: operations
|
||||
)
|
||||
|
||||
#expect(!completedMarker)
|
||||
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||
#expect(transport.startServicesCallCount == 0)
|
||||
#expect(!viewModel.networkActivationAllowed)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func failedStartupKeychainRecoveryLeavesIntentAndTransportBlocked() {
|
||||
let keychain = MockKeychain()
|
||||
keychain.simulatedDeleteAllResult = false
|
||||
var events: [String] = []
|
||||
let operations = PanicRecoveryOperations(
|
||||
isPending: { true },
|
||||
begin: {
|
||||
events.append("begin")
|
||||
return PanicRecoveryIntent(
|
||||
fileMarkerEstablished: true,
|
||||
externalMarkerEstablished: true
|
||||
)
|
||||
},
|
||||
wipeMedia: { _ in events.append("wipe") },
|
||||
complete: { events.append("complete") }
|
||||
)
|
||||
|
||||
let (viewModel, transport) = makeTestableViewModel(
|
||||
keychain: keychain,
|
||||
panicRecoveryOperations: operations
|
||||
)
|
||||
|
||||
#expect(events == ["begin", "wipe"])
|
||||
#expect(keychain.deleteAllCallCount == 1)
|
||||
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||
#expect(transport.startServicesCallCount == 0)
|
||||
#expect(!viewModel.networkActivationAllowed)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicClearAllData_delegatesToTransport() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
@@ -18,6 +18,8 @@ final class MockKeychain: KeychainManagerProtocol {
|
||||
var simulatedReadError: KeychainReadResult?
|
||||
var simulatedSaveError: KeychainSaveResult?
|
||||
var simulatedGenericReadError: KeychainReadResult?
|
||||
var simulatedDeleteAllResult = true
|
||||
private(set) var deleteAllCallCount = 0
|
||||
|
||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||
storage[key] = keyData
|
||||
@@ -34,6 +36,8 @@ final class MockKeychain: KeychainManagerProtocol {
|
||||
}
|
||||
|
||||
func deleteAllKeychainData() -> Bool {
|
||||
deleteAllCallCount += 1
|
||||
guard simulatedDeleteAllResult else { return false }
|
||||
storage.removeAll()
|
||||
serviceStorage.removeAll()
|
||||
return true
|
||||
|
||||
@@ -1,10 +1,103 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("PreviewKeychainManager Tests")
|
||||
struct PreviewKeychainManagerTests {
|
||||
|
||||
@Test("Install lifecycle distinguishes upgrade, reinstall, bootstrap, and unreadable keychain")
|
||||
func installLifecycleDecision() {
|
||||
#expect(KeychainManager.installLifecycleAction(
|
||||
containerKnowsMarker: true,
|
||||
markerRead: .success(Data([1]))
|
||||
) == .markerPresent)
|
||||
#expect(KeychainManager.installLifecycleAction(
|
||||
containerKnowsMarker: false,
|
||||
markerRead: .success(Data([1]))
|
||||
) == .clearStaleKeys)
|
||||
#expect(KeychainManager.installLifecycleAction(
|
||||
containerKnowsMarker: false,
|
||||
markerRead: .itemNotFound
|
||||
) == .bootstrapMarker)
|
||||
#expect(KeychainManager.installLifecycleAction(
|
||||
containerKnowsMarker: false,
|
||||
markerRead: .deviceLocked
|
||||
) == .retryLater)
|
||||
#expect(KeychainManager.installLifecycleAction(
|
||||
containerKnowsMarker: false,
|
||||
cleanupPending: true,
|
||||
markerRead: .itemNotFound
|
||||
) == .clearStaleKeys)
|
||||
}
|
||||
|
||||
@Test("Accessibility migration covers custom services and retries after any incomplete update")
|
||||
func accessibilityMigrationCoversEveryApplicationOwnedService() {
|
||||
let primaryService = "chat.bitchat.test-primary"
|
||||
var visitedServices: [String] = []
|
||||
|
||||
let completed = KeychainManager
|
||||
.migrateAccessibilityForApplicationOwnedServices(
|
||||
primaryService: primaryService
|
||||
) { service in
|
||||
visitedServices.append(service)
|
||||
return service == "chat.bitchat.favorites"
|
||||
? errSecInteractionNotAllowed
|
||||
: errSecItemNotFound
|
||||
}
|
||||
|
||||
#expect(!completed)
|
||||
#expect(visitedServices.first == primaryService)
|
||||
#expect(Set(visitedServices).isSuperset(of: [
|
||||
"chat.bitchat.nostr",
|
||||
"chat.bitchat.favorites",
|
||||
"chat.bitchat.outbox"
|
||||
]))
|
||||
#expect(Set(visitedServices).count == visitedServices.count)
|
||||
|
||||
let retryCompleted = KeychainManager
|
||||
.migrateAccessibilityForApplicationOwnedServices(
|
||||
primaryService: primaryService
|
||||
) { _ in errSecSuccess }
|
||||
#expect(retryCompleted)
|
||||
}
|
||||
|
||||
@Test("Keychain cleanup is complete only when every owned scope is clean")
|
||||
func keychainCleanupRequiresEveryApplicationOwnedService() {
|
||||
let primaryService = "chat.bitchat.test-primary"
|
||||
var visitedServices: [String] = []
|
||||
|
||||
let partialCleanup = KeychainManager
|
||||
.deleteApplicationOwnedKeychainServices(
|
||||
primaryService: primaryService
|
||||
) { service in
|
||||
visitedServices.append(service)
|
||||
return service == "chat.bitchat.outbox"
|
||||
? errSecInteractionNotAllowed
|
||||
: errSecSuccess
|
||||
}
|
||||
|
||||
#expect(!partialCleanup)
|
||||
#expect(visitedServices.first == primaryService)
|
||||
#expect(Set(visitedServices).isSuperset(of: [
|
||||
"chat.bitchat.nostr",
|
||||
"chat.bitchat.favorites",
|
||||
"chat.bitchat.outbox"
|
||||
]))
|
||||
#expect(Set(visitedServices).count == visitedServices.count)
|
||||
|
||||
let emptyCleanup = KeychainManager
|
||||
.deleteApplicationOwnedKeychainServices(
|
||||
primaryService: primaryService
|
||||
) { _ in errSecItemNotFound }
|
||||
#expect(emptyCleanup)
|
||||
#expect(KeychainManager.completedApplicationGroupDelete(status: -34018))
|
||||
#expect(!KeychainManager.completedApplicationGroupDelete(
|
||||
status: errSecInteractionNotAllowed
|
||||
))
|
||||
}
|
||||
|
||||
@Test("Preview keychain manager stores identity and service-scoped data in memory")
|
||||
func previewKeychainManagerRoundTripsData() {
|
||||
let manager = PreviewKeychainManager()
|
||||
@@ -51,4 +144,132 @@ struct PreviewKeychainManagerTests {
|
||||
Issue.record("Expected preview keychain to be empty after deleteAllKeychainData")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Failed reinstall cleanup blocks stale data until a successful retry")
|
||||
func failedReinstallCleanupBlocksEveryNamespaceUntilSuccessfulRetry() {
|
||||
let gate = KeychainInstallAccessGate()
|
||||
var cleanupCanComplete = false
|
||||
var reconciliationAttempts = 0
|
||||
var manager: PreviewKeychainManager!
|
||||
manager = PreviewKeychainManager(
|
||||
installAccessGate: gate
|
||||
) {
|
||||
reconciliationAttempts += 1
|
||||
guard cleanupCanComplete else { return false }
|
||||
return manager.deleteAllKeychainData()
|
||||
}
|
||||
|
||||
let staleIdentity = Data([1, 2, 3])
|
||||
let staleFavorite = Data([4, 5, 6])
|
||||
let staleOutbox = Data([7, 8, 9])
|
||||
let staleCustom = Data([10, 11, 12])
|
||||
#expect(manager.saveIdentityKey(
|
||||
staleIdentity,
|
||||
forKey: "noiseStaticKey"
|
||||
))
|
||||
#expect(manager.saveIdentityKey(
|
||||
staleIdentity,
|
||||
forKey: "identity_noiseStaticKey"
|
||||
))
|
||||
#expect(manager.verifyIdentityKeyExists())
|
||||
manager.save(
|
||||
key: "favorite",
|
||||
data: staleFavorite,
|
||||
service: "chat.bitchat.favorites",
|
||||
accessible: nil
|
||||
)
|
||||
manager.save(
|
||||
key: "outbox",
|
||||
data: staleOutbox,
|
||||
service: "chat.bitchat.outbox",
|
||||
accessible: nil
|
||||
)
|
||||
manager.save(
|
||||
key: "custom",
|
||||
data: staleCustom,
|
||||
service: "chat.bitchat.future-custom",
|
||||
accessible: nil
|
||||
)
|
||||
|
||||
gate.block()
|
||||
|
||||
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil)
|
||||
#expect(!manager.verifyIdentityKeyExists())
|
||||
if case .accessDenied = manager.getIdentityKeyWithResult(
|
||||
forKey: "noiseStaticKey"
|
||||
) {
|
||||
} else {
|
||||
Issue.record("Expected blocked identity read to fail closed")
|
||||
}
|
||||
|
||||
for (key, service) in [
|
||||
("favorite", "chat.bitchat.favorites"),
|
||||
("outbox", "chat.bitchat.outbox"),
|
||||
("custom", "chat.bitchat.future-custom")
|
||||
] {
|
||||
#expect(manager.load(key: key, service: service) == nil)
|
||||
if case .accessDenied = manager.loadWithResult(
|
||||
key: key,
|
||||
service: service
|
||||
) {
|
||||
} else {
|
||||
Issue.record(
|
||||
"Expected blocked \(service) read to fail closed"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#expect(!manager.saveIdentityKey(
|
||||
Data([13]),
|
||||
forKey: "replacement"
|
||||
))
|
||||
if case .accessDenied = manager.saveIdentityKeyWithResult(
|
||||
Data([14]),
|
||||
forKey: "replacement"
|
||||
) {
|
||||
} else {
|
||||
Issue.record("Expected blocked identity save to fail closed")
|
||||
}
|
||||
|
||||
let failedAttempts = reconciliationAttempts
|
||||
#expect(failedAttempts > 0)
|
||||
cleanupCanComplete = true
|
||||
|
||||
// The first access retries cleanup synchronously. It must not return
|
||||
// any surviving value from before the reinstall.
|
||||
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil)
|
||||
#expect(reconciliationAttempts == failedAttempts + 1)
|
||||
#expect(manager.load(
|
||||
key: "favorite",
|
||||
service: "chat.bitchat.favorites"
|
||||
) == nil)
|
||||
#expect(manager.load(
|
||||
key: "outbox",
|
||||
service: "chat.bitchat.outbox"
|
||||
) == nil)
|
||||
#expect(manager.load(
|
||||
key: "custom",
|
||||
service: "chat.bitchat.future-custom"
|
||||
) == nil)
|
||||
|
||||
let replacementIdentity = Data([21, 22, 23])
|
||||
let replacementCustom = Data([24, 25, 26])
|
||||
#expect(manager.saveIdentityKey(
|
||||
replacementIdentity,
|
||||
forKey: "noiseStaticKey"
|
||||
))
|
||||
#expect(manager.getIdentityKey(
|
||||
forKey: "noiseStaticKey"
|
||||
) == replacementIdentity)
|
||||
manager.save(
|
||||
key: "custom",
|
||||
data: replacementCustom,
|
||||
service: "chat.bitchat.future-custom",
|
||||
accessible: nil
|
||||
)
|
||||
#expect(manager.load(
|
||||
key: "custom",
|
||||
service: "chat.bitchat.future-custom"
|
||||
) == replacementCustom)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +370,132 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(!FileManager.default.fileExists(atPath: evictable.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func panicWipeDeletesEveryManagedMediaFileAndRecreatesEmptyDirectories() throws {
|
||||
let base = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("panic-media-wipe-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: base) }
|
||||
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||
let subdirectories = [
|
||||
"voicenotes/incoming",
|
||||
"voicenotes/outgoing",
|
||||
"images/incoming",
|
||||
"images/outgoing",
|
||||
"files/incoming",
|
||||
"files/outgoing"
|
||||
]
|
||||
|
||||
for subdirectory in subdirectories {
|
||||
let directory = base
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
.appendingPathComponent(subdirectory, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try Data("secret".utf8).write(to: directory.appendingPathComponent("artifact.bin"))
|
||||
}
|
||||
let unmanaged = base.appendingPathComponent("files/legacy/secret.bin")
|
||||
try FileManager.default.createDirectory(at: unmanaged.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try Data("legacy".utf8).write(to: unmanaged)
|
||||
|
||||
try store.panicWipe()
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: unmanaged.path))
|
||||
for subdirectory in subdirectories {
|
||||
let directory = base
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
.appendingPathComponent(subdirectory, isDirectory: true)
|
||||
var isDirectory: ObjCBool = false
|
||||
#expect(FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory))
|
||||
#expect(isDirectory.boolValue)
|
||||
#expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
|
||||
enum MarkerFailure: Error { case unavailable }
|
||||
|
||||
let base = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"panic-marker-failure-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: base) }
|
||||
let secret = base
|
||||
.appendingPathComponent("files/images/outgoing", isDirectory: true)
|
||||
.appendingPathComponent("secret.jpg")
|
||||
try FileManager.default.createDirectory(
|
||||
at: secret.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Data("secret".utf8).write(to: secret)
|
||||
let store = BLEIncomingFileStore(
|
||||
baseDirectory: base,
|
||||
panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable }
|
||||
)
|
||||
|
||||
do {
|
||||
try store.panicWipe(hasDurablePendingMarker: false)
|
||||
Issue.record("Expected the missing durable marker to fail closed")
|
||||
} catch {
|
||||
// The marker error is reported only after the deletion attempt.
|
||||
}
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: secret.path))
|
||||
#expect(
|
||||
FileManager.default.fileExists(
|
||||
atPath: secret.deletingLastPathComponent().path
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func externalMarkerAllowsDeletionToCommitWhenFileMarkerFails() throws {
|
||||
enum MarkerFailure: Error { case unavailable }
|
||||
|
||||
let base = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"panic-external-marker-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: base) }
|
||||
let secret = base
|
||||
.appendingPathComponent("files/voicenotes/incoming", isDirectory: true)
|
||||
.appendingPathComponent("secret.m4a")
|
||||
try FileManager.default.createDirectory(
|
||||
at: secret.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Data("secret".utf8).write(to: secret)
|
||||
let store = BLEIncomingFileStore(
|
||||
baseDirectory: base,
|
||||
panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable }
|
||||
)
|
||||
|
||||
try store.panicWipe(hasDurablePendingMarker: true)
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: secret.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func panicRecoveryMarkerPersistsUntilExplicitCommit() throws {
|
||||
let base = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"panic-recovery-marker-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: base) }
|
||||
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||
|
||||
try store.markPanicRecoveryPending()
|
||||
#expect(try store.isPanicRecoveryPending())
|
||||
try store.panicWipe(hasDurablePendingMarker: true)
|
||||
#expect(try store.isPanicRecoveryPending())
|
||||
|
||||
try store.completePanicRecovery()
|
||||
|
||||
#expect(try !store.isPanicRecoveryPending())
|
||||
}
|
||||
|
||||
private func expectNoSideEffects(_ recorder: Recorder) {
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
|
||||
@@ -76,6 +76,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
||||
burstMaxDelay: 0
|
||||
)
|
||||
|
||||
service.start()
|
||||
service.performHeartbeat()
|
||||
|
||||
let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 }
|
||||
@@ -83,7 +84,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
||||
XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
||||
XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
||||
XCTAssertEqual(sleptNanoseconds.count, 3)
|
||||
XCTAssertEqual(scheduler.intervals, [17])
|
||||
XCTAssertEqual(scheduler.intervals, [17, 17])
|
||||
}
|
||||
|
||||
func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async {
|
||||
@@ -97,11 +98,12 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
||||
loopMaxInterval: 21
|
||||
)
|
||||
|
||||
service.start()
|
||||
service.performHeartbeat()
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertEqual(sendCount, 0)
|
||||
XCTAssertEqual(scheduler.intervals, [21])
|
||||
XCTAssertEqual(scheduler.intervals, [21, 21])
|
||||
}
|
||||
|
||||
func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async {
|
||||
@@ -115,11 +117,45 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
||||
loopMaxInterval: 22
|
||||
)
|
||||
|
||||
service.start()
|
||||
service.performHeartbeat()
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertEqual(sendCount, 0)
|
||||
XCTAssertEqual(scheduler.intervals, [22])
|
||||
XCTAssertEqual(scheduler.intervals, [22, 22])
|
||||
}
|
||||
|
||||
func test_stopForPanic_cancelsTimerAndSuppressesDelayedBroadcast() async throws {
|
||||
let identity = try NostrIdentity.generate()
|
||||
let scheduler = MockGeohashPresenceScheduler()
|
||||
var sleeperContinuation: CheckedContinuation<Void, Never>?
|
||||
var sendCount = 0
|
||||
let service = makeService(
|
||||
scheduler: scheduler,
|
||||
deriveIdentity: { _ in identity },
|
||||
relaySender: { _, _ in sendCount += 1 },
|
||||
sleeper: { _ in
|
||||
await withCheckedContinuation { continuation in
|
||||
sleeperContinuation = continuation
|
||||
}
|
||||
},
|
||||
burstMinDelay: 1,
|
||||
burstMaxDelay: 1
|
||||
)
|
||||
|
||||
service.start()
|
||||
service.performHeartbeat()
|
||||
let delayStarted = await waitUntil {
|
||||
sleeperContinuation != nil
|
||||
}
|
||||
XCTAssertTrue(delayStarted)
|
||||
|
||||
service.stopForPanic()
|
||||
sleeperContinuation?.resume()
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertEqual(sendCount, 0)
|
||||
XCTAssertEqual(scheduler.timers.first?.invalidateCallCount, 1)
|
||||
}
|
||||
|
||||
func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws {
|
||||
|
||||
@@ -91,6 +91,53 @@ final class NetworkActivationServiceTests: XCTestCase {
|
||||
XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1)
|
||||
}
|
||||
|
||||
func test_stopForPanic_synchronouslyStopsAndIgnoresPublisherUpdates() async {
|
||||
let context = makeService(permission: .authorized, favorites: [])
|
||||
|
||||
context.service.start()
|
||||
context.service.stopForPanic()
|
||||
let connectCountAfterStop = context.relayController.connectCallCount
|
||||
let startCountAfterStop = context.torController.startIfNeededCallCount
|
||||
|
||||
context.favoritesSubject.send([Data([0x01])])
|
||||
context.reachability.set(false)
|
||||
context.reachability.set(true)
|
||||
try? await Task.sleep(nanoseconds: 30_000_000)
|
||||
|
||||
XCTAssertFalse(context.service.activationAllowed)
|
||||
XCTAssertEqual(context.reachability.stopCallCount, 1)
|
||||
XCTAssertEqual(context.torController.autoStartAllowedValues.last, false)
|
||||
XCTAssertEqual(context.proxyController.proxyModes.last, false)
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
context.torController.shutdownCompletelyCallCount,
|
||||
1
|
||||
)
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
context.relayController.disconnectCallCount,
|
||||
1
|
||||
)
|
||||
XCTAssertEqual(
|
||||
context.relayController.connectCallCount,
|
||||
connectCountAfterStop
|
||||
)
|
||||
XCTAssertEqual(
|
||||
context.torController.startIfNeededCallCount,
|
||||
startCountAfterStop
|
||||
)
|
||||
}
|
||||
|
||||
func test_start_afterPanicStop_reestablishesSubscriptions() {
|
||||
let context = makeService(permission: .authorized, favorites: [])
|
||||
|
||||
context.service.start()
|
||||
context.service.stopForPanic()
|
||||
context.service.start()
|
||||
|
||||
XCTAssertTrue(context.service.activationAllowed)
|
||||
XCTAssertEqual(context.reachability.startCallCount, 2)
|
||||
XCTAssertEqual(context.relayController.connectCallCount, 2)
|
||||
}
|
||||
|
||||
private func makeService(
|
||||
permission: LocationChannelManager.PermissionState,
|
||||
favorites: Set<Data>
|
||||
@@ -104,6 +151,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
||||
let torController = MockNetworkActivationTorController()
|
||||
let relayController = MockNetworkActivationRelayController()
|
||||
let proxyController = MockNetworkActivationProxyController()
|
||||
let reachability = MockNetworkActivationReachability()
|
||||
let notificationCenter = NotificationCenter()
|
||||
let service = NetworkActivationService(
|
||||
storage: storage,
|
||||
@@ -111,7 +159,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
||||
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
||||
permissionProvider: { permissionSubject.value },
|
||||
mutualFavoritesProvider: { favoritesSubject.value },
|
||||
reachabilityMonitor: AlwaysReachableMonitor(),
|
||||
reachabilityMonitor: reachability,
|
||||
torController: torController,
|
||||
relayController: relayController,
|
||||
proxyController: proxyController,
|
||||
@@ -121,6 +169,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
||||
service: service,
|
||||
storage: storage,
|
||||
favoritesSubject: favoritesSubject,
|
||||
reachability: reachability,
|
||||
torController: torController,
|
||||
relayController: relayController,
|
||||
proxyController: proxyController,
|
||||
@@ -148,12 +197,38 @@ private struct NetworkActivationTestContext {
|
||||
let service: NetworkActivationService
|
||||
let storage: UserDefaults
|
||||
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
|
||||
let reachability: MockNetworkActivationReachability
|
||||
let torController: MockNetworkActivationTorController
|
||||
let relayController: MockNetworkActivationRelayController
|
||||
let proxyController: MockNetworkActivationProxyController
|
||||
let notificationCenter: NotificationCenter
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockNetworkActivationReachability:
|
||||
NetworkReachabilityMonitoring {
|
||||
private let subject = CurrentValueSubject<Bool, Never>(true)
|
||||
private(set) var startCallCount = 0
|
||||
private(set) var stopCallCount = 0
|
||||
|
||||
var isReachable: Bool { subject.value }
|
||||
var reachabilityPublisher: AnyPublisher<Bool, Never> {
|
||||
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func start() {
|
||||
startCallCount += 1
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stopCallCount += 1
|
||||
}
|
||||
|
||||
func set(_ reachable: Bool) {
|
||||
subject.send(reachable)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockNetworkActivationTorController: NetworkActivationTorControlling {
|
||||
private(set) var autoStartAllowedValues: [Bool] = []
|
||||
|
||||
@@ -213,6 +213,7 @@ private final class ControllableReachabilityMonitor: NetworkReachabilityMonitori
|
||||
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
||||
}
|
||||
func start() { startCalled = true }
|
||||
func stop() { startCalled = false }
|
||||
func set(_ reachable: Bool) { subject.send(reachable) }
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession {
|
||||
private let startError: Error?
|
||||
private(set) var finishStarted = false
|
||||
private(set) var cancelCount = 0
|
||||
private(set) var panicCancelCount = 0
|
||||
private var finishContinuation: CheckedContinuation<URL?, Never>?
|
||||
|
||||
init(startError: Error? = nil) {
|
||||
@@ -83,6 +84,10 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession {
|
||||
cancelCount += 1
|
||||
}
|
||||
|
||||
func panicCancelSynchronously() {
|
||||
panicCancelCount += 1
|
||||
}
|
||||
|
||||
func resolveFinish(with url: URL?) {
|
||||
let continuation = finishContinuation
|
||||
finishContinuation = nil
|
||||
@@ -204,4 +209,57 @@ struct VoiceCaptureSessionTests {
|
||||
}
|
||||
#expect(viewModel.state == .idle)
|
||||
}
|
||||
|
||||
@Test func panicSynchronouslyCancelsActiveCaptureAndResetsUI() async {
|
||||
let session = GatedVoiceCaptureSession()
|
||||
let viewModel = VoiceRecordingViewModel()
|
||||
viewModel.sessionProvider = { session }
|
||||
|
||||
viewModel.start(shouldShow: true)
|
||||
await waitUntil { self.isRecording(viewModel.state) }
|
||||
|
||||
viewModel.panicWipe()
|
||||
|
||||
#expect(session.panicCancelCount == 1)
|
||||
#expect(viewModel.state == .idle)
|
||||
#expect(!viewModel.isLiveStreaming)
|
||||
}
|
||||
|
||||
@Test func panicInvalidatesARecordingAlreadyFinalizing() async throws {
|
||||
let session = GatedVoiceCaptureSession()
|
||||
let viewModel = VoiceRecordingViewModel()
|
||||
viewModel.sessionProvider = { session }
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("voice-panic-\(UUID().uuidString).m4a")
|
||||
try Data([0x01]).write(to: url)
|
||||
var delivered = false
|
||||
|
||||
viewModel.start(shouldShow: true)
|
||||
await waitUntil { self.isRecording(viewModel.state) }
|
||||
viewModel.finish { _ in delivered = true }
|
||||
await waitUntil { session.finishStarted }
|
||||
|
||||
viewModel.panicWipe()
|
||||
session.resolveFinish(with: url)
|
||||
await waitUntil {
|
||||
!FileManager.default.fileExists(atPath: url.path)
|
||||
}
|
||||
|
||||
#expect(!delivered)
|
||||
#expect(viewModel.state == .idle)
|
||||
}
|
||||
|
||||
@Test func liveSessionPanicStopsCaptureWithoutSendingControl() {
|
||||
let capture = StubPTTCapture(stopResult: (nil, 0))
|
||||
var sentPackets: [Data] = []
|
||||
let session = PTTLiveVoiceSession(
|
||||
sendPacket: { sentPackets.append($0) },
|
||||
capture: capture
|
||||
)
|
||||
|
||||
session.panicCancelSynchronously()
|
||||
|
||||
#expect(capture.cancelCount == 1)
|
||||
#expect(sentPackets.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +360,35 @@ struct VoiceRecorderTests {
|
||||
#expect(FileManager.default.fileExists(atPath: secondURL.path))
|
||||
}
|
||||
|
||||
@Test func classicSessionPanicStopsRecorderAndDeletesFileBeforeReturning() async throws {
|
||||
let directory = try makeTemporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let rawSession = VoiceRecorderTestSession()
|
||||
let coordinator = AudioSessionCoordinator(session: rawSession)
|
||||
let factory = TestVoiceAudioRecorderFactory(plans: [.success])
|
||||
let voiceRecorder = VoiceRecorder(
|
||||
sessionCoordinator: coordinator,
|
||||
recorderFactory: factory,
|
||||
permissionGranted: { true },
|
||||
paddingInterval: 0,
|
||||
outputDirectory: directory
|
||||
)
|
||||
let capture = VoiceNoteCaptureSession(recorder: voiceRecorder)
|
||||
|
||||
try await capture.start()
|
||||
let url = try #require(factory.urls.first)
|
||||
let recorder = try #require(factory.recorders.first)
|
||||
|
||||
capture.panicCancelSynchronously()
|
||||
|
||||
#expect(recorder.stopCallCount == 1)
|
||||
#expect(!recorder.isRecording)
|
||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||
await coordinator.drain()
|
||||
#expect(rawSession.activationCalls == [true, false])
|
||||
}
|
||||
|
||||
private func verifyFailedStart(
|
||||
firstPlan: TestVoiceAudioRecorderFactory.Plan,
|
||||
expectedPrepareCalls: Int,
|
||||
|
||||
Reference in New Issue
Block a user