Files
bitchat/bitchatTests/ChatTransportEventCoordinatorContextTests.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

368 lines
15 KiB
Swift

//
// ChatTransportEventCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatTransportEventCoordinator` against a mock
// `ChatTransportEventContext` — proving the coordinator works without a
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: the coordinator hops every event onto the main actor via an
// internal `Task`; tests drain those tasks with `Task.yield()`. All flows are
// mockable — no singletons are involved at this layer.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatTransportEventContext` proving that
/// `ChatTransportEventCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatTransportEventContext: ChatTransportEventContext {
// Connection & chat state
var isConnected = false
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var privateChats: [PeerID: [BitchatMessage]] = [:]
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
privateChats[peerID] ?? []
}
var unreadPrivateMessages: Set<PeerID> = []
var selectedPrivateChatPeer: PeerID?
private(set) var unmarkedReadReceiptBatches: [[String]] = []
private(set) var notifyUIChangedCount = 0
// Conversation store intents (mirror `ConversationStore` semantics)
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool {
var chat = privateChats[peerID] ?? []
guard !chat.contains(where: { $0.id == message.id }) else { return false }
let index = chat.firstIndex(where: { $0.timestamp > message.timestamp }) ?? chat.count
chat.insert(message, at: index)
privateChats[peerID] = chat
return true
}
func removePrivateChat(_ peerID: PeerID) {
privateChats.removeValue(forKey: peerID)
unreadPrivateMessages.remove(peerID)
}
func markPrivateChatUnread(_ peerID: PeerID) {
unreadPrivateMessages.insert(peerID)
}
func markPrivateChatRead(_ peerID: PeerID) {
unreadPrivateMessages.remove(peerID)
}
func unmarkReadReceiptsSent(_ ids: [String]) {
unmarkedReadReceiptBatches.append(ids)
}
func notifyUIChanged() {
notifyUIChangedCount += 1
}
// Inbound message handling
var blockedMessageIDs: Set<String> = []
private(set) var handledPrivateMessages: [BitchatMessage] = []
private(set) var handledPublicMessages: [BitchatMessage] = []
private(set) var mentionCheckedMessageIDs: [String] = []
private(set) var hapticMessageIDs: [String] = []
func isMessageBlocked(_ message: BitchatMessage) -> Bool {
blockedMessageIDs.contains(message.id)
}
func handlePrivateMessage(_ message: BitchatMessage) {
handledPrivateMessages.append(message)
}
func handlePublicMessage(_ message: BitchatMessage) {
handledPublicMessages.append(message)
}
func checkForMentions(_ message: BitchatMessage) {
mentionCheckedMessageIDs.append(message.id)
}
func sendHapticFeedback(for message: BitchatMessage) {
hapticMessageIDs.append(message.id)
}
func parseMentions(from content: String) -> [String] {
content.contains("@me") ? ["me"] : []
}
// Peer identity & sessions
var blockedPeers: Set<PeerID> = []
var peersByID: [PeerID: BitchatPeer] = [:]
var noiseSessionKeysByPeerID: [PeerID: Data] = [:]
private(set) var stablePeerIDCache: [PeerID: PeerID] = [:]
private(set) var registeredEphemeralSessions: [PeerID] = []
private(set) var removedEphemeralSessions: [PeerID] = []
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] }
func resolveNickname(for peerID: PeerID) -> String { "anon\(peerID.id.prefix(4))" }
func registerEphemeralSession(peerID: PeerID) { registeredEphemeralSessions.append(peerID) }
func removeEphemeralSession(peerID: PeerID) { removedEphemeralSessions.append(peerID) }
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { noiseSessionKeysByPeerID[peerID] }
func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) {
stablePeerIDCache[shortPeerID] = stablePeerID
}
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? { stablePeerIDCache[shortPeerID] }
// Routing & acknowledgements
private(set) var flushedOutboxPeerIDs: [PeerID] = []
private(set) var courierRetryPeerIDs: [PeerID] = []
private(set) var meshDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
func flushRouterOutbox(for peerID: PeerID) { flushedOutboxPeerIDs.append(peerID) }
func retryCourierDeposits(via peerID: PeerID) { courierRetryPeerIDs.append(peerID) }
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
meshDeliveryAcks.append((messageID, peerID))
}
// Delivery status
var applyMessageDeliveryStatusResult = true
var deliveryStatusesByMessageID: [String: DeliveryStatus] = [:]
private(set) var appliedDeliveryStatuses: [(messageID: String, status: DeliveryStatus)] = []
@discardableResult
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
appliedDeliveryStatuses.append((messageID, status))
return applyMessageDeliveryStatusResult
}
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
deliveryStatusesByMessageID[messageID]
}
// Verification payloads
private(set) var verifyChallengePayloads: [(peerID: PeerID, payload: Data)] = []
private(set) var verifyResponsePayloads: [(peerID: PeerID, payload: Data)] = []
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) {
verifyChallengePayloads.append((peerID, payload))
}
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) {
verifyResponsePayloads.append((peerID, payload))
}
private(set) var vouchPayloads: [(peerID: PeerID, payload: Data)] = []
func handleVouchPayload(from peerID: PeerID, payload: Data) {
vouchPayloads.append((peerID, payload))
}
}
// MARK: - Helpers
/// Lets the coordinator's internal `Task { @MainActor … }` hops run.
@MainActor
private func drainMainActorTasks() async {
for _ in 0..<10 { await Task.yield() }
}
private func makeMessage(
id: String,
sender: String = "alice",
content: String = "hello",
isPrivate: Bool = false,
senderPeerID: PeerID? = nil
) -> BitchatMessage {
BitchatMessage(
id: id,
sender: sender,
content: content,
timestamp: Date(),
isRelay: false,
isPrivate: isPrivate,
recipientNickname: isPrivate ? "me" : nil,
senderPeerID: senderPeerID
)
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatTransportEventCoordinator` against
/// `MockChatTransportEventContext` with no `ChatViewModel`.
struct ChatTransportEventCoordinatorContextTests {
@Test @MainActor
func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
// Blocked messages are dropped before any handling.
context.blockedMessageIDs = ["blocked"]
coordinator.didReceiveMessage(makeMessage(id: "blocked"))
// Empty public content is dropped too.
coordinator.didReceiveMessage(makeMessage(id: "empty", content: " "))
await drainMainActorTasks()
#expect(context.handledPublicMessages.isEmpty)
#expect(context.handledPrivateMessages.isEmpty)
#expect(context.mentionCheckedMessageIDs.isEmpty)
// Private goes to the private handler, public to the public handler;
// both get mention checks and haptics.
coordinator.didReceiveMessage(makeMessage(id: "pm", isPrivate: true))
coordinator.didReceiveMessage(makeMessage(id: "pub"))
await drainMainActorTasks()
#expect(context.handledPrivateMessages.map(\.id) == ["pm"])
#expect(context.handledPublicMessages.map(\.id) == ["pub"])
#expect(context.mentionCheckedMessageIDs == ["pm", "pub"])
#expect(context.hapticMessageIDs == ["pm", "pub"])
}
@Test @MainActor
func didReceivePublicMessage_trimsContentAndParsesMentions() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "aabbccdd00112233")
coordinator.didReceivePublicMessage(
from: peerID,
nickname: "alice",
content: " hi @me ",
timestamp: Date(),
messageID: "m1"
)
await drainMainActorTasks()
#expect(context.handledPublicMessages.count == 1)
let message = context.handledPublicMessages[0]
#expect(message.content == "hi @me")
#expect(message.mentions == ["me"])
#expect(message.senderPeerID == peerID)
#expect(context.hapticMessageIDs == ["m1"])
}
@Test @MainActor
func didConnectAndDisconnect_manageSessionsStableIDsAndReadReceipts() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data(repeating: 0xAB, count: 32)
context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")
coordinator.didConnectToPeer(peerID)
await drainMainActorTasks()
#expect(context.isConnected)
#expect(context.registeredEphemeralSessions == [peerID])
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey))
#expect(context.flushedOutboxPeerIDs == [peerID])
#expect(context.notifyUIChangedCount == 1)
// Their messages' read receipts are un-marked on disconnect so READ
// acks can be re-sent after reconnect; our own messages are not.
context.privateChats[peerID] = [
makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID),
makeMessage(id: "mine-1", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID)
]
coordinator.didDisconnectFromPeer(peerID)
await drainMainActorTasks()
#expect(context.removedEphemeralSessions == [peerID])
#expect(context.unmarkedReadReceiptBatches == [["theirs-1", "theirs-2"]])
#expect(context.notifyUIChangedCount == 2)
}
@Test @MainActor
func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data(repeating: 0xCD, count: 32)
let stablePeerID = PeerID(hexData: noiseKey)
// No cached stable ID: it must be derived from the Noise session key.
context.noiseSessionKeysByPeerID[peerID] = noiseKey
context.selectedPrivateChatPeer = peerID
context.unreadPrivateMessages = [peerID]
context.privateChats[peerID] = [
makeMessage(id: "m1", isPrivate: true, senderPeerID: peerID),
makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID)
]
coordinator.didDisconnectFromPeer(peerID)
await drainMainActorTasks()
#expect(context.privateChats[peerID] == nil)
#expect(context.privateChats[stablePeerID]?.map(\.id) == ["m1", "mine"])
// Sender IDs migrate to the stable peer ID, except our own.
#expect(context.privateChats[stablePeerID]?.first?.senderPeerID == stablePeerID)
#expect(context.privateChats[stablePeerID]?.last?.senderPeerID == context.myPeerID)
#expect(context.selectedPrivateChatPeer == stablePeerID)
#expect(context.unreadPrivateMessages == [stablePeerID])
#expect(context.stablePeerIDCache[peerID] == stablePeerID)
}
@Test @MainActor
func noisePayloads_driveDeliveryStatusAcksAndVerification() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "99aabbccddeeff00")
let noiseKey = Data(repeating: 0x44, count: 32)
context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")
// Inbound private message: decoded, handled, and delivery-acked.
let packet = PrivateMessagePacket(messageID: "pm-1", content: "hi there")
coordinator.didReceiveNoisePayload(
from: peerID,
type: .privateMessage,
payload: packet.encode() ?? Data(),
timestamp: Date()
)
await drainMainActorTasks()
#expect(context.handledPrivateMessages.map(\.id) == ["pm-1"])
#expect(context.handledPrivateMessages.first?.sender == "alice")
#expect(context.meshDeliveryAcks.count == 1)
#expect(context.meshDeliveryAcks.first?.messageID == "pm-1")
// Delivered / read acks resolve the display name from the unified peer.
coordinator.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data("m-1".utf8), timestamp: Date())
coordinator.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data("m-2".utf8), timestamp: Date())
await drainMainActorTasks()
#expect(context.appliedDeliveryStatuses.count == 2)
#expect(context.appliedDeliveryStatuses[0].messageID == "m-1")
if case .delivered(let to, _) = context.appliedDeliveryStatuses[0].status {
#expect(to == "alice")
} else {
Issue.record("expected .delivered status")
}
if case .read(let by, _) = context.appliedDeliveryStatuses[1].status {
#expect(by == "alice")
} else {
Issue.record("expected .read status")
}
// Verification payloads are forwarded untouched.
coordinator.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data([0x01]), timestamp: Date())
coordinator.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data([0x02]), timestamp: Date())
await drainMainActorTasks()
#expect(context.verifyChallengePayloads.count == 1)
#expect(context.verifyResponsePayloads.count == 1)
// Blocked peers' private messages are dropped (no handling, no ack).
context.blockedPeers = [peerID]
coordinator.didReceiveNoisePayload(
from: peerID,
type: .privateMessage,
payload: packet.encode() ?? Data(),
timestamp: Date()
)
await drainMainActorTasks()
#expect(context.handledPrivateMessages.count == 1)
#expect(context.meshDeliveryAcks.count == 1)
}
}