mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
* 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>
354 lines
16 KiB
Swift
354 lines
16 KiB
Swift
//
|
|
// ChatVouchCoordinatorContextTests.swift
|
|
// bitchatTests
|
|
//
|
|
// Exercises `ChatVouchCoordinator` against a mock `ChatVouchContext` —
|
|
// proving the exchange policy (verified + capable peers only, batch cap,
|
|
// 24h rate limit) and the accept policy (verified senders only, real
|
|
// Ed25519 signature verification, expiry) without a `ChatViewModel`.
|
|
// Storage-level gates (self-vouch, already-verified vouchee, per-vouchee
|
|
// cap) are covered by `SecureIdentityStateManagerVouchTests`.
|
|
//
|
|
|
|
import CryptoKit
|
|
import Foundation
|
|
import BitFoundation
|
|
import Testing
|
|
|
|
@testable import bitchat
|
|
|
|
// MARK: - Mock Context
|
|
|
|
@MainActor
|
|
private final class MockChatVouchContext: ChatVouchContext {
|
|
// Identity & trust state
|
|
var fingerprintsByPeerID: [PeerID: String] = [:]
|
|
var verifiedFingerprints: Set<String> = []
|
|
var signingKeysByFingerprint: [String: Data] = [:]
|
|
var recentVerified: [String] = []
|
|
private(set) var recentVerifiedRequests: [(limit: Int, excluding: String)] = []
|
|
private(set) var recordedVouches: [(vouchee: String, voucher: String, timestamp: Date)] = []
|
|
var recordVouchResult = true
|
|
var lastBatchSentAt: [String: Date] = [:]
|
|
private(set) var markedBatchSent: [(fingerprint: String, date: Date)] = []
|
|
|
|
func getFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] }
|
|
func isVerifiedFingerprint(_ fingerprint: String) -> Bool { verifiedFingerprints.contains(fingerprint) }
|
|
func signingKey(forFingerprint fingerprint: String) -> Data? { signingKeysByFingerprint[fingerprint] }
|
|
|
|
func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
|
|
recentVerifiedRequests.append((limit, fingerprint))
|
|
return Array(recentVerified.filter { $0 != fingerprint }.prefix(limit))
|
|
}
|
|
|
|
@discardableResult
|
|
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
|
|
recordedVouches.append((voucheeFingerprint, voucherFingerprint, timestamp))
|
|
return recordVouchResult
|
|
}
|
|
|
|
func lastVouchBatchSent(to fingerprint: String) -> Date? { lastBatchSentAt[fingerprint] }
|
|
|
|
func markVouchBatchSent(to fingerprint: String, at date: Date) {
|
|
markedBatchSent.append((fingerprint, date))
|
|
lastBatchSentAt[fingerprint] = date
|
|
}
|
|
|
|
// Transport
|
|
var capabilitiesByPeerID: [PeerID: PeerCapabilities] = [:]
|
|
var mySigningKey = Curve25519.Signing.PrivateKey()
|
|
private(set) var installedObservers: [(PeerID, String) -> Void] = []
|
|
private(set) var sentVouchPayloads: [(payload: Data, peerID: PeerID)] = []
|
|
|
|
var connectedPeerIDList: [PeerID] = []
|
|
|
|
func peerCapabilities(for peerID: PeerID) -> PeerCapabilities { capabilitiesByPeerID[peerID] ?? [] }
|
|
|
|
func connectedPeerIDs() -> [PeerID] { connectedPeerIDList }
|
|
|
|
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
|
|
installedObservers.append(handler)
|
|
}
|
|
|
|
func noiseSignData(_ data: Data) -> Data? { try? mySigningKey.signature(for: data) }
|
|
|
|
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
|
|
sentVouchPayloads.append((payload, peerID))
|
|
}
|
|
|
|
// UI refresh
|
|
private(set) var trustChangedCount = 0
|
|
|
|
func notifyPeerTrustChanged() { trustChangedCount += 1 }
|
|
}
|
|
|
|
// MARK: - Tests
|
|
|
|
struct ChatVouchCoordinatorContextTests {
|
|
private let peerID = PeerID(str: "1122334455667788")
|
|
private let peerFingerprint = String(repeating: "0f", count: 32)
|
|
|
|
@MainActor
|
|
private func makeVerifiedCapablePeer() -> (MockChatVouchContext, ChatVouchCoordinator) {
|
|
let context = MockChatVouchContext()
|
|
let coordinator = ChatVouchCoordinator(context: context)
|
|
context.fingerprintsByPeerID[peerID] = peerFingerprint
|
|
context.verifiedFingerprints.insert(peerFingerprint)
|
|
context.capabilitiesByPeerID[peerID] = [.vouch]
|
|
return (context, coordinator)
|
|
}
|
|
|
|
// MARK: Exchange policy
|
|
|
|
@Test @MainActor
|
|
func peerAuthenticated_sendsBatchForVerifiedCapablePeer() throws {
|
|
let (context, coordinator) = makeVerifiedCapablePeer()
|
|
let vouchees = [String(repeating: "01", count: 32), String(repeating: "02", count: 32)]
|
|
context.recentVerified = vouchees
|
|
for vouchee in vouchees {
|
|
context.signingKeysByFingerprint[vouchee] = Data(repeating: 0x33, count: 32)
|
|
}
|
|
|
|
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
|
|
|
|
// Candidates are requested most-recent-first, excluding the target.
|
|
#expect(context.recentVerifiedRequests.count == 1)
|
|
#expect(context.recentVerifiedRequests.first?.limit == VouchAttestation.maxBatchCount)
|
|
#expect(context.recentVerifiedRequests.first?.excluding == peerFingerprint)
|
|
|
|
let sent = try #require(context.sentVouchPayloads.first)
|
|
#expect(sent.peerID == peerID)
|
|
let attestations = VouchAttestation.decodeList(from: sent.payload)
|
|
#expect(attestations.map(\.voucheeFingerprintHex) == vouchees)
|
|
// Every attestation carries a valid signature under our signing key.
|
|
let myPublicKey = context.mySigningKey.publicKey.rawRepresentation
|
|
#expect(attestations.allSatisfy { $0.verifySignature(voucherSigningKey: myPublicKey) })
|
|
|
|
// The rate limit is stamped only after an actual send.
|
|
#expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint])
|
|
}
|
|
|
|
@Test @MainActor
|
|
func peerAuthenticated_requiresVerificationAndCapability() {
|
|
let (context, coordinator) = makeVerifiedCapablePeer()
|
|
context.recentVerified = [String(repeating: "01", count: 32)]
|
|
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
|
|
|
|
// Not verified by me: nothing.
|
|
context.verifiedFingerprints.remove(peerFingerprint)
|
|
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
|
|
#expect(context.sentVouchPayloads.isEmpty)
|
|
|
|
// Verified but advertises a non-empty capability set lacking .vouch:
|
|
// nothing. (An *empty*/unknown set is race-tolerant and still sends —
|
|
// see `attemptVouch_sendsWhenCapabilitiesUnknown`.)
|
|
context.verifiedFingerprints.insert(peerFingerprint)
|
|
context.capabilitiesByPeerID[peerID] = [.prekeys]
|
|
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
|
|
#expect(context.sentVouchPayloads.isEmpty)
|
|
#expect(context.markedBatchSent.isEmpty)
|
|
}
|
|
|
|
// MARK: Capability race tolerance & new triggers
|
|
|
|
@Test @MainActor
|
|
func attemptVouch_sendsWhenCapabilitiesUnknown() {
|
|
// Capability set still empty at attempt time (the peer's .vouch bit
|
|
// arrives on a later announce): the batch must still go out.
|
|
let (context, coordinator) = makeVerifiedCapablePeer()
|
|
context.capabilitiesByPeerID[peerID] = []
|
|
context.recentVerified = [String(repeating: "01", count: 32)]
|
|
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
|
|
|
|
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
|
|
#expect(context.sentVouchPayloads.count == 1)
|
|
#expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint])
|
|
}
|
|
|
|
@Test @MainActor
|
|
func vouchToConnectedVerifiedPeers_sendsToConnectedVerifiedCapablePeer() {
|
|
let (context, coordinator) = makeVerifiedCapablePeer()
|
|
context.connectedPeerIDList = [peerID]
|
|
context.recentVerified = [String(repeating: "01", count: 32)]
|
|
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
|
|
|
|
// Session is already up (no peerAuthenticated re-fire); the verify pass
|
|
// is what makes the batch go out.
|
|
coordinator.vouchToConnectedVerifiedPeers()
|
|
let sent = context.sentVouchPayloads
|
|
#expect(sent.count == 1)
|
|
#expect(sent.first?.peerID == peerID)
|
|
#expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint])
|
|
}
|
|
|
|
@Test @MainActor
|
|
func vouchToConnectedVerifiedPeers_skipsUnverifiedConnectedPeers() {
|
|
let (context, coordinator) = makeVerifiedCapablePeer()
|
|
context.connectedPeerIDList = [peerID]
|
|
context.verifiedFingerprints.remove(peerFingerprint)
|
|
context.recentVerified = [String(repeating: "01", count: 32)]
|
|
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
|
|
|
|
coordinator.vouchToConnectedVerifiedPeers()
|
|
#expect(context.sentVouchPayloads.isEmpty)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func peersUpdated_sendsOnceCapabilityBearingAnnounceArrives() {
|
|
let (context, coordinator) = makeVerifiedCapablePeer()
|
|
context.recentVerified = [String(repeating: "01", count: 32)]
|
|
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
|
|
|
|
// First announce before the .vouch bit is known: empty set is
|
|
// race-tolerant, so it already sends and stamps the throttle.
|
|
context.capabilitiesByPeerID[peerID] = []
|
|
coordinator.peersUpdated([peerID])
|
|
#expect(context.sentVouchPayloads.count == 1)
|
|
|
|
// A later announce carrying .vouch must not double-send (throttled).
|
|
context.capabilitiesByPeerID[peerID] = [.vouch]
|
|
coordinator.peersUpdated([peerID])
|
|
#expect(context.sentVouchPayloads.count == 1)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func peerAuthenticated_rateLimitsPerPeerPer24Hours() {
|
|
let (context, coordinator) = makeVerifiedCapablePeer()
|
|
context.recentVerified = [String(repeating: "01", count: 32)]
|
|
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
|
|
|
|
let now = Date()
|
|
context.lastBatchSentAt[peerFingerprint] = now.addingTimeInterval(-60 * 60)
|
|
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint, now: now)
|
|
#expect(context.sentVouchPayloads.isEmpty)
|
|
|
|
// Once the interval has elapsed the batch goes out again.
|
|
context.lastBatchSentAt[peerFingerprint] = now.addingTimeInterval(-ChatVouchCoordinator.batchInterval - 1)
|
|
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint, now: now)
|
|
#expect(context.sentVouchPayloads.count == 1)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func peerAuthenticated_skipsCandidatesWithoutSigningKeysAndEmptyBatches() {
|
|
let (context, coordinator) = makeVerifiedCapablePeer()
|
|
let withKey = String(repeating: "01", count: 32)
|
|
let withoutKey = String(repeating: "02", count: 32)
|
|
context.recentVerified = [withoutKey, withKey]
|
|
context.signingKeysByFingerprint[withKey] = Data(repeating: 0x33, count: 32)
|
|
|
|
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
|
|
let attestations = VouchAttestation.decodeList(from: context.sentVouchPayloads[0].payload)
|
|
#expect(attestations.map(\.voucheeFingerprintHex) == [withKey])
|
|
|
|
// No signable candidates at all: nothing is sent or rate-stamped.
|
|
let freshPeer = PeerID(str: "aabbccddeeff0011")
|
|
let freshFingerprint = String(repeating: "0e", count: 32)
|
|
context.fingerprintsByPeerID[freshPeer] = freshFingerprint
|
|
context.verifiedFingerprints.insert(freshFingerprint)
|
|
context.capabilitiesByPeerID[freshPeer] = [.vouch]
|
|
context.recentVerified = [withoutKey]
|
|
coordinator.peerAuthenticated(freshPeer, fingerprint: freshFingerprint)
|
|
#expect(context.sentVouchPayloads.count == 1)
|
|
#expect(!context.markedBatchSent.contains { $0.fingerprint == freshFingerprint })
|
|
}
|
|
|
|
// MARK: Accept policy
|
|
|
|
@MainActor
|
|
private func makeInboundBatch(
|
|
signedBy key: Curve25519.Signing.PrivateKey,
|
|
vouchee: String = String(repeating: "07", count: 32),
|
|
timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
) throws -> Data {
|
|
let voucheeData = try #require(Data(hexString: vouchee))
|
|
let attestation = try #require(VouchAttestation.build(
|
|
voucheeFingerprint: voucheeData,
|
|
voucheeSigningKey: Data(repeating: 0x44, count: 32),
|
|
timestampMs: timestampMs,
|
|
sign: { try? key.signature(for: $0) }
|
|
))
|
|
return try #require(VouchAttestation.encodeList([attestation]))
|
|
}
|
|
|
|
@Test @MainActor
|
|
func handleVouchPayload_acceptsValidVouchFromVerifiedSender() throws {
|
|
let (context, coordinator) = makeVerifiedCapablePeer()
|
|
let senderKey = Curve25519.Signing.PrivateKey()
|
|
context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation
|
|
|
|
let vouchee = String(repeating: "07", count: 32)
|
|
let payload = try makeInboundBatch(signedBy: senderKey, vouchee: vouchee)
|
|
coordinator.handleVouchPayload(from: peerID, payload: payload)
|
|
|
|
#expect(context.recordedVouches.count == 1)
|
|
#expect(context.recordedVouches.first?.vouchee == vouchee)
|
|
#expect(context.recordedVouches.first?.voucher == peerFingerprint)
|
|
#expect(context.trustChangedCount == 1)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func handleVouchPayload_rejectsUnverifiedOrUnknownSender() throws {
|
|
let (context, coordinator) = makeVerifiedCapablePeer()
|
|
let senderKey = Curve25519.Signing.PrivateKey()
|
|
context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation
|
|
let payload = try makeInboundBatch(signedBy: senderKey)
|
|
|
|
// Sender's fingerprint is not in my verified set.
|
|
context.verifiedFingerprints.remove(peerFingerprint)
|
|
coordinator.handleVouchPayload(from: peerID, payload: payload)
|
|
#expect(context.recordedVouches.isEmpty)
|
|
|
|
// Unknown peer entirely.
|
|
coordinator.handleVouchPayload(from: PeerID(str: "ffeeddccbbaa9988"), payload: payload)
|
|
#expect(context.recordedVouches.isEmpty)
|
|
#expect(context.trustChangedCount == 0)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func handleVouchPayload_rejectsForgedSignaturesAndExpiredAttestations() throws {
|
|
let (context, coordinator) = makeVerifiedCapablePeer()
|
|
let senderKey = Curve25519.Signing.PrivateKey()
|
|
context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation
|
|
|
|
// Signed by an imposter key: signature check against the sender's
|
|
// announce-bound key fails.
|
|
let imposter = Curve25519.Signing.PrivateKey()
|
|
let forged = try makeInboundBatch(signedBy: imposter)
|
|
coordinator.handleVouchPayload(from: peerID, payload: forged)
|
|
#expect(context.recordedVouches.isEmpty)
|
|
|
|
// Correctly signed but expired.
|
|
let staleMs = UInt64(Date().addingTimeInterval(-31 * 24 * 60 * 60).timeIntervalSince1970 * 1000)
|
|
let expired = try makeInboundBatch(signedBy: senderKey, timestampMs: staleMs)
|
|
coordinator.handleVouchPayload(from: peerID, payload: expired)
|
|
#expect(context.recordedVouches.isEmpty)
|
|
#expect(context.trustChangedCount == 0)
|
|
|
|
// No signing key known for the sender: batch dropped.
|
|
context.signingKeysByFingerprint.removeValue(forKey: peerFingerprint)
|
|
let valid = try makeInboundBatch(signedBy: senderKey)
|
|
coordinator.handleVouchPayload(from: peerID, payload: valid)
|
|
#expect(context.recordedVouches.isEmpty)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func handleVouchPayload_skipsUIRefreshWhenNothingStored() throws {
|
|
let (context, coordinator) = makeVerifiedCapablePeer()
|
|
let senderKey = Curve25519.Signing.PrivateKey()
|
|
context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation
|
|
context.recordVouchResult = false // e.g. self-vouch dropped by the store
|
|
|
|
let payload = try makeInboundBatch(signedBy: senderKey)
|
|
coordinator.handleVouchPayload(from: peerID, payload: payload)
|
|
#expect(context.recordedVouches.count == 1)
|
|
#expect(context.trustChangedCount == 0)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func setupNoiseCallbacks_installsAdditiveObserver() {
|
|
let (context, coordinator) = makeVerifiedCapablePeer()
|
|
coordinator.setupNoiseCallbacks()
|
|
#expect(context.installedObservers.count == 1)
|
|
}
|
|
}
|