Harden panic recovery and service shutdown

This commit is contained in:
jack
2026-07-26 00:12:26 +02:00
parent 76d3b0f1ed
commit b081c98dba
22 changed files with 1236 additions and 126 deletions
+151 -5
View File
@@ -16,9 +16,12 @@ import BitFoundation
/// Creates a ChatViewModel with mock dependencies for testing
@MainActor
private func makeTestableViewModel(
panicMediaWipe: (() throws -> Void)? = nil
keychain injectedKeychain: MockKeychain? = nil,
panicMediaWipe: (() throws -> Void)? = nil,
panicRecoveryOperations: PanicRecoveryOperations? = nil,
panicNetworkLifecycle: PanicNetworkLifecycle = .noop
) -> (viewModel: ChatViewModel, transport: MockTransport) {
let keychain = MockKeychain()
let keychain = injectedKeychain ?? MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
let identityManager = MockIdentityManager(keychain)
@@ -29,7 +32,9 @@ private func makeTestableViewModel(
idBridge: idBridge,
identityManager: identityManager,
transport: transport,
panicMediaWipe: panicMediaWipe
panicMediaWipe: panicMediaWipe,
panicRecoveryOperations: panicRecoveryOperations,
panicNetworkLifecycle: panicNetworkLifecycle
)
return (viewModel, transport)
@@ -1122,15 +1127,156 @@ struct ChatViewModelPanicTests {
@Test @MainActor
func panicClearAllData_finishesMediaWipeBeforeReturning() {
var wipeFinished = false
let (viewModel, _) = makeTestableViewModel {
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()