diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index f2256c21..79c23f57 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -4,6 +4,100 @@ import Network import Combine import Tor +protocol NostrRelayConnectionProtocol: AnyObject { + func resume() + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) + func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) + func receive(completionHandler: @escaping (Result) -> Void) + func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) +} + +protocol NostrRelaySessionProtocol { + func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol +} + +private final class URLSessionWebSocketTaskAdapter: NostrRelayConnectionProtocol { + private let base: URLSessionWebSocketTask + + init(base: URLSessionWebSocketTask) { + self.base = base + } + + func resume() { + base.resume() + } + + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + base.cancel(with: closeCode, reason: reason) + } + + func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) { + base.send(message, completionHandler: completionHandler) + } + + func receive(completionHandler: @escaping (Result) -> Void) { + base.receive(completionHandler: completionHandler) + } + + func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) { + base.sendPing(pongReceiveHandler: pongReceiveHandler) + } +} + +private struct URLSessionAdapter: NostrRelaySessionProtocol { + let base: URLSession + + func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol { + URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url)) + } +} + +struct NostrRelayManagerDependencies { + var activationAllowed: () -> Bool + var userTorEnabled: () -> Bool + var hasMutualFavorites: () -> Bool + var hasLocationPermission: () -> Bool + var mutualFavoritesPublisher: AnyPublisher, Never> + var locationPermissionPublisher: AnyPublisher + var torEnforced: () -> Bool + var torIsReady: () -> Bool + var torIsForeground: () -> Bool + var awaitTorReady: (@escaping (Bool) -> Void) -> Void + var makeSession: () -> NostrRelaySessionProtocol + var scheduleAfter: (TimeInterval, @escaping () -> Void) -> Void + var now: () -> Date +} + +private extension NostrRelayManagerDependencies { + @MainActor + static func live() -> Self { + Self( + activationAllowed: { NetworkActivationService.shared.activationAllowed }, + userTorEnabled: { NetworkActivationService.shared.userTorEnabled }, + hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty }, + hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized }, + mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(), + locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(), + torEnforced: { TorManager.shared.torEnforced }, + torIsReady: { TorManager.shared.isReady }, + torIsForeground: { TorManager.shared.isForeground() }, + awaitTorReady: { completion in + Task.detached { + let ready = await TorManager.shared.awaitReady() + await MainActor.run { + completion(ready) + } + } + }, + makeSession: { URLSessionAdapter(base: TorURLSession.shared.session) }, + scheduleAfter: { delay, action in + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) + }, + now: Date.init + ) + } +} + /// Manages WebSocket connections to Nostr relays @MainActor final class NostrRelayManager: ObservableObject { @@ -41,10 +135,11 @@ final class NostrRelayManager: ObservableObject { @Published private(set) var relays: [Relay] = [] @Published private(set) var isConnected = false + private let dependencies: NostrRelayManagerDependencies private var allowDefaultRelays: Bool = false private var hasMutualFavorites: Bool = false private var hasLocationPermission: Bool = false - private var connections: [String: URLSessionWebSocketTask] = [:] + private var connections: [String: NostrRelayConnectionProtocol] = [:] private var subscriptions: [String: Set] = [:] // relay URL -> active subscription IDs private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON) private var messageHandlers: [String: (NostrEvent) -> Void] = [:] @@ -69,8 +164,7 @@ final class NostrRelayManager: ObservableObject { private var messageQueue: [PendingSend] = [] private let messageQueueLock = NSLock() private let encoder = JSONEncoder() - private var networkService: NetworkActivationService { NetworkActivationService.shared } - private var shouldUseTor: Bool { networkService.userTorEnabled } + private var shouldUseTor: Bool { dependencies.userTorEnabled() } // Exponential backoff configuration private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds @@ -82,12 +176,13 @@ final class NostrRelayManager: ObservableObject { private var connectionGeneration: Int = 0 init() { - hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty - hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized + self.dependencies = .live() + hasMutualFavorites = dependencies.hasMutualFavorites() + hasLocationPermission = dependencies.hasLocationPermission() applyDefaultRelayPolicy(force: true) // Deterministic JSON shape for outbound requests self.encoder.outputFormatting = .sortedKeys - FavoritesPersistenceService.shared.$mutualFavorites + dependencies.mutualFavoritesPublisher .receive(on: DispatchQueue.main) .sink { [weak self] favorites in guard let self = self else { return } @@ -95,7 +190,34 @@ final class NostrRelayManager: ObservableObject { self.applyDefaultRelayPolicy() } .store(in: &cancellables) - LocationChannelManager.shared.$permissionState + dependencies.locationPermissionPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self = self else { return } + let authorized = (state == .authorized) + if authorized == self.hasLocationPermission { return } + self.hasLocationPermission = authorized + self.applyDefaultRelayPolicy() + } + .store(in: &cancellables) + } + + internal init(dependencies: NostrRelayManagerDependencies) { + self.dependencies = dependencies + hasMutualFavorites = dependencies.hasMutualFavorites() + hasLocationPermission = dependencies.hasLocationPermission() + applyDefaultRelayPolicy(force: true) + // Deterministic JSON shape for outbound requests + self.encoder.outputFormatting = .sortedKeys + dependencies.mutualFavoritesPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] favorites in + guard let self = self else { return } + self.hasMutualFavorites = !favorites.isEmpty + self.applyDefaultRelayPolicy() + } + .store(in: &cancellables) + dependencies.locationPermissionPublisher .receive(on: DispatchQueue.main) .sink { [weak self] state in guard let self = self else { return } @@ -110,20 +232,18 @@ final class NostrRelayManager: ObservableObject { /// Connect to all configured relays func connect() { // Global network policy gate - guard networkService.activationAllowed else { return } + guard dependencies.activationAllowed() else { return } if shouldUseTor { // Ensure Tor is started early and wait for readiness off-main; then hop back to connect. - Task.detached { - let ready = await TorManager.shared.awaitReady() - await MainActor.run { - if !ready { - SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session) - return - } - SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session) - for relay in self.relays { - self.connectToRelay(relay.url) - } + dependencies.awaitTorReady { [weak self] ready in + guard let self = self else { return } + if !ready { + SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session) + return + } + SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session) + for relay in self.relays { + self.connectToRelay(relay.url) } } } else { @@ -150,15 +270,14 @@ final class NostrRelayManager: ObservableObject { /// Ensure connections exist to the given relay URLs (idempotent). func ensureConnections(to relayUrls: [String]) { // Global network policy gate - guard networkService.activationAllowed else { return } + guard dependencies.activationAllowed() else { return } let targets = allowedRelayList(from: relayUrls) guard !targets.isEmpty else { return } - if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady { + if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { // Defer until Tor is fully ready; avoid queuing connection attempts early - Task.detached { [weak self] in + dependencies.awaitTorReady { [weak self] ready in guard let self = self else { return } - let ready = await TorManager.shared.awaitReady() - await MainActor.run { if ready { self.ensureConnections(to: relayUrls) } } + if ready { self.ensureConnections(to: relayUrls) } } return } @@ -175,13 +294,12 @@ final class NostrRelayManager: ObservableObject { /// Send an event to specified relays (or all if none specified) func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) { // Global network policy gate - guard networkService.activationAllowed else { return } - if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady { + guard dependencies.activationAllowed() else { return } + if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { // Defer sends until Tor is ready to avoid premature queueing - Task.detached { [weak self] in + dependencies.awaitTorReady { [weak self] ready in guard let self = self else { return } - let ready = await TorManager.shared.awaitReady() - await MainActor.run { if ready { self.sendEvent(event, to: relayUrls) } } + if ready { self.sendEvent(event, to: relayUrls) } } return } @@ -253,24 +371,21 @@ final class NostrRelayManager: ObservableObject { onEOSE: (() -> Void)? = nil ) { // Global network policy gate - guard networkService.activationAllowed else { return } + guard dependencies.activationAllowed() else { return } // Coalesce rapid duplicate subscribe requests only if a handler already exists - let now = Date() + let now = dependencies.now() if messageHandlers[id] != nil { if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 { return } } subscribeCoalesce[id] = now - if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady { + if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { // Defer subscription setup until Tor is ready; avoid queuing subs early - Task.detached { [weak self] in + dependencies.awaitTorReady { [weak self] ready in guard let self = self else { return } - let ready = await TorManager.shared.awaitReady() - await MainActor.run { - if ready { - self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler) - } + if ready { + self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler, onEOSE: onEOSE) } } return @@ -346,7 +461,7 @@ final class NostrRelayManager: ObservableObject { relays.append(Relay(url: url)) existing.insert(url) } - if networkService.activationAllowed { + if dependencies.activationAllowed() { ensureConnections(to: Self.defaultRelays) } } else { @@ -400,10 +515,9 @@ final class NostrRelayManager: ObservableObject { // Send unsubscribe to all relays for (relayUrl, connection) in connections { if subscriptions[relayUrl]?.contains(id) == true { + subscriptions[relayUrl]?.remove(id) connection.send(.string(messageString)) { _ in - Task { @MainActor in - self.subscriptions[relayUrl]?.remove(id) - } + // Local state is cleared before sending so callers can re-subscribe immediately. } } } @@ -413,14 +527,14 @@ final class NostrRelayManager: ObservableObject { private func connectToRelay(_ urlString: String) { // Global network policy gate - guard networkService.activationAllowed else { return } + guard dependencies.activationAllowed() else { return } guard let url = URL(string: urlString) else { SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session) return } // Avoid initiating connections while app is backgrounded; we'll reconnect on foreground - if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() { + if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsForeground() { return } @@ -435,19 +549,16 @@ final class NostrRelayManager: ObservableObject { // Attempting to connect to Nostr relay via the proxied session // If Tor is enforced but not ready, delay connection until it is. - if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady { - Task.detached { [weak self] in + if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { + dependencies.awaitTorReady { [weak self] ready in guard let self = self else { return } - let ready = await TorManager.shared.awaitReady() - await MainActor.run { - if ready { self.connectToRelay(urlString) } - else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) } - } + if ready { self.connectToRelay(urlString) } + else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) } } return } - let session = TorURLSession.shared.session + let session = dependencies.makeSession() let task = session.webSocketTask(with: url) connections[urlString] = task @@ -495,7 +606,7 @@ final class NostrRelayManager: ObservableObject { pendingSubscriptions[relayUrl] = nil } - private func receiveMessage(from task: URLSessionWebSocketTask, relayUrl: String) { + private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) { task.receive { [weak self] result in guard let self = self else { return } @@ -505,7 +616,7 @@ final class NostrRelayManager: ObservableObject { Task.detached(priority: .utility) { guard let parsed = ParsedInbound(message) else { return } await MainActor.run { - NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl) + self.handleParsedMessage(parsed, from: relayUrl) } } @@ -569,7 +680,7 @@ final class NostrRelayManager: ObservableObject { } } - private func sendToRelay(event: NostrEvent, connection: URLSessionWebSocketTask, relayUrl: String) { + private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) { let req = NostrRequest.event(event) do { @@ -601,11 +712,11 @@ final class NostrRelayManager: ObservableObject { relays[index].isConnected = isConnected relays[index].lastError = error if isConnected { - relays[index].lastConnectedAt = Date() + relays[index].lastConnectedAt = dependencies.now() relays[index].reconnectAttempts = 0 // Reset on successful connection relays[index].nextReconnectTime = nil } else { - relays[index].lastDisconnectedAt = Date() + relays[index].lastDisconnectedAt = dependencies.now() } } updateConnectionStatus() @@ -621,7 +732,7 @@ final class NostrRelayManager: ObservableObject { private func handleDisconnection(relayUrl: String, error: Error) { // If networking is disallowed, do not schedule reconnection - if !networkService.activationAllowed { + if !dependencies.activationAllowed() { connections.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl) updateRelayStatus(relayUrl, isConnected: false, error: error) @@ -666,13 +777,13 @@ final class NostrRelayManager: ObservableObject { maxBackoffInterval ) - let nextReconnectTime = Date().addingTimeInterval(backoffInterval) + let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval) relays[index].nextReconnectTime = nextReconnectTime // Schedule reconnection with exponential backoff let gen = connectionGeneration - DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in + dependencies.scheduleAfter(backoffInterval) { [weak self] in guard let self = self else { return } // Ignore stale scheduled reconnects from a previous generation guard gen == self.connectionGeneration else { return } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 2980e4b5..0ba5a753 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -251,7 +251,8 @@ final class BLEService: NSObject { init( keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, - identityManager: SecureIdentityStateManagerProtocol + identityManager: SecureIdentityStateManagerProtocol, + initializeBluetoothManagers: Bool = true ) { self.keychain = keychain self.idBridge = idBridge @@ -294,22 +295,23 @@ final class BLEService: NSObject { // Tag BLE queue for re-entrancy detection bleQueue.setSpecific(key: bleQueueKey, value: ()) - // Initialize BLE on background queue to prevent main thread blocking - // This prevents app freezes during BLE operations - #if os(iOS) - let centralOptions: [String: Any] = [ - CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID - ] - centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions) + if initializeBluetoothManagers { + // Initialize BLE on background queue to prevent main thread blocking. + #if os(iOS) + let centralOptions: [String: Any] = [ + CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID + ] + centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions) - let peripheralOptions: [String: Any] = [ - CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID - ] - peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions) - #else - centralManager = CBCentralManager(delegate: self, queue: bleQueue) - peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue) - #endif + let peripheralOptions: [String: Any] = [ + CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID + ] + peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions) + #else + centralManager = CBCentralManager(delegate: self, queue: bleQueue) + peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue) + #endif + } // Single maintenance timer for all periodic tasks (dispatch-based for determinism) let timer = DispatchSource.makeTimerSource(queue: bleQueue) diff --git a/bitchat/Services/GeohashPresenceService.swift b/bitchat/Services/GeohashPresenceService.swift index 08415631..e311c5c4 100644 --- a/bitchat/Services/GeohashPresenceService.swift +++ b/bitchat/Services/GeohashPresenceService.swift @@ -13,6 +13,25 @@ import Combine import BitLogger import Tor +protocol GeohashPresenceTimerProtocol: AnyObject { + var isValid: Bool { get } + func invalidate() +} + +private final class GeohashPresenceTimerAdapter: GeohashPresenceTimerProtocol { + private let base: Timer + + init(base: Timer) { + self.base = base + } + + var isValid: Bool { base.isValid } + + func invalidate() { + base.invalidate() + } +} + /// Service that coordinates the broadcasting of presence heartbeats. /// /// Behavior: @@ -25,18 +44,27 @@ final class GeohashPresenceService: ObservableObject { static let shared = GeohashPresenceService() private var subscriptions = Set() - private var heartbeatTimer: Timer? - private let idBridge = NostrIdentityBridge() + private var heartbeatTimer: GeohashPresenceTimerProtocol? + private let availableChannelsProvider: () -> [GeohashChannel] + private let locationChanges: AnyPublisher<[GeohashChannel], Never> + private let torReadyPublisher: AnyPublisher + private let torIsReady: () -> Bool + private let torIsForeground: () -> Bool + private let deriveIdentity: (String) throws -> NostrIdentity + private let relayLookup: (String, Int) -> [String] + private let relaySender: (NostrEvent, [String]) -> Void + private let sleeper: (UInt64) async -> Void + private let scheduleTimer: (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol // MARK: - Constants // Loop interval range in seconds - private let loopMinInterval: TimeInterval = 40.0 - private let loopMaxInterval: TimeInterval = 80.0 + private let loopMinInterval: TimeInterval + private let loopMaxInterval: TimeInterval // Per-broadcast decorrelation delay range in seconds - private let burstMinDelay: TimeInterval = 2.0 - private let burstMaxDelay: TimeInterval = 5.0 + private let burstMinDelay: TimeInterval + private let burstMaxDelay: TimeInterval // Privacy: Only broadcast to these levels private let allowedPrecisions: Set = [ @@ -46,6 +74,74 @@ final class GeohashPresenceService: ObservableObject { ] private init() { + let idBridge = NostrIdentityBridge() + self.availableChannelsProvider = { LocationStateManager.shared.availableChannels } + self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher() + self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady) + .map { _ in () } + .eraseToAnyPublisher() + self.torIsReady = { TorManager.shared.isReady } + self.torIsForeground = { TorManager.shared.isForeground() } + self.deriveIdentity = { try idBridge.deriveIdentity(forGeohash: $0) } + self.relayLookup = { geohash, count in + GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count) + } + self.relaySender = { event, relays in + NostrRelayManager.shared.sendEvent(event, to: relays) + } + self.sleeper = { nanoseconds in + try? await Task.sleep(nanoseconds: nanoseconds) + } + self.scheduleTimer = { interval, action in + GeohashPresenceTimerAdapter( + base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in + action() + } + ) + } + self.loopMinInterval = 40.0 + self.loopMaxInterval = 80.0 + self.burstMinDelay = 2.0 + self.burstMaxDelay = 5.0 + setupObservers() + } + + internal init( + availableChannelsProvider: @escaping () -> [GeohashChannel], + locationChanges: AnyPublisher<[GeohashChannel], Never>, + torReadyPublisher: AnyPublisher, + torIsReady: @escaping () -> Bool, + torIsForeground: @escaping () -> Bool, + deriveIdentity: @escaping (String) throws -> NostrIdentity, + relayLookup: @escaping (String, Int) -> [String], + relaySender: @escaping (NostrEvent, [String]) -> Void, + sleeper: @escaping (UInt64) async -> Void = { nanoseconds in try? await Task.sleep(nanoseconds: nanoseconds) }, + scheduleTimer: @escaping (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol = { interval, action in + GeohashPresenceTimerAdapter( + base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in + action() + } + ) + }, + loopMinInterval: TimeInterval = 40.0, + loopMaxInterval: TimeInterval = 80.0, + burstMinDelay: TimeInterval = 2.0, + burstMaxDelay: TimeInterval = 5.0 + ) { + self.availableChannelsProvider = availableChannelsProvider + self.locationChanges = locationChanges + self.torReadyPublisher = torReadyPublisher + self.torIsReady = torIsReady + self.torIsForeground = torIsForeground + self.deriveIdentity = deriveIdentity + self.relayLookup = relayLookup + self.relaySender = relaySender + self.sleeper = sleeper + self.scheduleTimer = scheduleTimer + self.loopMinInterval = loopMinInterval + self.loopMaxInterval = loopMaxInterval + self.burstMinDelay = burstMinDelay + self.burstMaxDelay = burstMaxDelay setupObservers() } @@ -57,7 +153,7 @@ final class GeohashPresenceService: ObservableObject { private func setupObservers() { // Monitor location channel changes - LocationStateManager.shared.$availableChannels + locationChanges .dropFirst() .sink { [weak self] _ in self?.handleLocationChange() @@ -65,28 +161,28 @@ final class GeohashPresenceService: ObservableObject { .store(in: &subscriptions) // Monitor Tor readiness to kick off heartbeat if it was stalled - NotificationCenter.default.publisher(for: .TorDidBecomeReady) + torReadyPublisher .sink { [weak self] _ in self?.handleConnectivityChange() } .store(in: &subscriptions) } - private func handleLocationChange() { + func handleLocationChange() { // When location changes, we trigger an immediate (but slightly delayed) heartbeat // to announce presence in the new zone, then reset the loop. SecureLogger.debug("Presence: location changed, scheduling update", category: .session) heartbeatTimer?.invalidate() // Small delay to allow location state to settle - heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { [weak self] _ in + heartbeatTimer = scheduleTimer(5.0) { [weak self] in Task { @MainActor [weak self] in self?.performHeartbeat() } } } - private func handleConnectivityChange() { + func handleConnectivityChange() { SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session) // If we were waiting for network, do it now if heartbeatTimer == nil || !heartbeatTimer!.isValid { @@ -94,33 +190,33 @@ final class GeohashPresenceService: ObservableObject { } } - private func scheduleNextHeartbeat() { + func scheduleNextHeartbeat() { heartbeatTimer?.invalidate() let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval) - heartbeatTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in + heartbeatTimer = scheduleTimer(interval) { [weak self] in Task { @MainActor [weak self] in self?.performHeartbeat() } } } - private func performHeartbeat() { + func performHeartbeat() { // Always schedule next loop first ensures continuity even if this one fails/skips defer { scheduleNextHeartbeat() } // 1. Check preconditions - guard TorManager.shared.isReady else { + guard torIsReady() else { SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session) return } // App must be active (or at least we shouldn't broadcast if in background, usually) - if !TorManager.shared.isForeground() { + if !torIsForeground() { return } // 2. Get channels - let channels = LocationStateManager.shared.availableChannels + let channels = availableChannelsProvider() guard !channels.isEmpty else { return } // 3. Filter and broadcast @@ -136,16 +232,16 @@ final class GeohashPresenceService: ObservableObject { // Random delay for decorrelation let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay) let nanoseconds = UInt64(delay * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanoseconds) + await self.sleeper(nanoseconds) self.broadcastPresence(for: channel.geohash) } } } - private func broadcastPresence(for geohash: String) { + func broadcastPresence(for geohash: String) { do { - guard let identity = try? idBridge.deriveIdentity(forGeohash: geohash) else { + guard let identity = try? deriveIdentity(geohash) else { return } @@ -155,13 +251,10 @@ final class GeohashPresenceService: ObservableObject { ) // Send via RelayManager - let targetRelays = GeoRelayDirectory.shared.closestRelays( - toGeohash: geohash, - count: TransportConfig.nostrGeoRelayCount - ) + let targetRelays = relayLookup(geohash, TransportConfig.nostrGeoRelayCount) if !targetRelays.isEmpty { - NostrRelayManager.shared.sendEvent(event, to: targetRelays) + relaySender(event, targetRelays) SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session) } } catch { diff --git a/bitchat/Services/LocationStateManager.swift b/bitchat/Services/LocationStateManager.swift index dc7af41f..6d287146 100644 --- a/bitchat/Services/LocationStateManager.swift +++ b/bitchat/Services/LocationStateManager.swift @@ -5,6 +5,82 @@ import Combine #if os(iOS) || os(macOS) import CoreLocation +protocol LocationStateManaging: AnyObject { + var delegate: CLLocationManagerDelegate? { get set } + var desiredAccuracy: CLLocationAccuracy { get set } + var distanceFilter: CLLocationDistance { get set } + var authorizationStatus: CLAuthorizationStatus { get } + func requestWhenInUseAuthorization() + func requestLocation() + func startUpdatingLocation() + func stopUpdatingLocation() +} + +protocol LocationStateGeocoding: AnyObject { + func cancelGeocode() + func reverseGeocodeLocation( + _ location: CLLocation, + completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void + ) +} + +private final class CLLocationManagerAdapter: NSObject, LocationStateManaging { + private let base = CLLocationManager() + + var delegate: CLLocationManagerDelegate? { + get { base.delegate } + set { base.delegate = newValue } + } + + var desiredAccuracy: CLLocationAccuracy { + get { base.desiredAccuracy } + set { base.desiredAccuracy = newValue } + } + + var distanceFilter: CLLocationDistance { + get { base.distanceFilter } + set { base.distanceFilter = newValue } + } + + var authorizationStatus: CLAuthorizationStatus { + if #available(iOS 14.0, macOS 11.0, *) { + return base.authorizationStatus + } + return CLLocationManager.authorizationStatus() + } + + func requestWhenInUseAuthorization() { + base.requestWhenInUseAuthorization() + } + + func requestLocation() { + base.requestLocation() + } + + func startUpdatingLocation() { + base.startUpdatingLocation() + } + + func stopUpdatingLocation() { + base.stopUpdatingLocation() + } +} + +private final class CLGeocoderAdapter: LocationStateGeocoding { + private let base = CLGeocoder() + + func cancelGeocode() { + base.cancelGeocode() + } + + func reverseGeocodeLocation( + _ location: CLLocation, + completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void + ) { + base.reverseGeocodeLocation(location, completionHandler: completionHandler) + } +} + /// Unified manager for location-based channel state including: /// - CoreLocation permissions and one-shot location retrieval /// - Geohash channel computation from coordinates @@ -26,8 +102,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl // MARK: - Private Properties (CoreLocation) - private let cl = CLLocationManager() - private let geocoder = CLGeocoder() + private let cl: LocationStateManaging + private let geocoder: LocationStateGeocoding private var lastLocation: CLLocation? private var refreshTimer: Timer? private var isGeocoding: Bool = false @@ -73,6 +149,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl private override init() { self.storage = .standard + self.cl = CLLocationManagerAdapter() + self.geocoder = CLGeocoderAdapter() super.init() // Skip CoreLocation setup in test environments @@ -92,10 +170,30 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl /// Internal initializer for testing with custom storage init(storage: UserDefaults) { self.storage = storage + self.cl = CLLocationManagerAdapter() + self.geocoder = CLGeocoderAdapter() super.init() loadPersistedState() } + internal init( + storage: UserDefaults, + locationManager: LocationStateManaging, + geocoder: LocationStateGeocoding, + shouldInitializeCoreLocation: Bool + ) { + self.storage = storage + self.cl = locationManager + self.geocoder = geocoder + super.init() + loadPersistedState() + guard shouldInitializeCoreLocation else { return } + cl.delegate = self + cl.desiredAccuracy = kCLLocationAccuracyHundredMeters + cl.distanceFilter = TransportConfig.locationDistanceFilterMeters + initializePermissionState() + } + private func loadPersistedState() { // Load selected channel if let data = storage.data(forKey: selectedChannelKey), @@ -132,12 +230,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl } private func initializePermissionState() { - let status: CLAuthorizationStatus - if #available(iOS 14.0, macOS 11.0, *) { - status = cl.authorizationStatus - } else { - status = CLLocationManager.authorizationStatus() - } + let status = cl.authorizationStatus updatePermissionState(from: status) // Fall back to persisted teleport state if no location authorization @@ -156,12 +249,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl // MARK: - Public API (Permissions & Location) func enableLocationChannels() { - let status: CLAuthorizationStatus - if #available(iOS 14.0, macOS 11.0, *) { - status = cl.authorizationStatus - } else { - status = CLLocationManager.authorizationStatus() - } + let status = cl.authorizationStatus switch status { case .notDetermined: cl.requestWhenInUseAuthorization() diff --git a/bitchat/Services/NetworkActivationService.swift b/bitchat/Services/NetworkActivationService.swift index 305726c5..a9795024 100644 --- a/bitchat/Services/NetworkActivationService.swift +++ b/bitchat/Services/NetworkActivationService.swift @@ -3,6 +3,27 @@ import BitLogger import Combine import Tor +@MainActor +protocol NetworkActivationTorControlling: AnyObject { + func setAutoStartAllowed(_ allowed: Bool) + func startIfNeeded() + func shutdownCompletely() +} + +@MainActor +protocol NetworkActivationRelayControlling: AnyObject { + func connect() + func disconnect() +} + +protocol NetworkActivationProxyControlling: AnyObject { + func setProxyMode(useTor: Bool) +} + +extension TorManager: NetworkActivationTorControlling {} +extension NostrRelayManager: NetworkActivationRelayControlling {} +extension TorURLSession: NetworkActivationProxyControlling {} + /// Coordinates when the app is allowed to start Tor and connect to Nostr relays. /// Policy: permit start when either location permissions are authorized OR /// there exists at least one mutual favorite. Otherwise, do not start. @@ -17,14 +38,55 @@ final class NetworkActivationService: ObservableObject { private var started = false private let torPreferenceKey = "networkActivationService.userTorEnabled" private var torAutoStartDesired: Bool = false + private let storage: UserDefaults + private let locationPermissionPublisher: AnyPublisher + private let mutualFavoritesPublisher: AnyPublisher, Never> + private let permissionProvider: () -> LocationChannelManager.PermissionState + private let mutualFavoritesProvider: () -> Set + private let torController: NetworkActivationTorControlling + private let relayController: NetworkActivationRelayControlling + private let proxyController: NetworkActivationProxyControlling + private let notificationCenter: NotificationCenter - private init() {} + private init() { + storage = .standard + locationPermissionPublisher = LocationChannelManager.shared.$permissionState.eraseToAnyPublisher() + mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher() + permissionProvider = { LocationChannelManager.shared.permissionState } + mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites } + torController = TorManager.shared + relayController = NostrRelayManager.shared + proxyController = TorURLSession.shared + notificationCenter = .default + } + + internal init( + storage: UserDefaults, + locationPermissionPublisher: AnyPublisher, + mutualFavoritesPublisher: AnyPublisher, Never>, + permissionProvider: @escaping () -> LocationChannelManager.PermissionState, + mutualFavoritesProvider: @escaping () -> Set, + torController: NetworkActivationTorControlling, + relayController: NetworkActivationRelayControlling, + proxyController: NetworkActivationProxyControlling, + notificationCenter: NotificationCenter = .default + ) { + self.storage = storage + self.locationPermissionPublisher = locationPermissionPublisher + self.mutualFavoritesPublisher = mutualFavoritesPublisher + self.permissionProvider = permissionProvider + self.mutualFavoritesProvider = mutualFavoritesProvider + self.torController = torController + self.relayController = relayController + self.proxyController = proxyController + self.notificationCenter = notificationCenter + } func start() { guard !started else { return } started = true - if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool { + if let stored = storage.object(forKey: torPreferenceKey) as? Bool { userTorEnabled = stored } else { userTorEnabled = true @@ -34,16 +96,16 @@ final class NetworkActivationService: ObservableObject { let allowed = basePolicyAllowed() activationAllowed = allowed torAutoStartDesired = allowed && userTorEnabled - TorManager.shared.setAutoStartAllowed(torAutoStartDesired) + torController.setAutoStartAllowed(torAutoStartDesired) applyTorState(torDesired: torAutoStartDesired) if allowed { - NostrRelayManager.shared.connect() + relayController.connect() } else { - NostrRelayManager.shared.disconnect() + relayController.disconnect() } // React to location permission changes - LocationChannelManager.shared.$permissionState + locationPermissionPublisher .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.reevaluate() @@ -51,7 +113,7 @@ final class NetworkActivationService: ObservableObject { .store(in: &cancellables) // React to mutual favorites changes - FavoritesPersistenceService.shared.$mutualFavorites + mutualFavoritesPublisher .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.reevaluate() @@ -62,8 +124,8 @@ final class NetworkActivationService: ObservableObject { func setUserTorEnabled(_ enabled: Bool) { guard enabled != userTorEnabled else { return } userTorEnabled = enabled - UserDefaults.standard.set(enabled, forKey: torPreferenceKey) - NotificationCenter.default.post( + storage.set(enabled, forKey: torPreferenceKey) + notificationCenter.post( name: .TorUserPreferenceChanged, object: nil, userInfo: ["enabled": enabled] @@ -82,33 +144,33 @@ final class NetworkActivationService: ObservableObject { } if statusChanged || torChanged { torAutoStartDesired = torDesired - TorManager.shared.setAutoStartAllowed(torDesired) + torController.setAutoStartAllowed(torDesired) applyTorState(torDesired: torDesired) } if allowed { if torChanged { // Reset relay sockets when switching transport path (Tor ↔︎ direct) - NostrRelayManager.shared.disconnect() + relayController.disconnect() } - NostrRelayManager.shared.connect() + relayController.connect() } else if statusChanged { - NostrRelayManager.shared.disconnect() + relayController.disconnect() } } private func basePolicyAllowed() -> Bool { - let permOK = LocationChannelManager.shared.permissionState == .authorized - let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty + let permOK = permissionProvider() == .authorized + let hasMutual = !mutualFavoritesProvider().isEmpty return permOK || hasMutual } private func applyTorState(torDesired: Bool) { - TorURLSession.shared.setProxyMode(useTor: torDesired) + proxyController.setProxyMode(useTor: torDesired) if torDesired { - TorManager.shared.startIfNeeded() + torController.startIfNeeded() } else { - TorManager.shared.shutdownCompletely() + torController.shutdownCompletely() } } } diff --git a/bitchat/Services/NotificationService.swift b/bitchat/Services/NotificationService.swift index 636bd97d..f63f3923 100644 --- a/bitchat/Services/NotificationService.swift +++ b/bitchat/Services/NotificationService.swift @@ -14,24 +14,103 @@ import UIKit import AppKit #endif +protocol NotificationAuthorizing { + func requestAuthorization( + options: UNAuthorizationOptions, + completionHandler: @escaping (Bool, Error?) -> Void + ) +} + +protocol NotificationRequestDelivering { + func add(_ request: UNNotificationRequest) +} + +private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing { + private let center: UNUserNotificationCenter + + init(center: UNUserNotificationCenter) { + self.center = center + } + + func requestAuthorization( + options: UNAuthorizationOptions, + completionHandler: @escaping (Bool, Error?) -> Void + ) { + center.requestAuthorization(options: options, completionHandler: completionHandler) + } +} + +private final class NotificationCenterRequestDelivererAdapter: NotificationRequestDelivering { + private let center: UNUserNotificationCenter + + init(center: UNUserNotificationCenter) { + self.center = center + } + + func add(_ request: UNNotificationRequest) { + Task { + try? await center.add(request) + } + } +} + +private struct NoopNotificationAuthorizer: NotificationAuthorizing { + func requestAuthorization( + options: UNAuthorizationOptions, + completionHandler: @escaping (Bool, Error?) -> Void + ) { + completionHandler(false, nil) + } +} + +private struct NoopNotificationRequestDeliverer: NotificationRequestDelivering { + func add(_ request: UNNotificationRequest) {} +} + final class NotificationService { static let shared = NotificationService() + private let isRunningTestsProvider: () -> Bool + private let authorizer: NotificationAuthorizing + private let requestDeliverer: NotificationRequestDelivering + /// Returns true if running in test environment (XCTest, Swift Testing, or CI) private var isRunningTests: Bool { - let env = ProcessInfo.processInfo.environment - return NSClassFromString("XCTestCase") != nil || - env["XCTestConfigurationFilePath"] != nil || - env["XCTestBundlePath"] != nil || - env["GITHUB_ACTIONS"] != nil || - env["CI"] != nil + isRunningTestsProvider() } - private init() {} + private init() { + self.isRunningTestsProvider = { + let env = ProcessInfo.processInfo.environment + return NSClassFromString("XCTestCase") != nil || + env["XCTestConfigurationFilePath"] != nil || + env["XCTestBundlePath"] != nil || + env["GITHUB_ACTIONS"] != nil || + env["CI"] != nil + } + if isRunningTestsProvider() { + self.authorizer = NoopNotificationAuthorizer() + self.requestDeliverer = NoopNotificationRequestDeliverer() + } else { + let center = UNUserNotificationCenter.current() + self.authorizer = NotificationCenterAuthorizerAdapter(center: center) + self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center) + } + } + + internal init( + isRunningTestsProvider: @escaping () -> Bool, + authorizer: NotificationAuthorizing, + requestDeliverer: NotificationRequestDelivering + ) { + self.isRunningTestsProvider = isRunningTestsProvider + self.authorizer = authorizer + self.requestDeliverer = requestDeliverer + } func requestAuthorization() { guard !isRunningTests else { return } - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in if granted { // Permission granted } else { @@ -64,7 +143,7 @@ final class NotificationService { trigger: nil // Deliver immediately ) - UNUserNotificationCenter.current().add(request) + requestDeliverer.add(request) } func sendMentionNotification(from sender: String, message: String) { diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index b81a762d..5aa3dde9 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -89,7 +89,12 @@ private func makeService() -> BLEService { let keychain = MockKeychain() let identityManager = MockIdentityManager(keychain) let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) - return BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager) + return BLEService( + keychain: keychain, + idBridge: idBridge, + identityManager: identityManager, + initializeBluetoothManagers: false + ) } private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket { diff --git a/bitchatTests/ChatViewModelRefactoringTests.swift b/bitchatTests/ChatViewModelRefactoringTests.swift index 713ea036..22284e5b 100644 --- a/bitchatTests/ChatViewModelRefactoringTests.swift +++ b/bitchatTests/ChatViewModelRefactoringTests.swift @@ -125,26 +125,20 @@ struct ChatViewModelRefactoringTests { let (viewModel, _, _) = makePinnedViewModel() let senderID = PeerID(str: "sender_2") - // Setup - let message = BitchatMessage( - id: "msg_2", - sender: "charlie", + // Action + viewModel.didReceivePublicMessage( + from: senderID, + nickname: "charlie", content: "Public Hi", timestamp: Date(), - isRelay: false, - originalSender: nil, - isPrivate: false, - recipientNickname: nil, - senderPeerID: senderID, - mentions: nil + messageID: "msg_2" ) - // Action - viewModel.didReceiveMessage(message) - // Wait for async processing with proper timeout let found = await TestHelpers.waitUntil( - { viewModel.messages.contains(where: { $0.content == "Public Hi" }) }, + { + viewModel.timelineStore.messages(for: .mesh).contains(where: { $0.content == "Public Hi" }) + }, timeout: TestConstants.defaultTimeout ) diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index e48aa17f..e57234a9 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -196,8 +196,7 @@ struct ChatViewModelReceivingTests { ) let found = await TestHelpers.waitUntil({ - viewModel.publicMessagePipeline.flushIfNeeded() - return viewModel.messages.contains { $0.content == "Public hello from Bob" } + viewModel.timelineStore.messages(for: .mesh).contains { $0.content == "Public hello from Bob" } }, timeout: TestConstants.defaultTimeout) #expect(found) diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index c9db45e5..28800b45 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -1,11 +1,13 @@ +import Foundation import Testing @testable import bitchat +@Suite(.serialized) struct CommandProcessorTests { - private var identityManager = MockIdentityManager(MockKeychain()) @MainActor @Test func slapNotFoundGrammar() { + let identityManager = MockIdentityManager(MockKeychain()) let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager) let result = processor.process("/slap @system") switch result { @@ -18,6 +20,7 @@ struct CommandProcessorTests { @MainActor @Test func hugNotFoundGrammar() { + let identityManager = MockIdentityManager(MockKeychain()) let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager) let result = processor.process("/hug @system") switch result { @@ -30,6 +33,7 @@ struct CommandProcessorTests { @MainActor @Test func slapUsageMessage() { + let identityManager = MockIdentityManager(MockKeychain()) let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager) let result = processor.process("/slap") switch result { @@ -39,4 +43,389 @@ struct CommandProcessorTests { Issue.record("Expected error result for usage message") } } + + @MainActor + @Test func msgStartsPrivateChatAndSendsMessage() async { + let identityManager = MockIdentityManager(MockKeychain()) + let context = MockCommandContextProvider() + let peerID = PeerID(str: "abcd1234abcd1234") + context.nicknameToPeerID["alice"] = peerID + let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager) + + let result = await withSelectedChannel(.mesh) { + processor.process("/msg @alice hello there") + } + + switch result { + case .success(let message): + #expect(message == "started private chat with alice") + default: + Issue.record("Expected success result") + } + #expect(context.startedPrivateChats == [peerID]) + #expect(context.sentPrivateMessages.count == 1) + #expect(context.sentPrivateMessages.first?.content == "hello there") + #expect(context.sentPrivateMessages.first?.peerID == peerID) + } + + @MainActor + @Test func whoInMeshListsSortedPeerNicknames() async { + let identityManager = MockIdentityManager(MockKeychain()) + let transport = MockTransport() + transport.peerNicknames = [ + PeerID(str: "b"): "bob", + PeerID(str: "a"): "alice" + ] + let processor = CommandProcessor(contextProvider: MockCommandContextProvider(), meshService: transport, identityManager: identityManager) + + let result = await withSelectedChannel(.mesh) { + processor.process("/who") + } + + switch result { + case .success(let message): + #expect(message == "online: alice, bob") + default: + Issue.record("Expected success result") + } + } + + @MainActor + @Test func whoInGeohashListsVisibleParticipantsExcludingSelf() async throws { + let bridge = NostrIdentityBridge(keychain: MockKeychain()) + let identityManager = MockIdentityManager(MockKeychain()) + let context = MockCommandContextProvider(idBridge: bridge) + let geohash = "u4pruy" + let selfPubkey = try bridge.deriveIdentity(forGeohash: geohash).publicKeyHex.lowercased() + context.visibleGeoParticipants = [ + CommandGeoParticipant(id: selfPubkey, displayName: "me"), + CommandGeoParticipant(id: String(repeating: "b", count: 64), displayName: "bob") + ] + let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager) + let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) + + let result = try await withSelectedChannel(channel) { + processor.process("/who") + } + + switch result { + case .success(let message): + #expect(message == "online: bob") + default: + Issue.record("Expected success result") + } + } + + @MainActor + @Test func clearInPrivateChatRemovesOnlySelectedConversation() async { + let identityManager = MockIdentityManager(MockKeychain()) + let context = MockCommandContextProvider() + let activePeer = PeerID(str: "active") + let otherPeer = PeerID(str: "other") + context.selectedPrivateChatPeer = activePeer + context.privateChats = [ + activePeer: [makeMessage(sender: "alice", content: "secret")], + otherPeer: [makeMessage(sender: "bob", content: "keep")] + ] + let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager) + + let result = await withSelectedChannel(.mesh) { + processor.process("/clear") + } + + switch result { + case .handled: + break + default: + Issue.record("Expected handled result") + } + #expect(context.privateChats[activePeer] == []) + #expect(context.privateChats[otherPeer]?.count == 1) + } + + @MainActor + @Test func clearInPublicChatClearsTimeline() async { + let identityManager = MockIdentityManager(MockKeychain()) + let context = MockCommandContextProvider() + let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager) + + let result = await withSelectedChannel(.mesh) { + processor.process("/clear") + } + + switch result { + case .handled: + break + default: + Issue.record("Expected handled result") + } + #expect(context.clearCurrentPublicTimelineCallCount == 1) + } + + @MainActor + @Test func hugInPrivateChatSendsPersonalizedMessageAndLocalEcho() async { + let identityManager = MockIdentityManager(MockKeychain()) + let context = MockCommandContextProvider(nickname: "me") + let transport = MockTransport() + let peerID = PeerID(str: "abcd1234abcd1234") + context.selectedPrivateChatPeer = peerID + context.nicknameToPeerID["bob"] = peerID + transport.peerNicknames[peerID] = "Bob" + let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager) + + let result = await withSelectedChannel(.mesh) { + processor.process("/hug @bob") + } + + switch result { + case .handled: + break + default: + Issue.record("Expected handled result") + } + #expect(transport.sentPrivateMessages.count == 1) + #expect(transport.sentPrivateMessages.first?.content == "* 🫂 me hugs you *") + #expect(context.localPrivateSystemMessages.first?.content == "🫂 you hugged bob") + #expect(context.localPrivateSystemMessages.first?.peerID == peerID) + } + + @MainActor + @Test func slapInPublicChatSendsPublicRawAndEcho() async { + let identityManager = MockIdentityManager(MockKeychain()) + let context = MockCommandContextProvider(nickname: "me") + let peerID = PeerID(str: "abcd1234abcd1234") + context.nicknameToPeerID["bob"] = peerID + let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager) + + let result = await withSelectedChannel(.mesh) { + processor.process("/slap @bob") + } + + switch result { + case .handled: + break + default: + Issue.record("Expected handled result") + } + #expect(context.sentPublicRawMessages == ["* 🐟 me slaps bob around a bit with a large trout *"]) + #expect(context.publicSystemMessages == ["🐟 me slaps bob around a bit with a large trout"]) + } + + @MainActor + @Test func blockWithoutArgsListsMeshAndGeohashBlocks() async { + let identityManager = MockIdentityManager(MockKeychain()) + let context = MockCommandContextProvider() + let transport = MockTransport() + let peerID = PeerID(str: "abcd1234abcd1234") + transport.peerNicknames[peerID] = "bob" + transport.peerFingerprints[peerID] = "fp-bob" + context.blockedUsers = ["fp-bob"] + context.visibleGeoParticipants = [ + CommandGeoParticipant(id: String(repeating: "c", count: 64), displayName: "carol") + ] + identityManager.setNostrBlocked(String(repeating: "c", count: 64), isBlocked: true) + let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager) + + let result = await withSelectedChannel(.mesh) { + processor.process("/block") + } + + switch result { + case .success(let message): + #expect(message == "blocked peers: bob | geohash blocks: carol") + default: + Issue.record("Expected success result") + } + } + + @MainActor + @Test func blockAndUnblockMeshPeerUpdateIdentityState() async { + let identityManager = MockIdentityManager(MockKeychain()) + let context = MockCommandContextProvider() + let transport = MockTransport() + let peerID = PeerID(str: "abcd1234abcd1234") + transport.peerFingerprints[peerID] = "fp-bob" + context.nicknameToPeerID["bob"] = peerID + let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager) + + let blockResult = await withSelectedChannel(.mesh) { + processor.process("/block @bob") + } + switch blockResult { + case .success(let message): + #expect(message == "blocked bob. you will no longer receive messages from them") + default: + Issue.record("Expected success result") + } + #expect(identityManager.isBlocked(fingerprint: "fp-bob")) + + let unblockResult = await withSelectedChannel(.mesh) { + processor.process("/unblock bob") + } + switch unblockResult { + case .success(let message): + #expect(message == "unblocked bob") + default: + Issue.record("Expected success result") + } + #expect(!identityManager.isBlocked(fingerprint: "fp-bob")) + } + + @MainActor + @Test func blockAndUnblockGeohashPeerUseNostrBlockList() async { + let identityManager = MockIdentityManager(MockKeychain()) + let context = MockCommandContextProvider() + context.displayNameToNostrPubkey["carol"] = String(repeating: "d", count: 64) + let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager) + + let blockResult = await withSelectedChannel(.mesh) { + processor.process("/block carol") + } + switch blockResult { + case .success(let message): + #expect(message == "blocked carol in geohash chats") + default: + Issue.record("Expected success result") + } + #expect(identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64))) + + let unblockResult = await withSelectedChannel(.mesh) { + processor.process("/unblock @carol") + } + switch unblockResult { + case .success(let message): + #expect(message == "unblocked carol in geohash chats") + default: + Issue.record("Expected success result") + } + #expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64))) + } + + @MainActor + @Test func favoriteCommandIsRejectedOutsideMesh() async { + let identityManager = MockIdentityManager(MockKeychain()) + let processor = CommandProcessor( + contextProvider: MockCommandContextProvider(), + meshService: MockTransport(), + identityManager: identityManager + ) + let channel = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pruy")) + + let result = await withSelectedChannel(channel) { + processor.process("/fav alice") + } + + switch result { + case .error(let message): + #expect(message == "favorites are only for mesh peers in #mesh") + default: + Issue.record("Expected error result") + } + } + + @MainActor + private func withSelectedChannel(_ channel: ChannelID, perform work: @escaping () throws -> T) async rethrows -> T { + let originalChannel = LocationChannelManager.shared.selectedChannel + await setSelectedChannel(channel) + do { + let result = try work() + await setSelectedChannel(originalChannel) + return result + } catch { + await setSelectedChannel(originalChannel) + throw error + } + } + + @MainActor + private func setSelectedChannel(_ channel: ChannelID) async { + LocationChannelManager.shared.select(channel) + for _ in 0..<40 { + if LocationChannelManager.shared.selectedChannel == channel { + return + } + await Task.yield() + try? await Task.sleep(nanoseconds: 5_000_000) + } + } + + private func makeMessage(sender: String, content: String) -> BitchatMessage { + BitchatMessage( + sender: sender, + content: content, + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + isRelay: false + ) + } +} + +@MainActor +private final class MockCommandContextProvider: CommandContextProvider { + var nickname: String + var selectedPrivateChatPeer: PeerID? + var blockedUsers: Set = [] + var privateChats: [PeerID: [BitchatMessage]] = [:] + let idBridge: NostrIdentityBridge + + var nicknameToPeerID: [String: PeerID] = [:] + var visibleGeoParticipants: [CommandGeoParticipant] = [] + var displayNameToNostrPubkey: [String: String] = [:] + + private(set) var startedPrivateChats: [PeerID] = [] + private(set) var sentPrivateMessages: [(content: String, peerID: PeerID)] = [] + private(set) var clearCurrentPublicTimelineCallCount = 0 + private(set) var sentPublicRawMessages: [String] = [] + private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = [] + private(set) var publicSystemMessages: [String] = [] + private(set) var toggledFavorites: [PeerID] = [] + private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = [] + + init(nickname: String = "tester", idBridge: NostrIdentityBridge = NostrIdentityBridge(keychain: MockKeychain())) { + self.nickname = nickname + self.idBridge = idBridge + } + + func getPeerIDForNickname(_ nickname: String) -> PeerID? { + nicknameToPeerID[nickname] + } + + func getVisibleGeoParticipants() -> [CommandGeoParticipant] { + visibleGeoParticipants + } + + func nostrPubkeyForDisplayName(_ displayName: String) -> String? { + displayNameToNostrPubkey[displayName] + } + + func startPrivateChat(with peerID: PeerID) { + startedPrivateChats.append(peerID) + } + + func sendPrivateMessage(_ content: String, to peerID: PeerID) { + sentPrivateMessages.append((content, peerID)) + } + + func clearCurrentPublicTimeline() { + clearCurrentPublicTimelineCallCount += 1 + } + + func sendPublicRaw(_ content: String) { + sentPublicRawMessages.append(content) + } + + func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) { + localPrivateSystemMessages.append((content, peerID)) + } + + func addPublicSystemMessage(_ content: String) { + publicSystemMessages.append(content) + } + + func toggleFavorite(peerID: PeerID) { + toggledFavorites.append(peerID) + } + + func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { + favoriteNotifications.append((peerID, isFavorite)) + } } diff --git a/bitchatTests/Fragmentation/FragmentationTests.swift b/bitchatTests/Fragmentation/FragmentationTests.swift index 7feebc50..d016ceae 100644 --- a/bitchatTests/Fragmentation/FragmentationTests.swift +++ b/bitchatTests/Fragmentation/FragmentationTests.swift @@ -28,7 +28,8 @@ struct FragmentationTests { let ble = BLEService( keychain: mockKeychain, idBridge: idBridge, - identityManager: mockIdentityManager + identityManager: mockIdentityManager, + initializeBluetoothManagers: false ) let capture = CaptureDelegate() ble.delegate = capture @@ -63,7 +64,8 @@ struct FragmentationTests { let ble = BLEService( keychain: mockKeychain, idBridge: idBridge, - identityManager: mockIdentityManager + identityManager: mockIdentityManager, + initializeBluetoothManagers: false ) let capture = CaptureDelegate() ble.delegate = capture @@ -97,7 +99,8 @@ struct FragmentationTests { let ble = BLEService( keychain: mockKeychain, idBridge: idBridge, - identityManager: mockIdentityManager + identityManager: mockIdentityManager, + initializeBluetoothManagers: false ) let capture = CaptureDelegate() ble.delegate = capture @@ -153,7 +156,8 @@ struct FragmentationTests { let ble = BLEService( keychain: mockKeychain, idBridge: idBridge, - identityManager: mockIdentityManager + identityManager: mockIdentityManager, + initializeBluetoothManagers: false ) let capture = CaptureDelegate() ble.delegate = capture diff --git a/bitchatTests/MessageFormattingEngineTests.swift b/bitchatTests/MessageFormattingEngineTests.swift index 3de0b95f..2c1f8e1e 100644 --- a/bitchatTests/MessageFormattingEngineTests.swift +++ b/bitchatTests/MessageFormattingEngineTests.swift @@ -12,6 +12,108 @@ import SwiftUI @testable import bitchat struct MessageFormattingEngineTests { + // MARK: - Formatting Behavior Tests + + @MainActor + @Test func formatMessage_regularMessageFormatsHeaderContentAndTimestamp() { + let senderPeerID = PeerID(str: "abcdef1234567890") + let context = MockMessageFormattingContext( + nickname: "carol", + peerURLs: [senderPeerID: URL(string: "https://example.com/peers/alice")!] + ) + let message = BitchatMessage( + id: "message-1", + sender: "alice#a1b2", + content: "hello #mesh https://example.com", + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + isRelay: false, + senderPeerID: senderPeerID + ) + + let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light) + + #expect(String(formatted.characters) == "<@alice#a1b2> hello #mesh https://example.com [\(message.formattedTimestamp)]") + #expect(message.getCachedFormattedText(isDark: false, isSelf: false) != nil) + } + + @MainActor + @Test func formatMessage_systemMessageUsesSystemLayout() { + let context = MockMessageFormattingContext(nickname: "carol") + let message = BitchatMessage( + id: "system-1", + sender: "system", + content: "connected", + timestamp: Date(timeIntervalSince1970: 1_700_000_123), + isRelay: false + ) + + let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .dark) + + #expect(String(formatted.characters) == "* connected * [\(message.formattedTimestamp)]") + #expect(message.getCachedFormattedText(isDark: true, isSelf: false) != nil) + } + + @MainActor + @Test func formatMessage_longSelfMessageFallsBackToPlainContentPath() { + let context = MockMessageFormattingContext( + nickname: "me", + selfMessageIDs: ["self-1"] + ) + let longContent = String(repeating: "a", count: 4_500) + let message = BitchatMessage( + id: "self-1", + sender: "me#cafe", + content: longContent, + timestamp: Date(timeIntervalSince1970: 1_700_000_456), + isRelay: false + ) + + let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light) + + #expect(String(formatted.characters) == "<@me#cafe> \(longContent) [\(message.formattedTimestamp)]") + #expect(message.getCachedFormattedText(isDark: false, isSelf: true) != nil) + } + + @MainActor + @Test func formatMessage_mentionsAreRenderedThroughMentionFormatter() { + let context = MockMessageFormattingContext(nickname: "carol") + let message = BitchatMessage( + id: "message-mention", + sender: "alice", + content: "hi @bob#a1b2", + timestamp: Date(timeIntervalSince1970: 1_700_000_789), + isRelay: false + ) + + let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light) + + #expect(String(formatted.characters) == "<@alice> hi bob#a1b2 [\(message.formattedTimestamp)]") + } + + @MainActor + @Test func formatHeader_formatsNormalAndSystemSenders() { + let context = MockMessageFormattingContext(nickname: "carol") + let normalMessage = BitchatMessage( + id: "header-1", + sender: "alice#a1b2", + content: "hello", + timestamp: Date(timeIntervalSince1970: 1_700_001_000), + isRelay: false + ) + let systemMessage = BitchatMessage( + id: "header-2", + sender: "system", + content: "notice", + timestamp: Date(timeIntervalSince1970: 1_700_001_111), + isRelay: false + ) + + let normalHeader = MessageFormattingEngine.formatHeader(normalMessage, context: context, colorScheme: .light) + let systemHeader = MessageFormattingEngine.formatHeader(systemMessage, context: context, colorScheme: .dark) + + #expect(String(normalHeader.characters) == "<@alice#a1b2> ") + #expect(String(systemHeader.characters) == "system") + } // MARK: - Mention Extraction Tests @@ -221,3 +323,32 @@ struct MessageFormattingEngineTests { #expect(content.hasVeryLongToken(threshold: 50)) } } + +@MainActor +private final class MockMessageFormattingContext: MessageFormattingContext { + let nickname: String + private let selfMessageIDs: Set + private let peerURLs: [PeerID: URL] + + init( + nickname: String, + selfMessageIDs: Set = [], + peerURLs: [PeerID: URL] = [:] + ) { + self.nickname = nickname + self.selfMessageIDs = selfMessageIDs + self.peerURLs = peerURLs + } + + func isSelfMessage(_ message: BitchatMessage) -> Bool { + selfMessageIDs.contains(message.id) + } + + func senderColor(for message: BitchatMessage, isDark: Bool) -> Color { + .red + } + + func peerURL(for peerID: PeerID) -> URL? { + peerURLs[peerID] + } +} diff --git a/bitchatTests/Noise/NoiseRateLimiterTests.swift b/bitchatTests/Noise/NoiseRateLimiterTests.swift new file mode 100644 index 00000000..0e7f1a6a --- /dev/null +++ b/bitchatTests/Noise/NoiseRateLimiterTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import bitchat + +final class NoiseRateLimiterTests: XCTestCase { + func test_allowHandshake_blocksAfterPerPeerLimit() { + let limiter = NoiseRateLimiter() + let peerID = makePeerID(1) + + for _ in 0.. PeerID { + PeerID(str: String(format: "%016x", value)) + } +} diff --git a/bitchatTests/Protocols/BinaryEncodingUtilsTests.swift b/bitchatTests/Protocols/BinaryEncodingUtilsTests.swift new file mode 100644 index 00000000..7da9b00c --- /dev/null +++ b/bitchatTests/Protocols/BinaryEncodingUtilsTests.swift @@ -0,0 +1,72 @@ +import Foundation +import XCTest +@testable import bitchat + +final class BinaryEncodingUtilsTests: XCTestCase { + func test_appendAndReadPrimitiveValues_roundTrip() throws { + var data = Data() + data.appendUInt8(0x12) + data.appendUInt16(0x3456) + data.appendUInt32(0x789ABCDE) + data.appendUInt64(0x0123456789ABCDEF) + + var offset = 0 + XCTAssertEqual(data.readUInt8(at: &offset), 0x12) + XCTAssertEqual(data.readUInt16(at: &offset), 0x3456) + XCTAssertEqual(data.readUInt32(at: &offset), 0x789ABCDE) + XCTAssertEqual(data.readUInt64(at: &offset), 0x0123456789ABCDEF) + XCTAssertEqual(offset, data.count) + } + + func test_appendAndReadStringDataAndDate_roundTrip() throws { + let expectedDate = Date(timeIntervalSince1970: 1_700_000_000.123) + let expectedPayload = Data([0xAA, 0xBB, 0xCC, 0xDD]) + var data = Data() + + data.appendString("hello") + data.appendData(expectedPayload) + data.appendDate(expectedDate) + + var offset = 0 + XCTAssertEqual(data.readString(at: &offset), "hello") + XCTAssertEqual(data.readData(at: &offset), expectedPayload) + let decodedDate = try XCTUnwrap(data.readDate(at: &offset)) + XCTAssertEqual(decodedDate.timeIntervalSince1970, expectedDate.timeIntervalSince1970, accuracy: 0.001) + } + + func test_appendUUID_and_readUUID_roundTrip() throws { + let uuid = "12345678-90ab-cdef-1234-567890abcdef" + var data = Data() + + data.appendUUID(uuid) + + var offset = 0 + XCTAssertEqual(data.readUUID(at: &offset), uuid.uppercased()) + } + + func test_appendStringAndData_truncateToConfiguredMaxLength() throws { + var data = Data() + data.appendString("abcdef", maxLength: 4) + data.appendData(Data([1, 2, 3, 4, 5]), maxLength: 3) + + var offset = 0 + XCTAssertEqual(data.readString(at: &offset), "abcd") + XCTAssertEqual(data.readData(at: &offset, maxLength: 3), Data([1, 2, 3])) + } + + func test_readMethods_returnNilWhenOutOfBounds() { + var offset = 0 + let shortData = Data([0x01]) + + XCTAssertNil(shortData.readUInt16(at: &offset)) + XCTAssertEqual(offset, 0) + + offset = 0 + XCTAssertNil(shortData.readString(at: &offset)) + XCTAssertEqual(offset, 1) + + offset = 0 + XCTAssertNil(shortData.readFixedBytes(at: &offset, count: 2)) + XCTAssertEqual(offset, 0) + } +} diff --git a/bitchatTests/Services/FavoritesPersistenceServiceTests.swift b/bitchatTests/Services/FavoritesPersistenceServiceTests.swift new file mode 100644 index 00000000..8338d9bd --- /dev/null +++ b/bitchatTests/Services/FavoritesPersistenceServiceTests.swift @@ -0,0 +1,97 @@ +import XCTest +@testable import bitchat + +@MainActor +final class FavoritesPersistenceServiceTests: XCTestCase { + private let storageKey = "chat.bitchat.favorites" + private let serviceKey = "chat.bitchat.favorites" + + func test_addFavorite_persistsAndPostsNotification() throws { + let keychain = MockKeychain() + let service = FavoritesPersistenceService(keychain: keychain) + let peerKey = Data((0..<32).map(UInt8.init)) + let expectation = expectation(forNotification: .favoriteStatusChanged, object: nil) + + service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1alice", peerNickname: "Alice") + + wait(for: [expectation], timeout: 1.0) + XCTAssertTrue(service.isFavorite(peerKey)) + XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Alice") + XCTAssertNotNil(keychain.load(key: storageKey, service: serviceKey)) + } + + func test_removeFavorite_preservesRelationshipWhenPeerStillFavoritesUs() { + let service = FavoritesPersistenceService(keychain: MockKeychain()) + let peerKey = Data((32..<64).map(UInt8.init)) + + service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Bob") + service.addFavorite(peerNoisePublicKey: peerKey, peerNickname: "Bob") + service.removeFavorite(peerNoisePublicKey: peerKey) + + let relationship = service.getFavoriteStatus(for: peerKey) + XCTAssertNotNil(relationship) + XCTAssertEqual(relationship?.peerNickname, "Bob") + XCTAssertFalse(relationship?.isFavorite ?? true) + XCTAssertTrue(relationship?.theyFavoritedUs ?? false) + } + + func test_updatePeerFavoritedUs_removesRelationshipWhenNeitherSideFavorites() { + let service = FavoritesPersistenceService(keychain: MockKeychain()) + let peerKey = Data((64..<96).map(UInt8.init)) + + service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Carol") + XCTAssertNotNil(service.getFavoriteStatus(for: peerKey)) + + service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: false, peerNickname: "Carol") + + XCTAssertNil(service.getFavoriteStatus(for: peerKey)) + XCTAssertFalse(service.isMutualFavorite(peerKey)) + } + + func test_getFavoriteStatus_forPeerID_returnsMutualFavorite() { + let service = FavoritesPersistenceService(keychain: MockKeychain()) + let peerKey = Data((96..<128).map(UInt8.init)) + + service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1dan", peerNickname: "Dan") + service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Dan") + + let relationship = service.getFavoriteStatus(forPeerID: PeerID(publicKey: peerKey)) + XCTAssertEqual(relationship?.peerNickname, "Dan") + XCTAssertTrue(service.isMutualFavorite(peerKey)) + } + + func test_init_deduplicatesPersistedRelationshipsByPublicKey() throws { + let keychain = MockKeychain() + let peerKey = Data((128..<160).map(UInt8.init)) + let older = FavoritesPersistenceService.FavoriteRelationship( + peerNoisePublicKey: peerKey, + peerNostrPublicKey: nil, + peerNickname: "Older", + isFavorite: true, + theyFavoritedUs: false, + favoritedAt: Date(timeIntervalSince1970: 100), + lastUpdated: Date(timeIntervalSince1970: 100) + ) + let newer = FavoritesPersistenceService.FavoriteRelationship( + peerNoisePublicKey: peerKey, + peerNostrPublicKey: "npub1newer", + peerNickname: "Newer", + isFavorite: true, + theyFavoritedUs: true, + favoritedAt: Date(timeIntervalSince1970: 100), + lastUpdated: Date(timeIntervalSince1970: 200) + ) + let encoded = try JSONEncoder().encode([older, newer]) + keychain.save(key: storageKey, data: encoded, service: serviceKey, accessible: nil) + + let service = FavoritesPersistenceService(keychain: keychain) + + XCTAssertEqual(service.favorites.count, 1) + XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Newer") + XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNostrPublicKey, "npub1newer") + + let cleaned = try XCTUnwrap(keychain.load(key: storageKey, service: serviceKey)) + let decoded = try JSONDecoder().decode([FavoritesPersistenceService.FavoriteRelationship].self, from: cleaned) + XCTAssertEqual(decoded.count, 1) + } +} diff --git a/bitchatTests/Services/GeohashPresenceServiceTests.swift b/bitchatTests/Services/GeohashPresenceServiceTests.swift new file mode 100644 index 00000000..f6be1f25 --- /dev/null +++ b/bitchatTests/Services/GeohashPresenceServiceTests.swift @@ -0,0 +1,237 @@ +import Combine +import XCTest +@testable import bitchat + +@MainActor +final class GeohashPresenceServiceTests: XCTestCase { + func test_start_schedulesHeartbeatUsingConfiguredInterval() { + let scheduler = MockGeohashPresenceScheduler() + let service = makeService(scheduler: scheduler, loopMinInterval: 42, loopMaxInterval: 42) + + service.start() + + XCTAssertEqual(scheduler.intervals, [42]) + } + + func test_handleLocationChange_invalidatesExistingTimerAndSchedulesQuickRefresh() { + let scheduler = MockGeohashPresenceScheduler() + let service = makeService(scheduler: scheduler, loopMinInterval: 40, loopMaxInterval: 40) + + service.start() + let originalTimer = scheduler.timers.first + + service.handleLocationChange() + + XCTAssertEqual(scheduler.intervals, [40, 5]) + XCTAssertEqual(originalTimer?.invalidateCallCount, 1) + } + + func test_handleConnectivityChange_onlySchedulesWhenExistingTimerIsMissingOrInvalid() { + let scheduler = MockGeohashPresenceScheduler() + let service = makeService(scheduler: scheduler, loopMinInterval: 33, loopMaxInterval: 33) + + service.start() + service.handleConnectivityChange() + XCTAssertEqual(scheduler.intervals, [33]) + + scheduler.timers.last?.invalidate() + service.handleConnectivityChange() + XCTAssertEqual(scheduler.intervals, [33, 33]) + } + + func test_performHeartbeat_broadcastsOnlyAllowedPrecisionChannels() async throws { + let identity = try NostrIdentity.generate() + let scheduler = MockGeohashPresenceScheduler() + var sentGeohashes: [String] = [] + var lookedUpGeohashes: [String] = [] + var sleptNanoseconds: [UInt64] = [] + let channels = [ + GeohashChannel(level: .region, geohash: "9q"), + GeohashChannel(level: .province, geohash: "9q8y"), + GeohashChannel(level: .city, geohash: "9q8yy"), + GeohashChannel(level: .neighborhood, geohash: "9q8yyk"), + GeohashChannel(level: .block, geohash: "9q8yyk8"), + GeohashChannel(level: .building, geohash: "9q8yyk8y") + ] + let service = makeService( + scheduler: scheduler, + availableChannels: channels, + deriveIdentity: { _ in identity }, + relayLookup: { geohash, _ in + lookedUpGeohashes.append(geohash) + return ["wss://\(geohash).example"] + }, + relaySender: { event, _ in + let geohash = event.tags.first(where: { $0.first == "g" })?[1] + if let geohash { + sentGeohashes.append(geohash) + } + }, + sleeper: { nanoseconds in + sleptNanoseconds.append(nanoseconds) + }, + loopMinInterval: 17, + loopMaxInterval: 17, + burstMinDelay: 0, + burstMaxDelay: 0 + ) + + service.performHeartbeat() + + let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 } + XCTAssertTrue(sentAllAllowedChannels) + XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"])) + XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"])) + XCTAssertEqual(sleptNanoseconds.count, 3) + XCTAssertEqual(scheduler.intervals, [17]) + } + + func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async { + let scheduler = MockGeohashPresenceScheduler() + var sendCount = 0 + let service = makeService( + scheduler: scheduler, + torIsReady: { false }, + relaySender: { _, _ in sendCount += 1 }, + loopMinInterval: 21, + loopMaxInterval: 21 + ) + + service.performHeartbeat() + try? await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertEqual(sendCount, 0) + XCTAssertEqual(scheduler.intervals, [21]) + } + + func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async { + let scheduler = MockGeohashPresenceScheduler() + var sendCount = 0 + let service = makeService( + scheduler: scheduler, + torIsForeground: { false }, + relaySender: { _, _ in sendCount += 1 }, + loopMinInterval: 22, + loopMaxInterval: 22 + ) + + service.performHeartbeat() + try? await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertEqual(sendCount, 0) + XCTAssertEqual(scheduler.intervals, [22]) + } + + func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws { + let identity = try NostrIdentity.generate() + var sendCount = 0 + let service = makeService( + scheduler: MockGeohashPresenceScheduler(), + deriveIdentity: { _ in identity }, + relayLookup: { _, _ in [] }, + relaySender: { _, _ in sendCount += 1 } + ) + + service.broadcastPresence(for: "9q8yy") + try? await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertEqual(sendCount, 0) + } + + func test_broadcastPresence_skipsSendWhenIdentityDerivationFails() async { + enum PresenceError: Error { case failed } + + var sendCount = 0 + let service = makeService( + scheduler: MockGeohashPresenceScheduler(), + deriveIdentity: { _ in throw PresenceError.failed }, + relaySender: { _, _ in sendCount += 1 } + ) + + service.broadcastPresence(for: "9q8yy") + try? await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertEqual(sendCount, 0) + } + + private func makeService( + scheduler: MockGeohashPresenceScheduler, + availableChannels: [GeohashChannel] = [ + GeohashChannel(level: .city, geohash: "9q8yy") + ], + torIsReady: @escaping () -> Bool = { true }, + torIsForeground: @escaping () -> Bool = { true }, + deriveIdentity: @escaping (String) throws -> NostrIdentity = { _ in try NostrIdentity.generate() }, + relayLookup: @escaping (String, Int) -> [String] = { geohash, _ in ["wss://\(geohash).example"] }, + relaySender: @escaping (NostrEvent, [String]) -> Void = { _, _ in }, + sleeper: @escaping (UInt64) async -> Void = { _ in }, + loopMinInterval: TimeInterval = 40, + loopMaxInterval: TimeInterval = 40, + burstMinDelay: TimeInterval = 0, + burstMaxDelay: TimeInterval = 0 + ) -> GeohashPresenceService { + let locationSubject = PassthroughSubject<[GeohashChannel], Never>() + let torReadySubject = PassthroughSubject() + return GeohashPresenceService( + availableChannelsProvider: { availableChannels }, + locationChanges: locationSubject.eraseToAnyPublisher(), + torReadyPublisher: torReadySubject.eraseToAnyPublisher(), + torIsReady: torIsReady, + torIsForeground: torIsForeground, + deriveIdentity: deriveIdentity, + relayLookup: relayLookup, + relaySender: relaySender, + sleeper: sleeper, + scheduleTimer: scheduler.schedule(interval:handler:), + loopMinInterval: loopMinInterval, + loopMaxInterval: loopMaxInterval, + burstMinDelay: burstMinDelay, + burstMaxDelay: burstMaxDelay + ) + } + + private func waitUntil( + timeout: TimeInterval = 1.0, + condition: @escaping @MainActor () -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return true + } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return condition() + } +} + +private final class MockGeohashPresenceScheduler { + private(set) var intervals: [TimeInterval] = [] + private(set) var timers: [MockGeohashPresenceTimer] = [] + + func schedule(interval: TimeInterval, handler: @escaping () -> Void) -> GeohashPresenceTimerProtocol { + intervals.append(interval) + let timer = MockGeohashPresenceTimer(handler: handler) + timers.append(timer) + return timer + } +} + +private final class MockGeohashPresenceTimer: GeohashPresenceTimerProtocol { + private let handler: () -> Void + private(set) var isValid = true + private(set) var invalidateCallCount = 0 + + init(handler: @escaping () -> Void) { + self.handler = handler + } + + func invalidate() { + invalidateCallCount += 1 + isValid = false + } + + func fire() { + handler() + } +} diff --git a/bitchatTests/Services/LocationStateManagerTests.swift b/bitchatTests/Services/LocationStateManagerTests.swift new file mode 100644 index 00000000..c7a950f0 --- /dev/null +++ b/bitchatTests/Services/LocationStateManagerTests.swift @@ -0,0 +1,304 @@ +import CoreLocation +import MapKit +import XCTest +@testable import bitchat + +@MainActor +final class LocationStateManagerTests: XCTestCase { + func test_loadPersistedState_normalizesBookmarksAndRestoresTeleportedSelection() async throws { + let storage = makeStorage() + let selected = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pru")) + storage.set(try JSONEncoder().encode(selected), forKey: "locationChannel.selected") + storage.set(try JSONEncoder().encode(["u4pru"]), forKey: "locationChannel.teleportedSet") + storage.set(try JSONEncoder().encode(["#U4PRU", "u4pru", ""]), forKey: "locationChannel.bookmarks") + + let manager = LocationStateManager( + storage: storage, + locationManager: MockLocationManager(authorizationStatus: .denied), + geocoder: MockLocationGeocoder(), + shouldInitializeCoreLocation: true + ) + + let deniedLoaded = await waitUntil { manager.permissionState == .denied } + XCTAssertTrue(deniedLoaded) + XCTAssertEqual(manager.bookmarks, ["u4pru"]) + XCTAssertEqual(manager.selectedChannel, selected) + let teleportedLoaded = await waitUntil { manager.teleported } + XCTAssertTrue(teleportedLoaded) + } + + func test_enableLocationChannels_requestsAuthorizationWhenStatusIsUndetermined() { + let locationManager = MockLocationManager(authorizationStatus: .notDetermined) + let manager = LocationStateManager( + storage: makeStorage(), + locationManager: locationManager, + geocoder: MockLocationGeocoder(), + shouldInitializeCoreLocation: true + ) + + manager.enableLocationChannels() + + XCTAssertEqual(locationManager.requestAuthorizationCallCount, 1) + XCTAssertEqual(locationManager.requestLocationCallCount, 0) + } + + func test_enableLocationChannels_requestsOneShotLocationWhenAuthorized() async { + let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways) + let manager = LocationStateManager( + storage: makeStorage(), + locationManager: locationManager, + geocoder: MockLocationGeocoder(), + shouldInitializeCoreLocation: true + ) + + let authorizedLoaded = await waitUntil { manager.permissionState == .authorized } + XCTAssertTrue(authorizedLoaded) + + manager.enableLocationChannels() + + XCTAssertEqual(locationManager.requestLocationCallCount, 1) + XCTAssertEqual(manager.permissionState, .authorized) + } + + func test_beginAndEndLiveRefresh_adjustLocationManagerMode() async { + let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways) + let manager = LocationStateManager( + storage: makeStorage(), + locationManager: locationManager, + geocoder: MockLocationGeocoder(), + shouldInitializeCoreLocation: true + ) + + let authorizedLoaded = await waitUntil { manager.permissionState == .authorized } + XCTAssertTrue(authorizedLoaded) + + manager.beginLiveRefresh() + + XCTAssertEqual(locationManager.startUpdatingLocationCallCount, 1) + XCTAssertEqual(locationManager.requestLocationCallCount, 1) + XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyNearestTenMeters) + XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterLiveMeters) + + manager.endLiveRefresh() + + XCTAssertEqual(locationManager.stopUpdatingLocationCallCount, 1) + XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyHundredMeters) + XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterMeters) + } + + func test_didUpdateLocations_computesChannelsAndReverseGeocodesFriendlyNames() async { + let geocoder = MockLocationGeocoder() + geocoder.enqueue( + placemarks: [ + makePlacemark( + country: "United States", + administrativeArea: "Hawaii", + locality: "Honolulu", + subLocality: "Waikiki", + name: "Hilton Hawaiian Village" + ) + ] + ) + let manager = LocationStateManager( + storage: makeStorage(), + locationManager: MockLocationManager(authorizationStatus: .authorizedAlways), + geocoder: geocoder, + shouldInitializeCoreLocation: true + ) + let location = CLLocation(latitude: 21.2850, longitude: -157.8357) + + manager.locationManager(CLLocationManager(), didUpdateLocations: [location]) + + let channelsAndNamesLoaded = await waitUntil { + manager.availableChannels.count == GeohashChannelLevel.allCases.count && + manager.locationNames[.city] == "Honolulu" && + manager.locationNames[.building] == "Hilton Hawaiian Village" + } + XCTAssertTrue(channelsAndNamesLoaded) + XCTAssertEqual(geocoder.cancelCallCount, 1) + XCTAssertEqual(geocoder.reverseRequests.count, 1) + XCTAssertEqual(manager.availableChannels.map(\.geohash.count), GeohashChannelLevel.allCases.map(\.precision)) + XCTAssertEqual(manager.locationNames[.region], "United States") + XCTAssertEqual(manager.locationNames[.province], "Hawaii") + XCTAssertEqual(manager.locationNames[.city], "Honolulu") + XCTAssertEqual(manager.locationNames[.neighborhood], "Waikiki") + XCTAssertEqual(manager.locationNames[.block], "Waikiki") + XCTAssertEqual(manager.locationNames[.building], "Hilton Hawaiian Village") + } + + func test_selectingInRegionChannel_clearsTeleportedPersistence() async { + let storage = makeStorage() + let manager = LocationStateManager( + storage: storage, + locationManager: MockLocationManager(authorizationStatus: .authorizedAlways), + geocoder: MockLocationGeocoder(), + shouldInitializeCoreLocation: true + ) + let coordinate = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) + let cityGeohash = Geohash.encode( + latitude: coordinate.latitude, + longitude: coordinate.longitude, + precision: GeohashChannelLevel.city.precision + ) + let channel = GeohashChannel(level: .city, geohash: cityGeohash) + + manager.locationManager(CLLocationManager(), didUpdateLocations: [CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)]) + let channelAvailable = await waitUntil { manager.availableChannels.contains(channel) } + XCTAssertTrue(channelAvailable) + + manager.markTeleported(for: cityGeohash, true) + manager.select(.location(channel)) + + let selectionSettled = await waitUntil { + manager.selectedChannel == .location(channel) && !manager.teleported + } + XCTAssertTrue(selectionSettled) + + let reloaded = LocationStateManager( + storage: storage, + locationManager: MockLocationManager(authorizationStatus: .denied), + geocoder: MockLocationGeocoder(), + shouldInitializeCoreLocation: true + ) + + let reloadedDenied = await waitUntil { reloaded.permissionState == .denied } + XCTAssertTrue(reloadedDenied) + XCTAssertEqual(reloaded.selectedChannel, .location(channel)) + XCTAssertFalse(reloaded.teleported) + } + + func test_addBookmark_lowPrecisionResolvesCompositeAdminName() async { + let geocoder = MockLocationGeocoder() + geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")]) + geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Nevada")]) + geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")]) + geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Arizona")]) + geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Nevada")]) + let manager = LocationStateManager( + storage: makeStorage(), + locationManager: MockLocationManager(authorizationStatus: .denied), + geocoder: geocoder, + shouldInitializeCoreLocation: false + ) + + manager.addBookmark("9q") + + let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q"] == "California and Nevada" } + XCTAssertTrue(bookmarkResolved) + XCTAssertEqual(geocoder.reverseRequests.count, 5) + XCTAssertEqual(manager.bookmarks, ["9q"]) + } + + private func makeStorage() -> UserDefaults { + let suiteName = "LocationStateManagerTests-\(UUID().uuidString)" + let storage = UserDefaults(suiteName: suiteName)! + storage.removePersistentDomain(forName: suiteName) + addTeardownBlock { + storage.removePersistentDomain(forName: suiteName) + } + return storage + } + + private func makePlacemark( + country: String? = nil, + administrativeArea: String? = nil, + locality: String? = nil, + subLocality: String? = nil, + name: String? = nil + ) -> CLPlacemark { + var address: [String: Any] = [:] + if let country { + address["Country"] = country + } + if let administrativeArea { + address["State"] = administrativeArea + } + if let locality { + address["City"] = locality + } + if let subLocality { + address["SubLocality"] = subLocality + } + if let name { + address["Name"] = name + } + let placemark = MKPlacemark( + coordinate: CLLocationCoordinate2D(latitude: 21.2850, longitude: -157.8357), + addressDictionary: address + ) + return CLPlacemark(placemark: placemark) + } + + private func waitUntil( + timeout: TimeInterval = 1.0, + condition: @escaping @MainActor () -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return true + } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return condition() + } +} + +private final class MockLocationManager: LocationStateManaging { + weak var delegate: CLLocationManagerDelegate? + var desiredAccuracy: CLLocationAccuracy = 0 + var distanceFilter: CLLocationDistance = 0 + var authorizationStatus: CLAuthorizationStatus + private(set) var requestAuthorizationCallCount = 0 + private(set) var requestLocationCallCount = 0 + private(set) var startUpdatingLocationCallCount = 0 + private(set) var stopUpdatingLocationCallCount = 0 + + init(authorizationStatus: CLAuthorizationStatus) { + self.authorizationStatus = authorizationStatus + } + + func requestWhenInUseAuthorization() { + requestAuthorizationCallCount += 1 + } + + func requestLocation() { + requestLocationCallCount += 1 + } + + func startUpdatingLocation() { + startUpdatingLocationCallCount += 1 + } + + func stopUpdatingLocation() { + stopUpdatingLocationCallCount += 1 + } +} + +private final class MockLocationGeocoder: LocationStateGeocoding { + private struct Response { + let placemarks: [CLPlacemark]? + let error: Error? + } + + private(set) var cancelCallCount = 0 + private(set) var reverseRequests: [CLLocation] = [] + private var responses: [Response] = [] + + func enqueue(placemarks: [CLPlacemark]?, error: Error? = nil) { + responses.append(Response(placemarks: placemarks, error: error)) + } + + func cancelGeocode() { + cancelCallCount += 1 + } + + func reverseGeocodeLocation( + _ location: CLLocation, + completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void + ) { + reverseRequests.append(location) + let response = responses.isEmpty ? Response(placemarks: nil, error: nil) : responses.removeFirst() + completionHandler(response.placemarks, response.error) + } +} diff --git a/bitchatTests/Services/NetworkActivationServiceTests.swift b/bitchatTests/Services/NetworkActivationServiceTests.swift new file mode 100644 index 00000000..5523ccdc --- /dev/null +++ b/bitchatTests/Services/NetworkActivationServiceTests.swift @@ -0,0 +1,197 @@ +import Combine +import XCTest +@testable import bitchat + +@MainActor +final class NetworkActivationServiceTests: XCTestCase { + private let torPreferenceKey = "networkActivationService.userTorEnabled" + + func test_start_leavesNetworkDisabledWithoutPermissionOrFavorites() { + let context = makeService(permission: .denied, favorites: []) + + context.service.start() + + XCTAssertFalse(context.service.activationAllowed) + XCTAssertEqual(context.torController.autoStartAllowedValues, [false]) + XCTAssertEqual(context.proxyController.proxyModes, [false]) + XCTAssertEqual(context.torController.startIfNeededCallCount, 0) + XCTAssertEqual(context.torController.shutdownCompletelyCallCount, 1) + XCTAssertEqual(context.relayController.connectCallCount, 0) + XCTAssertEqual(context.relayController.disconnectCallCount, 1) + } + + func test_start_enablesTorAndRelaysWhenAuthorized() { + let context = makeService(permission: .authorized, favorites: []) + + context.service.start() + + XCTAssertTrue(context.service.activationAllowed) + XCTAssertEqual(context.torController.autoStartAllowedValues, [true]) + XCTAssertEqual(context.proxyController.proxyModes, [true]) + XCTAssertEqual(context.torController.startIfNeededCallCount, 1) + XCTAssertEqual(context.relayController.connectCallCount, 1) + XCTAssertEqual(context.relayController.disconnectCallCount, 0) + } + + func test_start_respectsStoredTorPreferenceForDirectMode() { + let context = makeService(permission: .authorized, favorites: []) + context.storage.set(false, forKey: torPreferenceKey) + + context.service.start() + + XCTAssertTrue(context.service.activationAllowed) + XCTAssertFalse(context.service.userTorEnabled) + XCTAssertEqual(context.torController.autoStartAllowedValues, [false]) + XCTAssertEqual(context.proxyController.proxyModes, [false]) + XCTAssertEqual(context.torController.startIfNeededCallCount, 0) + XCTAssertEqual(context.torController.shutdownCompletelyCallCount, 1) + XCTAssertEqual(context.relayController.connectCallCount, 1) + } + + func test_setUserTorEnabled_postsNotificationAndReconnectsOnTransportSwitch() { + let context = makeService(permission: .authorized, favorites: []) + let notified = expectation(description: "Tor preference notification") + let token = context.notificationCenter.addObserver( + forName: .TorUserPreferenceChanged, + object: nil, + queue: nil + ) { note in + XCTAssertEqual(note.userInfo?["enabled"] as? Bool, false) + notified.fulfill() + } + + context.service.start() + context.service.setUserTorEnabled(false) + + wait(for: [notified], timeout: 1.0) + context.notificationCenter.removeObserver(token) + + XCTAssertFalse(context.service.userTorEnabled) + XCTAssertEqual(context.storage.object(forKey: torPreferenceKey) as? Bool, false) + XCTAssertEqual(Array(context.proxyController.proxyModes.suffix(2)), [true, false]) + XCTAssertEqual(Array(context.torController.autoStartAllowedValues.suffix(2)), [true, false]) + XCTAssertEqual(context.relayController.disconnectCallCount, 1) + XCTAssertEqual(context.relayController.connectCallCount, 2) + } + + func test_mutualFavoritesPublisher_reactivatesNetwork() async { + let context = makeService(permission: .denied, favorites: []) + + context.service.start() + XCTAssertFalse(context.service.activationAllowed) + + context.favoritesSubject.send([Data([0x01])]) + let becameActive = await waitUntil { context.service.activationAllowed } + XCTAssertTrue(becameActive) + + XCTAssertTrue(context.service.activationAllowed) + XCTAssertTrue(context.torController.autoStartAllowedValues.contains(true)) + XCTAssertTrue(context.proxyController.proxyModes.contains(true)) + XCTAssertGreaterThanOrEqual(context.torController.startIfNeededCallCount, 1) + XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1) + } + + private func makeService( + permission: LocationChannelManager.PermissionState, + favorites: Set + ) -> NetworkActivationTestContext { + let suiteName = "NetworkActivationServiceTests-\(UUID().uuidString)" + let storage = UserDefaults(suiteName: suiteName)! + storage.removePersistentDomain(forName: suiteName) + + let permissionSubject = CurrentValueSubject(permission) + let favoritesSubject = CurrentValueSubject, Never>(favorites) + let torController = MockNetworkActivationTorController() + let relayController = MockNetworkActivationRelayController() + let proxyController = MockNetworkActivationProxyController() + let notificationCenter = NotificationCenter() + let service = NetworkActivationService( + storage: storage, + locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(), + mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(), + permissionProvider: { permissionSubject.value }, + mutualFavoritesProvider: { favoritesSubject.value }, + torController: torController, + relayController: relayController, + proxyController: proxyController, + notificationCenter: notificationCenter + ) + return NetworkActivationTestContext( + service: service, + storage: storage, + permissionSubject: permissionSubject, + favoritesSubject: favoritesSubject, + torController: torController, + relayController: relayController, + proxyController: proxyController, + notificationCenter: notificationCenter + ) + } + + private func waitUntil( + timeout: TimeInterval = 1.0, + condition: @escaping @MainActor () -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return true + } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return condition() + } +} + +@MainActor +private struct NetworkActivationTestContext { + let service: NetworkActivationService + let storage: UserDefaults + let permissionSubject: CurrentValueSubject + let favoritesSubject: CurrentValueSubject, Never> + let torController: MockNetworkActivationTorController + let relayController: MockNetworkActivationRelayController + let proxyController: MockNetworkActivationProxyController + let notificationCenter: NotificationCenter +} + +@MainActor +private final class MockNetworkActivationTorController: NetworkActivationTorControlling { + private(set) var autoStartAllowedValues: [Bool] = [] + private(set) var startIfNeededCallCount = 0 + private(set) var shutdownCompletelyCallCount = 0 + + func setAutoStartAllowed(_ allowed: Bool) { + autoStartAllowedValues.append(allowed) + } + + func startIfNeeded() { + startIfNeededCallCount += 1 + } + + func shutdownCompletely() { + shutdownCompletelyCallCount += 1 + } +} + +@MainActor +private final class MockNetworkActivationRelayController: NetworkActivationRelayControlling { + private(set) var connectCallCount = 0 + private(set) var disconnectCallCount = 0 + + func connect() { + connectCallCount += 1 + } + + func disconnect() { + disconnectCallCount += 1 + } +} + +private final class MockNetworkActivationProxyController: NetworkActivationProxyControlling { + private(set) var proxyModes: [Bool] = [] + + func setProxyMode(useTor: Bool) { + proxyModes.append(useTor) + } +} diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift new file mode 100644 index 00000000..100756a9 --- /dev/null +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -0,0 +1,441 @@ +import Combine +import XCTest +@testable import bitchat + +@MainActor +final class NostrRelayManagerTests: XCTestCase { + func test_permissionPublisher_addsAndRemovesDefaultRelays() async { + let context = makeContext(permission: .denied, favorites: []) + + XCTAssertEqual(context.manager.getRelayStatuses().count, 0) + + context.permissionSubject.send(.authorized) + + let defaultRelaysConnected = await waitUntil { + context.manager.getRelayStatuses().count == 5 && + context.manager.relays.allSatisfy(\.isConnected) + } + XCTAssertTrue(defaultRelaysConnected) + + context.permissionSubject.send(.denied) + + let defaultRelaysRemoved = await waitUntil { + context.manager.getRelayStatuses().isEmpty + } + XCTAssertTrue(defaultRelaysRemoved) + XCTAssertEqual(context.sessionFactory.allConnections.count, 5) + XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.cancelCallCount >= 1 }) + } + + func test_connect_waitsForTorReadinessBeforeCreatingSessions() async { + let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false) + + context.manager.connect() + + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + + context.torWaiter.resolve(true) + + let connectedAfterTorReady = await waitUntil { + context.sessionFactory.requestedURLs.count == 5 && + context.manager.relays.allSatisfy(\.isConnected) + } + XCTAssertTrue(connectedAfterTorReady) + } + + func test_connect_doesNothingWhenActivationIsDisallowed() { + let context = makeContext(permission: .authorized, activationAllowed: false) + + context.manager.connect() + + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + XCTAssertFalse(context.manager.isConnected) + } + + func test_ensureConnections_deduplicatesRelayURLs() async { + let relayOne = "wss://relay-one.example" + let relayTwo = "wss://relay-two.example" + let context = makeContext(permission: .denied) + + context.manager.ensureConnections(to: [relayOne, relayOne, relayTwo]) + + let connected = await waitUntil { + Set(context.manager.getRelayStatuses().map(\.url)) == Set([relayOne, relayTwo]) && + context.manager.relays.allSatisfy(\.isConnected) + } + XCTAssertTrue(connected) + XCTAssertEqual(context.sessionFactory.requestedURLs, [relayOne, relayTwo]) + } + + func test_subscribe_coalescesRapidDuplicateRequests() async { + let relayURL = "wss://subscribe.example" + let context = makeContext(permission: .denied) + let filter = makeFilter() + + context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in }) + + let firstSent = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 + } + XCTAssertTrue(firstSent) + + context.clock.now = context.clock.now.addingTimeInterval(0.5) + context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in }) + + XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1) + } + + func test_unsubscribe_allowsResubscribeWithSameID() async { + let relayURL = "wss://subscribe.example" + let context = makeContext(permission: .denied) + let filter = makeFilter() + + context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in }) + let initialSubscribeSent = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 + } + XCTAssertTrue(initialSubscribeSent) + + context.manager.unsubscribe(id: "sub") + let closeSent = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 2 + } + XCTAssertTrue(closeSent) + + context.clock.now = context.clock.now.addingTimeInterval(0.2) + context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in }) + + let resubscribed = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 3 + } + XCTAssertTrue(resubscribed) + } + + func test_receiveEvent_deliversHandlerAndTracksReceivedCount() async throws { + let relayURL = "wss://events.example" + let context = makeContext(permission: .denied) + let filter = makeFilter() + let event = try makeSignedEvent(content: "hello") + var receivedEvent: NostrEvent? + + context.manager.subscribe(filter: filter, id: "events", relayUrls: [relayURL]) { event in + receivedEvent = event + } + let subscriptionSent = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 + } + XCTAssertTrue(subscriptionSent) + + try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "events", event: event) + + let delivered = await waitUntil { + receivedEvent?.id == event.id && + context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1 + } + XCTAssertTrue(delivered) + XCTAssertEqual(receivedEvent?.id, event.id) + } + + func test_eoseCallback_waitsForAllTargetedRelays() async throws { + let relayOne = "wss://one.example" + let relayTwo = "wss://two.example" + let context = makeContext(permission: .denied) + var eoseCount = 0 + + context.manager.subscribe( + filter: makeFilter(), + id: "eose", + relayUrls: [relayOne, relayTwo], + handler: { _ in }, + onEOSE: { eoseCount += 1 } + ) + + let bothConnected = await waitUntil { + context.sessionFactory.latestConnection(for: relayOne)?.sentStrings.count == 1 && + context.sessionFactory.latestConnection(for: relayTwo)?.sentStrings.count == 1 + } + XCTAssertTrue(bothConnected) + + try context.sessionFactory.latestConnection(for: relayOne)?.emitEOSE(subscriptionID: "eose") + try? await Task.sleep(nanoseconds: 20_000_000) + XCTAssertEqual(eoseCount, 0) + + try context.sessionFactory.latestConnection(for: relayTwo)?.emitEOSE(subscriptionID: "eose") + + let eoseCompleted = await waitUntil { eoseCount == 1 } + XCTAssertTrue(eoseCompleted) + } + + func test_receiveFailure_schedulesReconnectWithBackoff() async { + let relayURL = "wss://retry.example" + let context = makeContext(permission: .denied) + + context.manager.ensureConnections(to: [relayURL]) + let firstConnected = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL) != nil + } + XCTAssertTrue(firstConnected) + + let firstConnection = context.sessionFactory.latestConnection(for: relayURL) + firstConnection?.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)) + + let retryScheduled = await waitUntil { + context.scheduler.scheduled.count == 1 && + context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 1 + } + XCTAssertTrue(retryScheduled) + XCTAssertEqual(context.scheduler.scheduled.first?.delay, TransportConfig.nostrRelayInitialBackoffSeconds) + + let initialRequestCount = context.sessionFactory.requestedURLs.count + context.scheduler.runNext() + + let retried = await waitUntil { + context.sessionFactory.requestedURLs.count == initialRequestCount + 1 + } + XCTAssertTrue(retried) + } + + func test_disconnect_invalidatesScheduledReconnectGeneration() async { + let relayURL = "wss://disconnect.example" + let context = makeContext(permission: .denied) + + context.manager.ensureConnections(to: [relayURL]) + let firstConnected = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL) != nil + } + XCTAssertTrue(firstConnected) + + context.sessionFactory.latestConnection(for: relayURL)?.fail( + error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + ) + let retryScheduled = await waitUntil { context.scheduler.scheduled.count == 1 } + XCTAssertTrue(retryScheduled) + + let requestCountBeforeDisconnect = context.sessionFactory.requestedURLs.count + context.manager.disconnect() + context.scheduler.runNext() + try? await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertEqual(context.sessionFactory.requestedURLs.count, requestCountBeforeDisconnect) + } + + private func makeContext( + permission: LocationChannelManager.PermissionState, + favorites: Set = [], + activationAllowed: Bool = true, + userTorEnabled: Bool = false, + torEnforced: Bool = false, + torIsReady: Bool = true + ) -> RelayManagerTestContext { + let permissionSubject = CurrentValueSubject(permission) + let favoritesSubject = CurrentValueSubject, Never>(favorites) + let sessionFactory = MockRelaySessionFactory() + let scheduler = MockRelayScheduler() + let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000)) + let torWaiter = MockTorWaiter(isReady: torIsReady) + let manager = NostrRelayManager( + dependencies: NostrRelayManagerDependencies( + activationAllowed: { activationAllowed }, + userTorEnabled: { userTorEnabled }, + hasMutualFavorites: { !favoritesSubject.value.isEmpty }, + hasLocationPermission: { permissionSubject.value == .authorized }, + mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(), + locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(), + torEnforced: { torEnforced }, + torIsReady: { torWaiter.isReady }, + torIsForeground: { true }, + awaitTorReady: torWaiter.await(completion:), + makeSession: { sessionFactory }, + scheduleAfter: scheduler.schedule(delay:action:), + now: { clock.now } + ) + ) + return RelayManagerTestContext( + manager: manager, + permissionSubject: permissionSubject, + favoritesSubject: favoritesSubject, + sessionFactory: sessionFactory, + scheduler: scheduler, + clock: clock, + torWaiter: torWaiter + ) + } + + private func makeFilter() -> NostrFilter { + var filter = NostrFilter() + filter.kinds = [NostrProtocol.EventKind.textNote.rawValue] + filter.limit = 10 + return filter + } + + private func makeSignedEvent(content: String) throws -> NostrEvent { + let identity = try NostrIdentity.generate() + let event = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(), + kind: .textNote, + tags: [], + content: content + ) + return try event.sign(with: identity.schnorrSigningKey()) + } + + private func waitUntil( + timeout: TimeInterval = 1.0, + condition: @escaping @MainActor () -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return true + } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return condition() + } +} + +@MainActor +private struct RelayManagerTestContext { + let manager: NostrRelayManager + let permissionSubject: CurrentValueSubject + let favoritesSubject: CurrentValueSubject, Never> + let sessionFactory: MockRelaySessionFactory + let scheduler: MockRelayScheduler + let clock: MutableClock + let torWaiter: MockTorWaiter +} + +private final class MutableClock { + var now: Date + + init(now: Date) { + self.now = now + } +} + +private final class MockTorWaiter { + private var completions: [(Bool) -> Void] = [] + var isReady: Bool + + init(isReady: Bool) { + self.isReady = isReady + } + + func await(completion: @escaping (Bool) -> Void) { + completions.append(completion) + } + + func resolve(_ ready: Bool) { + isReady = ready + let pending = completions + completions.removeAll() + pending.forEach { $0(ready) } + } +} + +private final class MockRelayScheduler { + struct ScheduledAction { + let delay: TimeInterval + let action: () -> Void + } + + private(set) var scheduled: [ScheduledAction] = [] + + func schedule(delay: TimeInterval, action: @escaping () -> Void) { + scheduled.append(ScheduledAction(delay: delay, action: action)) + } + + func runNext() { + guard !scheduled.isEmpty else { return } + let next = scheduled.removeFirst() + next.action() + } +} + +private final class MockRelaySessionFactory: NostrRelaySessionProtocol { + private(set) var requestedURLs: [String] = [] + private(set) var connectionsByURL: [String: [MockRelayConnection]] = [:] + + var allConnections: [MockRelayConnection] { + connectionsByURL.values.flatMap { $0 } + } + + func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol { + requestedURLs.append(url.absoluteString) + let connection = MockRelayConnection(url: url.absoluteString) + connectionsByURL[url.absoluteString, default: []].append(connection) + return connection + } + + func latestConnection(for url: String) -> MockRelayConnection? { + connectionsByURL[url]?.last + } +} + +private final class MockRelayConnection: NostrRelayConnectionProtocol { + private let url: String + private var receiveHandler: ((Result) -> Void)? + private(set) var resumeCallCount = 0 + private(set) var cancelCallCount = 0 + private(set) var sentMessages: [URLSessionWebSocketTask.Message] = [] + + var sentStrings: [String] { + sentMessages.compactMap { + switch $0 { + case .string(let string): string + case .data(let data): String(data: data, encoding: .utf8) + @unknown default: nil + } + } + } + + init(url: String) { + self.url = url + } + + func resume() { + resumeCallCount += 1 + } + + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + cancelCallCount += 1 + } + + func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) { + sentMessages.append(message) + completionHandler(nil) + } + + func receive(completionHandler: @escaping (Result) -> Void) { + receiveHandler = completionHandler + } + + func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) { + pongReceiveHandler(nil) + } + + func fail(error: Error) { + let handler = receiveHandler + receiveHandler = nil + handler?(.failure(error)) + } + + func emitEventMessage(subscriptionID: String, event: NostrEvent) throws { + let eventData = try JSONEncoder().encode(event) + let eventJSONObject = try JSONSerialization.jsonObject(with: eventData) as! [String: Any] + let payload: [Any] = ["EVENT", subscriptionID, eventJSONObject] + try emit(jsonObject: payload) + } + + func emitEOSE(subscriptionID: String) throws { + try emit(jsonObject: ["EOSE", subscriptionID]) + } + + private func emit(jsonObject: Any) throws { + let data = try JSONSerialization.data(withJSONObject: jsonObject) + let handler = receiveHandler + receiveHandler = nil + handler?(.success(.data(data))) + } +} diff --git a/bitchatTests/Services/NotificationServiceTests.swift b/bitchatTests/Services/NotificationServiceTests.swift new file mode 100644 index 00000000..7a60715c --- /dev/null +++ b/bitchatTests/Services/NotificationServiceTests.swift @@ -0,0 +1,122 @@ +import XCTest +import UserNotifications +@testable import bitchat + +final class NotificationServiceTests: XCTestCase { + func test_requestAuthorization_skipsWhenRunningTests() { + let authorizer = RecordingNotificationAuthorizer() + let service = NotificationService( + isRunningTestsProvider: { true }, + authorizer: authorizer, + requestDeliverer: RecordingNotificationRequestDeliverer() + ) + + service.requestAuthorization() + + XCTAssertEqual(authorizer.requestCallCount, 0) + } + + func test_requestAuthorization_requestsAlertSoundAndBadgePermissions() { + let authorizer = RecordingNotificationAuthorizer() + let service = NotificationService( + isRunningTestsProvider: { false }, + authorizer: authorizer, + requestDeliverer: RecordingNotificationRequestDeliverer() + ) + + service.requestAuthorization() + + XCTAssertEqual(authorizer.requestCallCount, 1) + XCTAssertEqual(authorizer.lastOptions, [.alert, .sound, .badge]) + } + + func test_sendLocalNotification_buildsImmediateRequestWithUserInfo() { + let deliverer = RecordingNotificationRequestDeliverer() + let service = NotificationService( + isRunningTestsProvider: { false }, + authorizer: RecordingNotificationAuthorizer(), + requestDeliverer: deliverer + ) + + service.sendLocalNotification( + title: "Hello", + body: "World", + identifier: "custom-id", + userInfo: ["peerID": "abcd"], + interruptionLevel: .timeSensitive + ) + + let request = deliverer.requests.singleValue + XCTAssertEqual(request?.identifier, "custom-id") + XCTAssertEqual(request?.content.title, "Hello") + XCTAssertEqual(request?.content.body, "World") + XCTAssertEqual(request?.content.userInfo["peerID"] as? String, "abcd") + XCTAssertEqual(request?.content.interruptionLevel, .timeSensitive) + XCTAssertNil(request?.trigger) + } + + func test_sendPrivateMessageNotification_populatesPeerMetadata() { + let deliverer = RecordingNotificationRequestDeliverer() + let service = NotificationService( + isRunningTestsProvider: { false }, + authorizer: RecordingNotificationAuthorizer(), + requestDeliverer: deliverer + ) + let peerID = PeerID(str: "deadbeefdeadbeef") + + service.sendPrivateMessageNotification(from: "Alice", message: "hi", peerID: peerID) + + let request = deliverer.requests.singleValue + XCTAssertEqual(request?.content.title, "🔒 DM from Alice") + XCTAssertEqual(request?.content.body, "hi") + XCTAssertEqual(request?.content.userInfo["peerID"] as? String, peerID.id) + XCTAssertEqual(request?.content.userInfo["senderName"] as? String, "Alice") + } + + func test_wrapperNotifications_setExpectedIdentifiersAndDeepLinks() { + let deliverer = RecordingNotificationRequestDeliverer() + let service = NotificationService( + isRunningTestsProvider: { false }, + authorizer: RecordingNotificationAuthorizer(), + requestDeliverer: deliverer + ) + + service.sendGeohashActivityNotification(geohash: "87yv", bodyPreview: "Someone is here") + service.sendNetworkAvailableNotification(peerCount: 2) + + XCTAssertEqual(deliverer.requests.count, 2) + XCTAssertEqual(deliverer.requests[0].content.userInfo["deeplink"] as? String, "bitchat://geohash/87yv") + XCTAssertTrue(deliverer.requests[0].identifier.hasPrefix("geo-activity-87yv-")) + XCTAssertEqual(deliverer.requests[1].identifier, "network-available") + XCTAssertEqual(deliverer.requests[1].content.interruptionLevel, .timeSensitive) + XCTAssertEqual(deliverer.requests[1].content.body, "2 people around") + } +} + +private final class RecordingNotificationAuthorizer: NotificationAuthorizing { + private(set) var requestCallCount = 0 + private(set) var lastOptions: UNAuthorizationOptions? + + func requestAuthorization( + options: UNAuthorizationOptions, + completionHandler: @escaping (Bool, Error?) -> Void + ) { + requestCallCount += 1 + lastOptions = options + completionHandler(true, nil) + } +} + +private final class RecordingNotificationRequestDeliverer: NotificationRequestDelivering { + private(set) var requests: [UNNotificationRequest] = [] + + func add(_ request: UNNotificationRequest) { + requests.append(request) + } +} + +private extension Array { + var singleValue: Element? { + count == 1 ? self[0] : nil + } +} diff --git a/bitchatTests/Services/SecureIdentityStateManagerTests.swift b/bitchatTests/Services/SecureIdentityStateManagerTests.swift new file mode 100644 index 00000000..1024d8cf --- /dev/null +++ b/bitchatTests/Services/SecureIdentityStateManagerTests.swift @@ -0,0 +1,138 @@ +import Foundation +import XCTest +@testable import bitchat + +final class SecureIdentityStateManagerTests: XCTestCase { + func test_upsertCryptographicIdentity_tracksByPeerIDPrefixAndClaimedNickname() async { + let manager = SecureIdentityStateManager(MockKeychain()) + let noisePublicKey = Data(repeating: 0x11, count: 32) + let signingPublicKey = Data(repeating: 0x22, count: 32) + let fingerprint = noisePublicKey.sha256Fingerprint() + + manager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: noisePublicKey, + signingPublicKey: signingPublicKey, + claimedNickname: "Alice" + ) + + let socialIdentityLoaded = await waitUntil { + manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "Alice" + } + XCTAssertTrue(socialIdentityLoaded) + let matches = manager.getCryptoIdentitiesByPeerIDPrefix(PeerID(publicKey: noisePublicKey)) + XCTAssertEqual(matches.count, 1) + XCTAssertEqual(matches.first?.fingerprint, fingerprint) + XCTAssertEqual(matches.first?.publicKey, noisePublicKey) + XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey) + } + + func test_setBlocked_clearsFavoriteState() async { + let manager = SecureIdentityStateManager(MockKeychain()) + let fingerprint = String(repeating: "ab", count: 32) + + manager.setFavorite(fingerprint, isFavorite: true) + let favoriteSet = await waitUntil { manager.isFavorite(fingerprint: fingerprint) } + XCTAssertTrue(favoriteSet) + + manager.setBlocked(fingerprint, isBlocked: true) + let blockedSet = await waitUntil { manager.isBlocked(fingerprint: fingerprint) } + XCTAssertTrue(blockedSet) + + XCTAssertFalse(manager.isFavorite(fingerprint: fingerprint)) + XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Unknown") + } + + func test_setVerified_updatesTrustLevelAndVerifiedSet() async { + let manager = SecureIdentityStateManager(MockKeychain()) + let fingerprint = String(repeating: "cd", count: 32) + + manager.setFavorite(fingerprint, isFavorite: false) + _ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil } + manager.setVerified(fingerprint: fingerprint, verified: true) + + let verifiedSet = await waitUntil { manager.isVerified(fingerprint: fingerprint) } + XCTAssertTrue(verifiedSet) + XCTAssertTrue(manager.getVerifiedFingerprints().contains(fingerprint)) + XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.trustLevel, .verified) + } + + func test_forceSave_persistsFavoriteStateAcrossReinit() async { + let keychain = MockKeychain() + let manager = SecureIdentityStateManager(keychain) + let fingerprint = String(repeating: "ef", count: 32) + + manager.setFavorite(fingerprint, isFavorite: true) + let favoriteSet = await waitUntil { manager.isFavorite(fingerprint: fingerprint) } + XCTAssertTrue(favoriteSet) + manager.forceSave() + + let reloaded = SecureIdentityStateManager(keychain) + XCTAssertTrue(reloaded.isFavorite(fingerprint: fingerprint)) + } + + func test_setNostrBlocked_normalizesToLowercaseAndPersists() async { + let keychain = MockKeychain() + let manager = SecureIdentityStateManager(keychain) + let pubkey = "ABCDEF1234" + + manager.setNostrBlocked(pubkey, isBlocked: true) + let nostrBlocked = await waitUntil { + manager.isNostrBlocked(pubkeyHexLowercased: pubkey.lowercased()) + } + XCTAssertTrue(nostrBlocked) + manager.forceSave() + + let reloaded = SecureIdentityStateManager(keychain) + XCTAssertEqual(reloaded.getBlockedNostrPubkeys(), Set([pubkey.lowercased()])) + XCTAssertTrue(reloaded.isNostrBlocked(pubkeyHexLowercased: pubkey)) + } + + func test_corruptPersistedCache_fallsBackToEmptyState() { + let keychain = MockKeychain() + _ = keychain.saveIdentityKey(Data(repeating: 0x01, count: 32), forKey: "identityCacheEncryptionKey") + _ = keychain.saveIdentityKey(Data([0xFF, 0x00, 0xAA]), forKey: "bitchat.identityCache.v2") + + let manager = SecureIdentityStateManager(keychain) + + XCTAssertTrue(manager.getFavorites().isEmpty) + XCTAssertTrue(manager.getVerifiedFingerprints().isEmpty) + XCTAssertTrue(manager.getBlockedNostrPubkeys().isEmpty) + } + + func test_clearAllIdentityData_removesCachedState() async { + let manager = SecureIdentityStateManager(MockKeychain()) + let fingerprint = String(repeating: "12", count: 32) + + manager.setFavorite(fingerprint, isFavorite: true) + manager.setVerified(fingerprint: fingerprint, verified: true) + manager.setNostrBlocked("ABCD", isBlocked: true) + let primed = await waitUntil { + manager.isFavorite(fingerprint: fingerprint) && + manager.isVerified(fingerprint: fingerprint) + } + XCTAssertTrue(primed) + + manager.clearAllIdentityData() + let cleared = await waitUntil { + !manager.isFavorite(fingerprint: fingerprint) && + !manager.isVerified(fingerprint: fingerprint) && + manager.getBlockedNostrPubkeys().isEmpty + } + XCTAssertTrue(cleared) + } + + private func waitUntil( + timeout: TimeInterval = 1.0, + condition: @escaping () -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return true + } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return condition() + } +} diff --git a/bitchatTests/Services/VerificationServiceTests.swift b/bitchatTests/Services/VerificationServiceTests.swift new file mode 100644 index 00000000..575b1781 --- /dev/null +++ b/bitchatTests/Services/VerificationServiceTests.swift @@ -0,0 +1,128 @@ +import XCTest +@testable import bitchat + +final class VerificationServiceTests: XCTestCase { + func test_buildMyQRString_roundTripsSuccessfully() throws { + let (service, noise) = makeService() + let nickname = "alice-\(UUID().uuidString)" + let npub = "npub1testvalue" + + let qrString = try XCTUnwrap(service.buildMyQRString(nickname: nickname, npub: npub)) + let parsed = try XCTUnwrap(service.verifyScannedQR(qrString)) + + XCTAssertEqual(parsed.nickname, nickname) + XCTAssertEqual(parsed.npub, npub) + XCTAssertEqual(parsed.noiseKeyHex, noise.getStaticPublicKeyData().hexEncodedString()) + XCTAssertEqual(parsed.signKeyHex, noise.getSigningPublicKeyData().hexEncodedString()) + } + + func test_buildMyQRString_returnsCachedValueForSameInputs() throws { + let (service, _) = makeService() + let nickname = "cache-\(UUID().uuidString)" + + let first = try XCTUnwrap(service.buildMyQRString(nickname: nickname, npub: nil)) + let second = try XCTUnwrap(service.buildMyQRString(nickname: nickname, npub: nil)) + + XCTAssertEqual(first, second) + } + + func test_verifyScannedQR_rejectsExpiredPayload() throws { + let (service, noise) = makeService() + let oldTimestamp = Int64(Date().addingTimeInterval(-3600).timeIntervalSince1970) + let qrString = try makeSignedQR( + noise: noise, + nickname: "expired-\(UUID().uuidString)", + npub: nil, + ts: oldTimestamp + ) + + XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60)) + } + + func test_verifyScannedQR_rejectsTamperedSignature() throws { + let (service, noise) = makeService() + let badSignature = Data(repeating: 0xAA, count: 64) + let qrString = try makeSignedQR( + noise: noise, + nickname: "tampered-\(UUID().uuidString)", + npub: nil, + ts: Int64(Date().timeIntervalSince1970), + signatureOverride: badSignature + ) + + XCTAssertNil(service.verifyScannedQR(qrString)) + } + + func test_buildVerifyChallenge_roundTripsThroughNoisePayload() throws { + let (service, _) = makeService() + let noiseKeyHex = String(repeating: "ab", count: 32) + let nonce = Data([0x01, 0x02, 0x03, 0x04]) + + let encoded = service.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonce) + let payload = try XCTUnwrap(NoisePayload.decode(encoded)) + let parsed = try XCTUnwrap(service.parseVerifyChallenge(payload.data)) + + XCTAssertEqual(payload.type, .verifyChallenge) + XCTAssertEqual(parsed.noiseKeyHex, noiseKeyHex) + XCTAssertEqual(parsed.nonceA, nonce) + } + + func test_buildVerifyResponse_roundTripsAndVerifiesSignature() throws { + let (service, noise) = makeService() + let noiseKeyHex = String(repeating: "cd", count: 32) + let nonce = Data([0x10, 0x20, 0x30, 0x40, 0x50]) + + let encoded = try XCTUnwrap(service.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonce)) + let payload = try XCTUnwrap(NoisePayload.decode(encoded)) + let parsed = try XCTUnwrap(service.parseVerifyResponse(payload.data)) + + XCTAssertEqual(payload.type, .verifyResponse) + XCTAssertEqual(parsed.noiseKeyHex, noiseKeyHex) + XCTAssertEqual(parsed.nonceA, nonce) + XCTAssertTrue( + service.verifyResponseSignature( + noiseKeyHex: parsed.noiseKeyHex, + nonceA: parsed.nonceA, + signature: parsed.signature, + signerPublicKeyHex: noise.getSigningPublicKeyData().hexEncodedString() + ) + ) + XCTAssertFalse( + service.verifyResponseSignature( + noiseKeyHex: parsed.noiseKeyHex, + nonceA: Data([0xFF]), + signature: parsed.signature, + signerPublicKeyHex: noise.getSigningPublicKeyData().hexEncodedString() + ) + ) + } + + private func makeService() -> (VerificationService, NoiseEncryptionService) { + let noise = NoiseEncryptionService(keychain: MockKeychain()) + let service = VerificationService() + service.configure(with: noise) + return (service, noise) + } + + private func makeSignedQR( + noise: NoiseEncryptionService, + nickname: String, + npub: String?, + ts: Int64, + signatureOverride: Data? = nil + ) throws -> String { + var payload = VerificationService.VerificationQR( + v: 1, + noiseKeyHex: noise.getStaticPublicKeyData().hexEncodedString(), + signKeyHex: noise.getSigningPublicKeyData().hexEncodedString(), + npub: npub, + nickname: nickname, + ts: ts, + nonceB64: Data((0..<16).map(UInt8.init)).base64EncodedString(), + sigHex: "" + ) + let signature = try XCTUnwrap(signatureOverride ?? noise.signData(payload.canonicalBytes())) + payload.sigHex = signature.hexEncodedString() + return payload.toURLString() + } +}