diff --git a/bitchat/App/VerificationModel.swift b/bitchat/App/VerificationModel.swift index a1c7cd1c..5916a958 100644 --- a/bitchat/App/VerificationModel.swift +++ b/bitchat/App/VerificationModel.swift @@ -150,6 +150,17 @@ final class VerificationModel: ObservableObject { self?.objectWillChange.send() } .store(in: &cancellables) + + // Vouch state changes (ChatVouchCoordinator.notifyPeerTrustChanged) + // are signalled via this notification rather than a published + // property, so an open fingerprint sheet refreshes its vouched badge + // live when a vouch batch is accepted. + NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated")) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) } private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String { diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 0b70f0e4..69b7fc89 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -591,6 +591,45 @@ struct AppArchitectureTests { #expect(!verificationModel.isVerified(peerID: peerID)) } + @Test("VerificationModel refreshes when peer trust changes (vouch accepted)") + @MainActor + func verificationModelRefreshesOnPeerTrustChange() async { + let viewModel = makeArchitectureViewModel() + var privateConversationModel: PrivateConversationModel? = PrivateConversationModel( + chatViewModel: viewModel, + conversations: viewModel.conversations, + locationChannelsModel: LocationChannelsModel(manager: makeArchitectureLocationManager()) + ) + let verificationModel = VerificationModel( + chatViewModel: viewModel, + privateConversationModel: privateConversationModel! + ) + + // PrivateConversationModel happens to observe the same notification + // and re-assign its published selection, which would ripple into + // VerificationModel; release it so this test pins VerificationModel's + // own subscription rather than that incidental chain. + privateConversationModel = nil + + // The bound @Published sources replay their current values on + // subscription; let those initial main-queue emissions settle so the + // sink below observes only the trust-change signal. + try? await Task.sleep(nanoseconds: 100_000_000) + + // ChatVouchCoordinator.notifyPeerTrustChanged() signals accepted + // vouches via "peerStatusUpdated"; an open fingerprint sheet must + // re-render its vouched badge from that signal alone. + var refreshed = false + let cancellable = verificationModel.objectWillChange.sink { _ in + refreshed = true + } + defer { cancellable.cancel() } + + NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil) + await waitUntil { refreshed } + #expect(refreshed) + } + @Test("PeerListModel publishes mesh and geohash directory state") @MainActor func peerListModelPublishesDirectoryState() async { diff --git a/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift b/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift index 9984e09f..6ab46490 100644 --- a/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift +++ b/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift @@ -9,6 +9,15 @@ import Testing /// Ordering note: mutations use barrier blocks on the manager's concurrent /// queue and reads use `queue.sync`, so a read submitted after a mutation /// always observes it — no polling needed. +/// +/// `@MainActor` matches production (the manager's vouch API is driven by the +/// main-actor `ChatVouchCoordinator`) and keeps the blocking `queue.sync` +/// reads off the Swift Concurrency cooperative pool. Left nonisolated, Swift +/// Testing runs these tests in parallel on that pool, and on CI's few-core +/// runners every pool thread ended up parked in `queue.sync` behind a pending +/// `queue.async(.barrier)` write that never got a dispatch worker — a +/// process-wide deadlock (watchdog SIGKILL, exit 137). +@MainActor struct SecureIdentityStateManagerVouchTests { private let voucher = String(repeating: "0a", count: 32) private let vouchee = String(repeating: "0b", count: 32)