Files
bitchat/bitchatTests/AppArchitectureTests.swift
T
2360140760 Transitive verification: vouch for verified peers over Noise (#1380)
* Add capability bits to announce TLV

Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Transitive verification: vouch for verified peers over Noise

When a Noise session establishes with a peer I verified and that peer
advertises the .vouch capability, send signed attestations (up to 16,
most recently verified first, at most once per peer per 24h) for the
OTHER fingerprints I verified. Receivers accept vouches only from
senders they verified themselves, verify the Ed25519 signature against
the sender's announce-bound signing key, and surface the result as a
new derived trust tier: vouched (unfilled seal) between casual and
trusted.

Protocol:
- NoisePayloadType.vouch = 0x12 carries a batch of TLV attestations:
  voucheeFingerprint (32B), voucheeSigningKey (32B), timestamp
  (uint64 ms BE), Ed25519 signature over
  "bitchat-vouch-v1" | fingerprint | signingKey | timestamp.
  The voucher is implicit in the authenticated session.
- PeerCapabilities.localSupported now advertises .vouch.

Storage (SecureIdentityStateManager / IdentityCache):
- vouches keyed by vouchee, capped at 8 vouchers each; validity is
  recomputed on read (voucher still verified-by-me, < 30 days old), so
  unverifying a voucher retires their vouches without cascade deletes.
- New IdentityCache fields are Optional so pre-existing encrypted
  caches decode cleanly; TrustLevel.vouched is inserted mid-ladder but
  raw values are strings, so persisted values are unaffected (and
  vouched itself is never persisted).
- Panic wipe clears vouch state with the rest of the identity cache.

UI: unfilled checkmark.seal badge in the mesh peer list (filled seal
stays exclusive to verified) and a "vouched for by N people you
verified" section with voucher names in FingerprintView; VoiceOver
labels and xcstrings entries included.

Tests: attestation encode/decode + signature (forged/tampered/expired),
accept-policy gates, batch cap, trust-level derivation incl. voucher
invalidation, persistence compat, and coordinator exchange/accept
policies. Full macOS suite: 1088 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix CI deadlock in vouch tests and live-refresh the fingerprint sheet on vouch acceptance

Two fixes for PR #1380 review findings:

1. CI "Run Swift Tests (app)" hang (exit 137): the new
   SecureIdentityStateManagerVouchTests suite was nonisolated, so Swift
   Testing ran its tests in parallel on the Swift Concurrency cooperative
   pool. Each test enqueues a queue.async(.barrier) write (setVerified)
   and immediately blocks in queue.sync / queue.sync(.barrier)
   (recordVouch / effectiveTrustLevel). On CI's few-core runners every
   cooperative-pool thread ended up parked behind a pending barrier that
   never got a dispatch worker, deadlocking the whole test process until
   the watchdog SIGKILLed it. The suite is now @MainActor, matching the
   production isolation of the vouch API (ChatVouchCoordinator is
   @MainActor) and keeping blocking syncs off the cooperative pool.

2. Codex P2: an open fingerprint sheet did not refresh its vouched badge
   when a vouch batch was accepted - VerificationModel.bind() never
   observed the trust-change signal. It now subscribes to the
   "peerStatusUpdated" notification that
   ChatVouchCoordinator.notifyPeerTrustChanged() posts (same source
   PeerListModel uses) and forwards it to objectWillChange. Added a
   regression test that pins VerificationModel's own subscription
   (verified to fail without the fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Skip media-wipe detached tasks under tests (shared-filesystem race)

panicClearAllData and clearCurrentPublicTimeline delete the real
~/Library/Application Support/files tree in detached utility-priority
tasks. The SPM test process shares that tree and ChatViewModelTests
invoke both methods, so under parallel scheduling the wipe lands at a
nondeterministic time — deleting media a concurrently running test just
wrote (and the developer's real app data with it). Guard both with the
existing TestEnvironment.isRunningTests pattern, mirroring the same fix
on feat/mesh-diagnostics (#1377).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Port vouch capability-race fix to feat/vouching (ports b8adcbe9)

Ports the on-device-confirmed fix from the integration test branch
(commit b8adcbe9) onto feat/vouching so PR #1380 is actually correct.
On-device testing confirmed the transitive vouch propagated once the
send was triggered on verify / announce arrival rather than auth alone.

Vouch attestations only ever sent from peerAuthenticated, gated on the
peer's .vouch capability. That capability arrives via the peer's announce,
processed independently of the Noise handshake, so at auth time the set was
usually empty -> gate failed -> vouch silently skipped and never retried.

- Refactor the send path into a reusable attemptVouch(to:fingerprint:now:).
- Trigger on peer-list updates (peersUpdated): fired after every verified
  announce, so the batch goes out once the .vouch bit actually arrives.
- Trigger on local verification (vouchToConnectedVerifiedPeers): verifying a
  peer runs a vouch pass over connected verified peers, covering the
  verify-while-connected case and propagating the new identity onward.
- Relax the capability gate: treat an empty/unknown set as eligible (the
  Noise 0x12 payload is ignored by non-supporting peers); only skip when a
  non-empty set explicitly lacks .vouch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:48:37 +02:00

723 lines
30 KiB
Swift

import BitFoundation
import Combine
import Foundation
import Testing
@testable import bitchat
@MainActor
private func makeArchitectureViewModel(
locationManager: LocationChannelManager? = nil
) -> ChatViewModel {
let keychain = MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
let identityManager = MockIdentityManager(keychain)
let locationManager = locationManager ?? makeArchitectureLocationManager()
return ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: MockTransport(),
locationManager: locationManager
)
}
@MainActor
private func makeArchitectureLocationManager() -> LocationChannelManager {
let suiteName = "AppArchitectureTests.\(UUID().uuidString)"
let storage = UserDefaults(suiteName: suiteName) ?? .standard
storage.removePersistentDomain(forName: suiteName)
return LocationChannelManager(storage: storage)
}
private func makeArchitectureSnapshot(
peerID: PeerID,
nickname: String,
connected: Bool,
noisePublicKey: Data
) -> TransportPeerSnapshot {
TransportPeerSnapshot(
peerID: peerID,
nickname: nickname,
isConnected: connected,
noisePublicKey: noisePublicKey,
lastSeen: Date()
)
}
@MainActor
private func makeArchitectureMessage(
id: String,
timestamp: TimeInterval = 0,
content: String? = nil,
isPrivate: Bool = false,
senderPeerID: PeerID = PeerID(str: "peer-a")
) -> BitchatMessage {
BitchatMessage(
id: id,
sender: "alice",
content: content ?? "message \(id)",
timestamp: Date(timeIntervalSince1970: timestamp),
isRelay: false,
originalSender: nil,
isPrivate: isPrivate,
recipientNickname: isPrivate ? "builder" : nil,
senderPeerID: senderPeerID
)
}
@MainActor
private func waitUntil(
timeoutNanoseconds: UInt64 = 3_000_000_000,
pollNanoseconds: UInt64 = 20_000_000,
_ condition: @escaping @MainActor () -> Bool
) async {
let timeout = Double(timeoutNanoseconds) / 1_000_000_000
let deadline = Date().addingTimeInterval(timeout)
while !condition(), Date() < deadline {
try? await Task.sleep(nanoseconds: pollNanoseconds)
}
}
@Suite("App Architecture Tests", .serialized)
struct AppArchitectureTests {
@Test("PeerIdentityStore owns fingerprint, mapping, and verification state")
@MainActor
func peerIdentityStoreOwnsIdentityState() {
let store = PeerIdentityStore()
let shortPeerID = PeerID(str: "peer-short")
let stablePeerID = PeerID(str: "peer-stable")
let canonicalPeerID = PeerID(str: "peer-canonical")
store.setStablePeerID(stablePeerID, forShortID: shortPeerID)
store.setFingerprint("fp-1", for: shortPeerID)
store.setCachedEncryptionStatus(.noiseHandshaking, for: shortPeerID)
store.setEncryptionStatus(.noiseSecured, for: shortPeerID)
store.setVerified("fp-1", verified: true)
let migratedFingerprint = store.migrateFingerprintMapping(
from: shortPeerID,
to: canonicalPeerID
)
#expect(store.stablePeerID(forShortID: shortPeerID) == stablePeerID)
#expect(store.shortPeerID(forStablePeerID: stablePeerID) == shortPeerID)
#expect(migratedFingerprint == "fp-1")
#expect(store.fingerprint(for: shortPeerID) == nil)
#expect(store.fingerprint(for: canonicalPeerID) == "fp-1")
#expect(store.selectedPrivateChatFingerprint == "fp-1")
#expect(store.encryptionStatus(for: shortPeerID) == .noiseSecured)
#expect(store.cachedEncryptionStatus(for: shortPeerID) == nil)
#expect(store.isVerified("fp-1"))
store.clearAll()
#expect(store.encryptionStatuses.isEmpty)
#expect(store.verifiedFingerprints.isEmpty)
#expect(store.peerFingerprintsByPeerID.isEmpty)
#expect(store.selectedPrivateChatFingerprint == nil)
#expect(store.stablePeerID(forShortID: shortPeerID) == nil)
}
@Test("LocationPresenceStore normalizes and resets geohash presence state")
@MainActor
func locationPresenceStoreNormalizesPresenceState() {
let store = LocationPresenceStore()
store.setCurrentGeohash("U4PRUY")
store.replaceGeoNicknames([
"ABCDEF": "alice",
"123456": "bob"
])
store.markTeleported("ABCDEF")
store.replaceTeleportedGeo(Set(["FEDCBA", "123456"]))
#expect(store.currentGeohash == "u4pruy")
#expect(store.geoNicknames["abcdef"] == "alice")
#expect(store.geoNicknames["123456"] == "bob")
#expect(store.teleportedGeo == Set(["fedcba", "123456"]))
store.reset()
#expect(store.currentGeohash == nil)
#expect(store.geoNicknames.isEmpty)
#expect(store.teleportedGeo.isEmpty)
}
@Test("PeerHandle equality and hashing use the canonical identity only")
func peerHandleEqualityUsesCanonicalIdentity() {
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
let second = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-b"))
#expect(first == second)
#expect(Set([first, second]).count == 1)
}
@Test("ConversationStore orders timelines and replaces duplicates by message ID")
@MainActor
func conversationStoreOrdersAndDedupsMessages() {
let store = ConversationStore()
let older = makeArchitectureMessage(id: "m1", timestamp: 1, content: "first")
let newer = makeArchitectureMessage(id: "m2", timestamp: 2, content: "second")
let replacement = makeArchitectureMessage(id: "m2", timestamp: 2, content: "second-updated")
store.append(newer, to: .mesh)
store.append(older, to: .mesh)
store.upsertByID(replacement, in: .mesh)
let messages = store.conversation(for: .mesh).messages
#expect(messages.map(\.id) == ["m1", "m2"])
#expect(messages.last?.content == "second-updated")
}
@Test("ConversationStore tracks unread direct conversations by routing peer ID")
@MainActor
func conversationStoreTracksUnreadDirectConversations() {
let store = ConversationStore()
let peerID = PeerID(str: "peer-1")
let message = makeArchitectureMessage(id: "dm-1", isPrivate: true, senderPeerID: peerID)
store.append(message, to: .directPeer(peerID))
store.markUnread(.directPeer(peerID))
#expect(store.conversation(for: .directPeer(peerID)).messages.map(\.id) == ["dm-1"])
#expect(store.unreadDirectRoutingPeerIDs() == Set([peerID]))
#expect(store.conversation(for: .directPeer(peerID)).isUnread)
store.markRead(.directPeer(peerID))
#expect(store.unreadDirectRoutingPeerIDs().isEmpty)
#expect(!store.conversation(for: .directPeer(peerID)).isUnread)
}
@Test("ConversationStore derives the selected conversation from channel and private peer")
@MainActor
func conversationStoreTracksSelectedConversationContext() {
let store = ConversationStore()
let peerID = PeerID(str: "0011223344556677")
let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy"))
store.setActiveChannel(geohashChannel)
store.setSelectedPrivatePeer(peerID)
#expect(store.activeChannel == geohashChannel)
#expect(store.selectedPrivatePeerID == peerID)
// The open private chat wins the derived selection.
#expect(store.selectedConversationID == ConversationID.directPeer(peerID))
store.setSelectedPrivatePeer(nil)
// Selection falls back to the active public channel.
#expect(store.selectedConversationID == ConversationID(channelID: geohashChannel))
store.setActiveChannel(.mesh)
#expect(store.activeChannel == ChannelID.mesh)
#expect(store.selectedPrivatePeerID == nil)
#expect(store.selectedConversationID == ConversationID.mesh)
}
@Test("ConversationStore re-keys a direct conversation via the migrate intent")
@MainActor
func conversationStoreMigratesDirectConversationsBetweenPeerIDs() {
let store = ConversationStore()
let noiseKey = Data((0..<32).map(UInt8.init))
let shortPeerID = PeerID(str: "0011223344556677")
let fullPeerID = PeerID(hexData: noiseKey)
store.append(
makeArchitectureMessage(id: "dm-1", timestamp: 1, isPrivate: true, senderPeerID: shortPeerID),
to: .directPeer(shortPeerID)
)
store.markUnread(.directPeer(shortPeerID))
store.setSelectedPrivatePeer(shortPeerID)
store.migrateConversation(from: .directPeer(shortPeerID), to: .directPeer(fullPeerID))
// Raw keying: the old peer's conversation is gone, the new peer's
// conversation holds the timeline, unread and selection carried over.
#expect(store.conversationsByID[.directPeer(shortPeerID)] == nil)
#expect(Set(store.directMessagesByRoutingPeerID().keys) == Set([fullPeerID]))
#expect(store.directMessagesByRoutingPeerID()[fullPeerID]?.map(\.id) == ["dm-1"])
#expect(store.unreadDirectRoutingPeerIDs() == Set([fullPeerID]))
#expect(store.selectedPrivatePeerID == fullPeerID)
#expect(store.selectedConversationID == ConversationID.directPeer(fullPeerID))
}
@Test("PrivateInboxModel reads direct message state from the ConversationStore")
@MainActor
func privateInboxModelReadsDirectMessageStateFromConversationStore() {
let store = ConversationStore()
let inboxModel = PrivateInboxModel(conversations: store)
let messagePeerID = PeerID(str: "peer-1")
let unreadOnlyPeerID = PeerID(str: "peer-2")
let selectedOnlyPeerID = PeerID(str: "peer-3")
store.append(
makeArchitectureMessage(id: "dm-1", isPrivate: true, senderPeerID: messagePeerID),
to: .directPeer(messagePeerID)
)
store.markUnread(.directPeer(messagePeerID))
store.markUnread(.directPeer(unreadOnlyPeerID))
store.setSelectedPrivatePeer(selectedOnlyPeerID)
// Reads are synchronous against the single-writer store.
#expect(inboxModel.selectedPeerID == selectedOnlyPeerID)
#expect(inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID]))
#expect(inboxModel.messages(for: messagePeerID).map(\.id) == ["dm-1"])
#expect(inboxModel.messages(for: unreadOnlyPeerID).isEmpty)
#expect(inboxModel.messages(for: selectedOnlyPeerID).isEmpty)
}
@Test("PrivateInboxModel republishes only for the selected conversation")
@MainActor
func privateInboxModelIsolatesBackgroundConversations() {
let store = ConversationStore()
let inboxModel = PrivateInboxModel(conversations: store)
let selectedPeerID = PeerID(str: "peer-selected")
let backgroundPeerID = PeerID(str: "peer-background")
store.setSelectedPrivatePeer(selectedPeerID)
var emissions = 0
let cancellable = inboxModel.objectWillChange.sink { _ in emissions += 1 }
defer { cancellable.cancel() }
let baseline = emissions
store.append(
makeArchitectureMessage(id: "dm-bg-1", isPrivate: true, senderPeerID: backgroundPeerID),
to: .directPeer(backgroundPeerID)
)
// An append to a background chat does not republish the model.
#expect(emissions == baseline)
store.append(
makeArchitectureMessage(id: "dm-sel-1", isPrivate: true, senderPeerID: selectedPeerID),
to: .directPeer(selectedPeerID)
)
#expect(emissions == baseline + 1)
#expect(inboxModel.messages(for: selectedPeerID).map(\.id) == ["dm-sel-1"])
}
@Test("PrivateInboxModel republishes read receipts for the selected DM (ephemeral- and stable-keyed)")
@MainActor
func privateInboxModelRepublishesReadReceiptsForSelectedConversation() {
// A DM's messages can live under BOTH .directPeer(ephemeral) and
// .directPeer(stableKey) (mirroring shares one BitchatMessage
// instance); the view's read-receipt update must fire no matter
// which of the two keys the selection holds.
let ephemeralPeerID = PeerID(str: "abcdef1234567890")
let stablePeerID = PeerID(str: String(repeating: "ab", count: 32))
for selectedPeerID in [ephemeralPeerID, stablePeerID] {
let store = ConversationStore()
let inboxModel = PrivateInboxModel(conversations: store)
store.setSelectedPrivatePeer(selectedPeerID)
// One shared instance mirrored into both direct conversations,
// exactly like `mirrorToEphemeralIfNeeded`.
let message = makeArchitectureMessage(
id: "dm-read-1",
isPrivate: true,
senderPeerID: ephemeralPeerID
)
store.append(message, to: .directPeer(ephemeralPeerID))
store.upsertByID(message, in: .directPeer(stablePeerID))
var emissions = 0
let cancellable = inboxModel.objectWillChange.sink { _ in emissions += 1 }
defer { cancellable.cancel() }
// ID-only intent — the exact call `ChatDeliveryCoordinator`
// makes when a READ ack arrives.
let read = DeliveryStatus.read(by: "builder", at: Date(timeIntervalSince1970: 100))
#expect(store.setDeliveryStatus(read, forMessageID: "dm-read-1"))
// The fan-out emits .statusChanged for both containing
// conversations; exactly the selected one republishes the model.
#expect(emissions == 1)
#expect(inboxModel.messages(for: selectedPeerID).first?.deliveryStatus == read)
}
}
@Test("PublicChatModel ignores appends to background conversations")
@MainActor
func publicChatModelIsolatesBackgroundConversations() {
let store = ConversationStore()
store.setActiveChannel(.mesh)
let model = PublicChatModel(conversations: store)
var emissions = 0
let cancellable = model.objectWillChange.sink { _ in emissions += 1 }
defer { cancellable.cancel() }
store.append(makeArchitectureMessage(id: "mesh-1"), to: .mesh)
let afterActiveAppend = emissions
#expect(afterActiveAppend >= 1)
#expect(model.messages.map(\.id) == ["mesh-1"])
// Appends to a background geohash channel and to a private chat do
// not invalidate the observer of the active conversation.
store.append(makeArchitectureMessage(id: "geo-1"), to: .geohash("u4pruyd"))
store.append(
makeArchitectureMessage(id: "dm-1", isPrivate: true),
to: .directPeer(PeerID(str: "peer-1"))
)
#expect(emissions == afterActiveAppend)
#expect(model.messages.map(\.id) == ["mesh-1"])
// Switching the channel retargets the observation.
store.setActiveChannel(.location(GeohashChannel(level: .neighborhood, geohash: "u4pruyd")))
#expect(model.messages.map(\.id) == ["geo-1"])
store.append(makeArchitectureMessage(id: "geo-2", timestamp: 1), to: .geohash("u4pruyd"))
#expect(model.messages.map(\.id) == ["geo-1", "geo-2"])
}
@Test("AppChromeModel mirrors nickname and unread state through focused models")
@MainActor
func appChromeModelMirrorsNicknameAndUnreadState() async {
let viewModel = makeArchitectureViewModel()
let conversations = ConversationStore()
let privateInboxModel = PrivateInboxModel(conversations: conversations)
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
chromeModel.setNickname("builder")
await waitUntil {
viewModel.nickname == "builder" && chromeModel.nickname == "builder"
}
#expect(viewModel.nickname == "builder")
#expect(chromeModel.nickname == "builder")
#expect(!chromeModel.hasUnreadPrivateMessages)
let peerID = PeerID(str: "peer-1")
conversations.markUnread(.directPeer(peerID))
await waitUntil {
chromeModel.hasUnreadPrivateMessages
}
#expect(chromeModel.hasUnreadPrivateMessages)
}
@Test("AppChromeModel owns fingerprint and screenshot presentation state")
@MainActor
func appChromeModelOwnsPresentationState() {
let viewModel = makeArchitectureViewModel()
let privateInboxModel = PrivateInboxModel(conversations: ConversationStore())
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
let peerID = PeerID(str: "peer-2")
chromeModel.showFingerprint(for: peerID)
chromeModel.presentAppInfo()
chromeModel.isLocationChannelsSheetPresented = true
chromeModel.triggerScreenshotPrivacyWarning()
#expect(chromeModel.showingFingerprintFor == peerID)
#expect(chromeModel.isAppInfoPresented)
#expect(chromeModel.shouldSuppressScreenshotNotification)
#expect(chromeModel.showScreenshotPrivacyWarning)
chromeModel.clearFingerprint()
#expect(chromeModel.showingFingerprintFor == nil)
}
@Test("PrivateConversationModel resolves canonical header state for the selected DM")
@MainActor
func privateConversationModelResolvesSelectedHeaderState() async {
let viewModel = makeArchitectureViewModel()
guard let transport = viewModel.meshService as? MockTransport else {
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
return
}
let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager())
let conversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversations: viewModel.conversations,
locationChannelsModel: locationChannelsModel
)
let noiseKey = Data((0..<32).map(UInt8.init))
let shortPeerID = PeerID(str: "0011223344556677")
let fullPeerID = PeerID(hexData: noiseKey)
transport.peerNicknames[shortPeerID] = "alice"
transport.reachablePeers.insert(shortPeerID)
viewModel.allPeers = [
BitchatPeer(
peerID: shortPeerID,
noisePublicKey: noiseKey,
nickname: "alice",
isConnected: false,
isReachable: true
)
]
conversationModel.startConversation(with: fullPeerID)
await waitUntil {
conversationModel.selectedPeerID == fullPeerID
}
#expect(conversationModel.selectedPeerID == fullPeerID)
#expect(conversationModel.selectedHeaderState?.headerPeerID == shortPeerID)
#expect(conversationModel.selectedHeaderState?.displayName == "alice")
#expect(conversationModel.selectedHeaderState?.availability == .meshReachable)
#expect(conversationModel.selectedHeaderState?.encryptionStatus == .noHandshake)
conversationModel.endConversation()
await waitUntil {
conversationModel.selectedPeerID == nil
}
#expect(conversationModel.selectedPeerID == nil)
#expect(conversationModel.selectedHeaderState == nil)
}
@Test("ConversationUIModel mirrors composer state and forwards sends")
@MainActor
func conversationUIModelMirrorsComposerStateAndForwardsSends() async {
let locationManager = makeArchitectureLocationManager()
let viewModel = makeArchitectureViewModel(locationManager: locationManager)
guard let transport = viewModel.meshService as? MockTransport else {
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
return
}
locationManager.select(.mesh)
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversations: viewModel.conversations,
locationChannelsModel: locationChannelsModel
)
let uiModel = ConversationUIModel(
chatViewModel: viewModel,
privateConversationModel: privateConversationModel,
conversations: viewModel.conversations
)
let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy"))
defer {
locationManager.select(.mesh)
}
viewModel.nickname = "builder"
viewModel.autocompleteSuggestions = ["alice"]
viewModel.showAutocomplete = true
locationChannelsModel.select(geohashChannel)
await waitUntil {
viewModel.activeChannel == geohashChannel &&
uiModel.currentNickname == "builder" &&
uiModel.showAutocomplete &&
uiModel.autocompleteSuggestions == ["alice"] &&
!uiModel.canSendMediaInCurrentContext
}
#expect(viewModel.activeChannel == geohashChannel)
#expect(uiModel.currentNickname == "builder")
#expect(uiModel.showAutocomplete)
#expect(uiModel.autocompleteSuggestions == ["alice"])
#expect(!uiModel.canSendMediaInCurrentContext)
locationChannelsModel.select(ChannelID.mesh)
await waitUntil {
viewModel.activeChannel == ChannelID.mesh &&
uiModel.canSendMediaInCurrentContext
}
#expect(viewModel.activeChannel == ChannelID.mesh)
#expect(uiModel.canSendMediaInCurrentContext)
uiModel.sendMessage("hello mesh")
await waitUntil {
transport.sentMessages.last?.content == "hello mesh"
}
#expect(transport.sentMessages.last?.content == "hello mesh")
}
@Test("VerificationModel bridges selected conversation and fingerprint actions")
@MainActor
func verificationModelBridgesSelectedConversationAndFingerprintActions() async {
let viewModel = makeArchitectureViewModel()
guard let transport = viewModel.meshService as? MockTransport else {
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
return
}
let peerID = PeerID(str: "0011223344556677")
let fingerprint = "verified-fingerprint"
let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager())
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversations: viewModel.conversations,
locationChannelsModel: locationChannelsModel
)
let verificationModel = VerificationModel(
chatViewModel: viewModel,
privateConversationModel: privateConversationModel
)
transport.peerFingerprints[peerID] = fingerprint
transport.peerNicknames[peerID] = "alice"
viewModel.allPeers = [
BitchatPeer(
peerID: peerID,
noisePublicKey: Data((0..<32).map(UInt8.init)),
nickname: "alice",
isConnected: true,
isReachable: true
)
]
privateConversationModel.startConversation(with: peerID)
await waitUntil {
verificationModel.selectedPeerID == peerID
}
let presentation = verificationModel.fingerprintPresentation(for: peerID)
#expect(verificationModel.selectedPeerID == peerID)
#expect(presentation.peerNickname == "alice")
#expect(presentation.theirFingerprint == fingerprint)
#expect(!presentation.myFingerprint.isEmpty)
#expect(!verificationModel.isVerified(peerID: peerID))
verificationModel.verifyFingerprint(for: peerID)
await waitUntil {
verificationModel.isVerified(peerID: peerID)
}
#expect(verificationModel.isVerified(peerID: peerID))
verificationModel.unverifyFingerprint(for: peerID)
await waitUntil {
!verificationModel.isVerified(peerID: peerID)
}
#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 {
let viewModel = makeArchitectureViewModel()
guard let transport = viewModel.meshService as? MockTransport else {
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
return
}
let myPeerID = PeerID(str: "me-peer")
let otherPeerID = PeerID(str: "0011223344556677")
let geohash = "9q8yy"
let remoteGeoID = String(repeating: "b", count: 64)
let locationManager = makeArchitectureLocationManager()
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
let otherNoiseKey = Data((0..<32).map(UInt8.init))
let verifiedFingerprint = otherNoiseKey.sha256Fingerprint()
transport.myPeerID = myPeerID
transport.peerFingerprints[otherPeerID] = verifiedFingerprint
transport.peerNicknames[otherPeerID] = "alice"
transport.reachablePeers.insert(otherPeerID)
viewModel.nickname = "builder"
viewModel.verifiedFingerprints.insert(verifiedFingerprint)
viewModel.markPrivateChatUnread(otherPeerID)
transport.updatePeerSnapshots([
makeArchitectureSnapshot(
peerID: myPeerID,
nickname: "builder",
connected: true,
noisePublicKey: Data(repeating: 0, count: 32)
),
makeArchitectureSnapshot(
peerID: otherPeerID,
nickname: "alice",
connected: false,
noisePublicKey: otherNoiseKey
)
])
locationManager.select(.location(GeohashChannel(level: .city, geohash: geohash)))
await waitUntil {
if case .location(let channel) = locationManager.selectedChannel {
return channel.geohash == geohash && !viewModel.allPeers.isEmpty
}
return false
}
viewModel.participantTracker.setActiveGeohash(geohash)
viewModel.teleportedGeo = Set([remoteGeoID])
viewModel.participantTracker.recordParticipant(pubkeyHex: remoteGeoID, geohash: geohash)
if let myGeoID = try? viewModel.idBridge.deriveIdentity(forGeohash: geohash).publicKeyHex.lowercased() {
viewModel.participantTracker.recordParticipant(pubkeyHex: myGeoID, geohash: geohash)
}
let peerListModel = PeerListModel(
chatViewModel: viewModel,
conversations: viewModel.conversations,
locationChannelsModel: locationChannelsModel
)
await waitUntil {
peerListModel.reachableMeshPeerCount == 1 &&
peerListModel.connectedMeshPeerCount == 0 &&
peerListModel.meshRows.contains(where: { $0.peerID == otherPeerID && $0.hasUnread }) &&
peerListModel.geohashPeople.contains(where: { $0.id == remoteGeoID && $0.isTeleported })
}
let meshRow = peerListModel.meshRows.first(where: { $0.peerID == otherPeerID })
#expect(peerListModel.reachableMeshPeerCount == 1)
#expect(peerListModel.connectedMeshPeerCount == 0)
#expect(meshRow?.displayName == "alice")
#expect(meshRow?.showsVerifiedBadgeWhenOffline == true)
#expect(meshRow?.hasUnread == true)
#expect(peerListModel.visibleGeohashPeerCount >= 1)
#expect(peerListModel.participantCount(for: geohash) >= 1)
#expect(peerListModel.geohashPeople.contains(where: { $0.id == remoteGeoID && $0.isTeleported }))
viewModel.participantTracker.clear()
viewModel.teleportedGeo = []
locationManager.markTeleported(for: geohash, false)
locationManager.select(ChannelID.mesh)
await waitUntil {
if case ChannelID.mesh = locationManager.selectedChannel {
return true
}
return false
}
}
}