From 7d83310bc2ce9e06071f2bf5e9ce22935286e297 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:41:05 -1000 Subject: [PATCH] Expand Nostr and identity coverage (#1059) Co-authored-by: jack --- .../Identity/SecureIdentityStateManager.swift | 25 +- bitchat/Nostr/GeoRelayDirectory.swift | 261 ++++++--- bitchat/Nostr/NostrRelayManager.swift | 34 +- bitchat/Services/LocationStateManager.swift | 5 +- bitchat/Sync/RequestSyncManager.swift | 21 +- bitchatTests/CommandProcessorTests.swift | 2 +- .../Fragmentation/FragmentationTests.swift | 74 ++- .../Nostr/GeoRelayDirectoryTests.swift | 364 ++++++++++++ bitchatTests/NostrProtocolTests.swift | 16 +- .../Services/NostrRelayManagerTests.swift | 542 +++++++++++++++++- .../SecureIdentityStateManagerTests.swift | 359 ++++++++++++ .../Sync/RequestSyncManagerTests.swift | 85 +++ 12 files changed, 1627 insertions(+), 161 deletions(-) create mode 100644 bitchatTests/Nostr/GeoRelayDirectoryTests.swift create mode 100644 bitchatTests/Sync/RequestSyncManagerTests.swift diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index f2172524..4fe323e5 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -331,16 +331,15 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { func updateSocialIdentity(_ identity: SocialIdentity) { queue.async(flags: .barrier) { + let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname self.cache.socialIdentities[identity.fingerprint] = identity // Update nickname index - if let existingIdentity = self.cache.socialIdentities[identity.fingerprint] { - // Remove old nickname from index if changed - if existingIdentity.claimedNickname != identity.claimedNickname { - self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint) - if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true { - self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname) - } + if let previousClaimedNickname, + previousClaimedNickname != identity.claimedNickname { + self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint) + if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true { + self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname) } } @@ -532,4 +531,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { return cache.verifiedFingerprints } } + + var debugNicknameIndex: [String: Set] { + queue.sync { cache.nicknameIndex } + } + + func debugEphemeralSession(for peerID: PeerID) -> EphemeralIdentity? { + queue.sync { ephemeralSessions[peerID] } + } + + func debugLastInteraction(for fingerprint: String) -> Date? { + queue.sync { cache.lastInteractions[fingerprint] } + } } diff --git a/bitchat/Nostr/GeoRelayDirectory.swift b/bitchat/Nostr/GeoRelayDirectory.swift index 45523e1a..6889a194 100644 --- a/bitchat/Nostr/GeoRelayDirectory.swift +++ b/bitchat/Nostr/GeoRelayDirectory.swift @@ -8,8 +8,108 @@ import AppKit #endif /// Directory of online Nostr relays with approximate GPS locations, used for geohash routing. +struct GeoRelayDirectoryDependencies { + var userDefaults: UserDefaults + var notificationCenter: NotificationCenter + var now: () -> Date + var remoteURL: URL + var fetchInterval: TimeInterval + var refreshCheckInterval: TimeInterval + var retryInitialSeconds: TimeInterval + var retryMaxSeconds: TimeInterval + var awaitTorReady: () async -> Bool + var fetchData: (URLRequest) async throws -> Data + var readData: (URL) -> Data? + var writeData: (Data, URL) throws -> Void + var cacheURL: () -> URL? + var bundledCSVURLs: () -> [URL] + var currentDirectoryPath: () -> String? + var retrySleep: (TimeInterval) async -> Void + var activeNotificationName: Notification.Name? + var autoStart: Bool +} + +private extension GeoRelayDirectoryDependencies { + @MainActor + static func live() -> Self { +#if os(iOS) + let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification +#elseif os(macOS) + let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification +#else + let activeNotificationName: Notification.Name? = nil +#endif + + return Self( + userDefaults: .standard, + notificationCenter: .default, + now: Date.init, + remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!, + fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds, + refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds, + retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds, + retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds, + awaitTorReady: { await TorManager.shared.awaitReady() }, + fetchData: { request in + let (data, _) = try await TorURLSession.shared.session.data(for: request) + return data + }, + readData: { try? Data(contentsOf: $0) }, + writeData: { data, url in + try data.write(to: url, options: .atomic) + }, + cacheURL: { + do { + let base = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let dir = base.appendingPathComponent("bitchat", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("georelays_cache.csv") + } catch { + return nil + } + }, + bundledCSVURLs: { + [ + Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"), + Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"), + Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays") + ].compactMap { $0 } + }, + currentDirectoryPath: { FileManager.default.currentDirectoryPath }, + retrySleep: { delay in + let nanoseconds = UInt64(delay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + }, + activeNotificationName: activeNotificationName, + autoStart: true + ) + } +} + @MainActor final class GeoRelayDirectory { + private final class CleanupState { + let notificationCenter: NotificationCenter + var observers: [NSObjectProtocol] = [] + var refreshTimer: Timer? + var retryTask: Task? + + init(notificationCenter: NotificationCenter) { + self.notificationCenter = notificationCenter + } + + deinit { + observers.forEach { notificationCenter.removeObserver($0) } + refreshTimer?.invalidate() + retryTask?.cancel() + } + } + struct Entry: Hashable { let host: String let lat: Double @@ -19,28 +119,33 @@ final class GeoRelayDirectory { static let shared = GeoRelayDirectory() private(set) var entries: [Entry] = [] - private let cacheFileName = "georelays_cache.csv" private let lastFetchKey = "georelay.lastFetchAt" - private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")! - private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds + private let dependencies: GeoRelayDirectoryDependencies + private let cleanupState: CleanupState - private var refreshTimer: Timer? - private var retryTask: Task? private var retryAttempt: Int = 0 private var isFetching: Bool = false - private var observers: [NSObjectProtocol] = [] private init() { + self.dependencies = .live() + self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter) entries = loadLocalEntries() - registerObservers() - startRefreshTimer() - prefetchIfNeeded() + if dependencies.autoStart { + registerObservers() + startRefreshTimer() + prefetchIfNeeded() + } } - deinit { - observers.forEach { NotificationCenter.default.removeObserver($0) } - refreshTimer?.invalidate() - retryTask?.cancel() + internal init(dependencies: GeoRelayDirectoryDependencies) { + self.dependencies = dependencies + self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter) + entries = loadLocalEntries() + if dependencies.autoStart { + registerObservers() + startRefreshTimer() + prefetchIfNeeded() + } } /// Returns up to `count` relay URLs (wss://) closest to the geohash center. @@ -83,13 +188,13 @@ final class GeoRelayDirectory { func prefetchIfNeeded(force: Bool = false) { guard !isFetching else { return } - let now = Date() - let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast + let now = dependencies.now() + let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast if !force { - guard now.timeIntervalSince(last) >= fetchInterval else { return } + guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return } } else if last != .distantPast, - now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds { + now.timeIntervalSince(last) < dependencies.retryInitialSeconds { // Skip forced fetches if we just refreshed moments ago. return } @@ -103,36 +208,36 @@ final class GeoRelayDirectory { isFetching = true let request = URLRequest( - url: remoteURL, + url: dependencies.remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15 ) - Task.detached { [weak self] in + Task { [weak self] in guard let self else { return } - let ready = await TorManager.shared.awaitReady() + let ready = await self.dependencies.awaitTorReady() if !ready { - await self.handleFetchFailure(.torNotReady) + self.handleFetchFailure(.torNotReady) return } do { - let (data, _) = try await TorURLSession.shared.session.data(for: request) + let data = try await self.dependencies.fetchData(request) guard let text = String(data: data, encoding: .utf8) else { - await self.handleFetchFailure(.invalidData) + self.handleFetchFailure(.invalidData) return } let parsed = GeoRelayDirectory.parseCSV(text) guard !parsed.isEmpty else { - await self.handleFetchFailure(.invalidData) + self.handleFetchFailure(.invalidData) return } - await self.handleFetchSuccess(entries: parsed, csv: text) + self.handleFetchSuccess(entries: parsed, csv: text) } catch { - await self.handleFetchFailure(.network(error)) + self.handleFetchFailure(.network(error)) } } } @@ -147,7 +252,7 @@ final class GeoRelayDirectory { private func handleFetchSuccess(entries parsed: [Entry], csv: String) { entries = parsed persistCache(csv) - UserDefaults.standard.set(Date(), forKey: lastFetchKey) + dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey) SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session) isFetching = false retryAttempt = 0 @@ -171,32 +276,34 @@ final class GeoRelayDirectory { @MainActor private func scheduleRetry() { retryAttempt = min(retryAttempt + 1, 10) - let base = TransportConfig.geoRelayRetryInitialSeconds - let maxDelay = TransportConfig.geoRelayRetryMaxSeconds + let base = dependencies.retryInitialSeconds + let maxDelay = dependencies.retryMaxSeconds let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0))) let calculated = base * multiplier let delay = min(maxDelay, max(base, calculated)) cancelRetry() - retryTask = Task { [weak self] in - let nanoseconds = UInt64(delay * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanoseconds) + cleanupState.retryTask = Task { [weak self] in + guard let self else { return } + await self.dependencies.retrySleep(delay) + guard !Task.isCancelled else { return } await MainActor.run { - self?.prefetchIfNeeded(force: true) + self.prefetchIfNeeded(force: true) } } } @MainActor private func cancelRetry() { - retryTask?.cancel() - retryTask = nil + cleanupState.retryTask?.cancel() + cleanupState.retryTask = nil } private func persistCache(_ text: String) { - guard let url = cacheURL() else { return } + guard let url = dependencies.cacheURL() else { return } + guard let data = text.data(using: .utf8) else { return } do { - try text.data(using: .utf8)?.write(to: url, options: .atomic) + try dependencies.writeData(data, url) } catch { SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session) } @@ -205,22 +312,18 @@ final class GeoRelayDirectory { // MARK: - Loading private func loadLocalEntries() -> [Entry] { // Prefer cached file if present - if let cache = cacheURL(), - let data = try? Data(contentsOf: cache), + if let cache = dependencies.cacheURL(), + let data = dependencies.readData(cache), let text = String(data: data, encoding: .utf8) { let arr = Self.parseCSV(text) if !arr.isEmpty { return arr } } // Try bundled resource(s) - let bundleCandidates = [ - Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"), - Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"), - Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays") - ].compactMap { $0 } + let bundleCandidates = dependencies.bundledCSVURLs() for url in bundleCandidates { - if let data = try? Data(contentsOf: url), + if let data = dependencies.readData(url), let text = String(data: data, encoding: .utf8) { let arr = Self.parseCSV(text) if !arr.isEmpty { return arr } @@ -228,8 +331,8 @@ final class GeoRelayDirectory { } // Try filesystem path (development/test) - if let cwd = FileManager.default.currentDirectoryPath as String?, - let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")), + if let cwd = dependencies.currentDirectoryPath(), + let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")), let text = String(data: data, encoding: .utf8) { return Self.parseCSV(text) } @@ -259,25 +362,9 @@ final class GeoRelayDirectory { return Array(result) } - private func cacheURL() -> URL? { - do { - let base = try FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - let dir = base.appendingPathComponent("bitchat", isDirectory: true) - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - return dir.appendingPathComponent(cacheFileName) - } catch { - return nil - } - } - // MARK: - Observers & Timers private func registerObservers() { - let center = NotificationCenter.default + let center = dependencies.notificationCenter let torReady = center.addObserver( forName: .TorDidBecomeReady, @@ -289,38 +376,26 @@ final class GeoRelayDirectory { self.prefetchIfNeeded(force: true) } } - observers.append(torReady) + cleanupState.observers.append(torReady) -#if os(iOS) - let didBecomeActive = center.addObserver( - forName: UIApplication.didBecomeActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - guard let self else { return } - Task { @MainActor in - self.prefetchIfNeeded() + if let activeNotificationName = dependencies.activeNotificationName { + let didBecomeActive = center.addObserver( + forName: activeNotificationName, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + self.prefetchIfNeeded() + } } + cleanupState.observers.append(didBecomeActive) } - observers.append(didBecomeActive) -#elseif os(macOS) - let didBecomeActive = center.addObserver( - forName: NSApplication.didBecomeActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - guard let self else { return } - Task { @MainActor in - self.prefetchIfNeeded() - } - } - observers.append(didBecomeActive) -#endif } private func startRefreshTimer() { - refreshTimer?.invalidate() - let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds + cleanupState.refreshTimer?.invalidate() + let interval = dependencies.refreshCheckInterval guard interval > 0 else { return } let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in @@ -329,9 +404,13 @@ final class GeoRelayDirectory { self.prefetchIfNeeded() } } - refreshTimer = timer + cleanupState.refreshTimer = timer RunLoop.main.add(timer, forMode: .common) } + + var debugRetryAttempt: Int { retryAttempt } + var debugHasRetryTask: Bool { cleanupState.retryTask != nil } + var debugObserverCount: Int { cleanupState.observers.count } } // MARK: - Distance diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 79c23f57..ebfeadaa 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -64,7 +64,7 @@ struct NostrRelayManagerDependencies { var torIsForeground: () -> Bool var awaitTorReady: (@escaping (Bool) -> Void) -> Void var makeSession: () -> NostrRelaySessionProtocol - var scheduleAfter: (TimeInterval, @escaping () -> Void) -> Void + var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void var now: () -> Date } @@ -412,7 +412,7 @@ final class NostrRelayManager: ObservableObject { for url in urls where !existingSet.contains(url) { relays.append(Relay(url: url)) } - for url in candidateUrls { + for url in urls { var map = self.pendingSubscriptions[url] ?? [:] map[id] = messageString self.pendingSubscriptions[url] = map @@ -471,6 +471,7 @@ final class NostrRelayManager: ObservableObject { } connections.removeValue(forKey: url) subscriptions.removeValue(forKey: url) + pendingSubscriptions.removeValue(forKey: url) } messageQueueLock.lock() for index in (0.. Int { + pendingSubscriptions[relayUrl]?.count ?? 0 + } + + func debugFlushMessageQueue() { + flushMessageQueue(for: nil) + } /// Reset all relay connections func resetAllConnections() { diff --git a/bitchat/Services/LocationStateManager.swift b/bitchat/Services/LocationStateManager.swift index 6d287146..378244d5 100644 --- a/bitchat/Services/LocationStateManager.swift +++ b/bitchat/Services/LocationStateManager.swift @@ -43,10 +43,7 @@ private final class CLLocationManagerAdapter: NSObject, LocationStateManaging { } var authorizationStatus: CLAuthorizationStatus { - if #available(iOS 14.0, macOS 11.0, *) { - return base.authorizationStatus - } - return CLLocationManager.authorizationStatus() + base.authorizationStatus } func requestWhenInUseAuthorization() { diff --git a/bitchat/Sync/RequestSyncManager.swift b/bitchat/Sync/RequestSyncManager.swift index c12bc775..46e6a3da 100644 --- a/bitchat/Sync/RequestSyncManager.swift +++ b/bitchat/Sync/RequestSyncManager.swift @@ -17,14 +17,21 @@ final class RequestSyncManager { private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent) private var pendingRequests: [PeerID: TimeInterval] = [:] + private let responseWindow: TimeInterval + private let now: () -> TimeInterval - // Allow responses for 30s after request - private let responseWindow: TimeInterval = 30.0 + init( + responseWindow: TimeInterval = 30.0, + now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 } + ) { + self.responseWindow = responseWindow + self.now = now + } /// Register that we are sending a sync request to a peer. /// - Parameter peerID: The peer we are requesting sync from func registerRequest(to peerID: PeerID) { - let now = Date().timeIntervalSince1970 + let now = self.now() queue.async(flags: .barrier) { SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))…", category: .sync) self.pendingRequests[peerID] = now @@ -46,7 +53,7 @@ final class RequestSyncManager { return false } - let now = Date().timeIntervalSince1970 + let now = self.now() if now - requestTime > responseWindow { SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security) // We don't remove here because we might receive multiple packets for one request @@ -59,7 +66,7 @@ final class RequestSyncManager { /// Periodic cleanup of expired requests func cleanup() { - let now = Date().timeIntervalSince1970 + let now = self.now() queue.async(flags: .barrier) { let originalCount = self.pendingRequests.count self.pendingRequests = self.pendingRequests.filter { _, timestamp in @@ -71,4 +78,8 @@ final class RequestSyncManager { } } } + + var debugPendingRequestCount: Int { + queue.sync { pendingRequests.count } + } } diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 28800b45..f88d1ec2 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -104,7 +104,7 @@ struct CommandProcessorTests { let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager) let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) - let result = try await withSelectedChannel(channel) { + let result = await withSelectedChannel(channel) { processor.process("/who") } diff --git a/bitchatTests/Fragmentation/FragmentationTests.swift b/bitchatTests/Fragmentation/FragmentationTests.swift index d016ceae..4e724206 100644 --- a/bitchatTests/Fragmentation/FragmentationTests.swift +++ b/bitchatTests/Fragmentation/FragmentationTests.swift @@ -209,16 +209,18 @@ extension FragmentationTests { private var expectedPublicMessageCount: Int = 0 private var expectedReceivedMessageCount: Int = 0 - var publicMessages: [(peerID: PeerID, nickname: String, content: String)] { + private func withLock(_ body: () -> T) -> T { lock.lock() defer { lock.unlock() } - return _publicMessages + return body() + } + + var publicMessages: [(peerID: PeerID, nickname: String, content: String)] { + withLock { _publicMessages } } var receivedMessages: [BitchatMessage] { - lock.lock() - defer { lock.unlock() } - return _receivedMessages + withLock { _receivedMessages } } func didReceiveMessage(_ message: BitchatMessage) { @@ -255,27 +257,32 @@ extension FragmentationTests { /// Waits for the specified number of public messages to be received func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws { - lock.lock() - if _publicMessages.count >= count { - lock.unlock() + let isAlreadySatisfied = withLock { () -> Bool in + if _publicMessages.count >= count { + return true + } + expectedPublicMessageCount = count + return false + } + if isAlreadySatisfied { return } - expectedPublicMessageCount = count - lock.unlock() try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { await withCheckedContinuation { continuation in - self.lock.lock() - // Recheck count after acquiring lock to avoid race condition - // where message arrives between initial check and continuation install - if self._publicMessages.count >= count { - self.lock.unlock() + let shouldResumeImmediately = self.withLock { + // Recheck count after acquiring lock to avoid race condition + // where message arrives between initial check and continuation install + if self._publicMessages.count >= count { + return true + } + self.publicMessageContinuation = continuation + return false + } + if shouldResumeImmediately { continuation.resume() - return } - self.publicMessageContinuation = continuation - self.lock.unlock() } } group.addTask { @@ -289,27 +296,32 @@ extension FragmentationTests { /// Waits for the specified number of received messages func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws { - lock.lock() - if _receivedMessages.count >= count { - lock.unlock() + let isAlreadySatisfied = withLock { () -> Bool in + if _receivedMessages.count >= count { + return true + } + expectedReceivedMessageCount = count + return false + } + if isAlreadySatisfied { return } - expectedReceivedMessageCount = count - lock.unlock() try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { await withCheckedContinuation { continuation in - self.lock.lock() - // Recheck count after acquiring lock to avoid race condition - // where message arrives between initial check and continuation install - if self._receivedMessages.count >= count { - self.lock.unlock() + let shouldResumeImmediately = self.withLock { + // Recheck count after acquiring lock to avoid race condition + // where message arrives between initial check and continuation install + if self._receivedMessages.count >= count { + return true + } + self.receivedMessageContinuation = continuation + return false + } + if shouldResumeImmediately { continuation.resume() - return } - self.receivedMessageContinuation = continuation - self.lock.unlock() } } group.addTask { diff --git a/bitchatTests/Nostr/GeoRelayDirectoryTests.swift b/bitchatTests/Nostr/GeoRelayDirectoryTests.swift new file mode 100644 index 00000000..57041dde --- /dev/null +++ b/bitchatTests/Nostr/GeoRelayDirectoryTests.swift @@ -0,0 +1,364 @@ +import Foundation +import Tor +import XCTest +@testable import bitchat + +@MainActor +final class GeoRelayDirectoryTests: XCTestCase { + func test_parseCSV_normalizesRelaySchemesAndDeduplicatesEntries() { + let csv = """ + relay url,lat,lon + wss://one.example/,10,20 + https://one.example,10,20 + http://two.example/,11,21 + invalid row + ws://three.example,not-a-lat,22 + """ + + let parsed = Set(GeoRelayDirectory.parseCSV(csv)) + + XCTAssertEqual( + parsed, + Set([ + GeoRelayDirectory.Entry(host: "one.example", lat: 10, lon: 20), + GeoRelayDirectory.Entry(host: "two.example", lat: 11, lon: 21) + ]) + ) + } + + func test_closestRelays_sortsByDistanceForLatLonAndGeohash() { + let harness = makeHarness( + cacheCSV: """ + relay url,lat,lon + close.example,37.7749,-122.4194 + medium.example,34.0522,-118.2437 + far.example,40.7128,-74.0060 + """ + ) + let directory = GeoRelayDirectory(dependencies: harness.dependencies) + + XCTAssertEqual( + directory.closestRelays(toLat: 37.78, lon: -122.41, count: 2), + ["wss://close.example", "wss://medium.example"] + ) + XCTAssertEqual( + directory.closestRelays(toLat: 37.78, lon: -122.41, count: 10), + ["wss://close.example", "wss://medium.example", "wss://far.example"] + ) + + let geohash = Geohash.encode(latitude: 37.78, longitude: -122.41, precision: 6) + XCTAssertEqual( + directory.closestRelays(toGeohash: geohash, count: 2), + ["wss://close.example", "wss://medium.example"] + ) + } + + func test_loadLocalEntries_prefersCacheThenBundleThenWorkingDirectory() { + let cacheHarness = makeHarness( + cacheCSV: """ + relay url,lat,lon + cache.example,1,1 + """, + bundleCSV: """ + relay url,lat,lon + bundle.example,2,2 + """, + workingDirectoryCSV: """ + relay url,lat,lon + cwd.example,3,3 + """ + ) + XCTAssertEqual( + GeoRelayDirectory(dependencies: cacheHarness.dependencies).entries, + [GeoRelayDirectory.Entry(host: "cache.example", lat: 1, lon: 1)] + ) + + let bundleHarness = makeHarness( + cacheCSV: "invalid", + bundleCSV: """ + relay url,lat,lon + bundle.example,2,2 + """, + workingDirectoryCSV: """ + relay url,lat,lon + cwd.example,3,3 + """ + ) + XCTAssertEqual( + GeoRelayDirectory(dependencies: bundleHarness.dependencies).entries, + [GeoRelayDirectory.Entry(host: "bundle.example", lat: 2, lon: 2)] + ) + + let cwdHarness = makeHarness( + cacheCSV: nil, + bundleCSV: "invalid", + workingDirectoryCSV: """ + relay url,lat,lon + cwd.example,3,3 + """ + ) + XCTAssertEqual( + GeoRelayDirectory(dependencies: cwdHarness.dependencies).entries, + [GeoRelayDirectory.Entry(host: "cwd.example", lat: 3, lon: 3)] + ) + } + + func test_prefetchIfNeeded_skipsWhenFetchIntervalHasNotElapsed() async { + let harness = makeHarness(fetchCSV: """ + relay url,lat,lon + one.example,1,1 + """) + harness.userDefaults.set(harness.clock.now, forKey: "georelay.lastFetchAt") + let directory = GeoRelayDirectory(dependencies: harness.dependencies) + + directory.prefetchIfNeeded() + try? await Task.sleep(nanoseconds: 20_000_000) + + let requestCount = await harness.fetcher.recordedRequestCount() + XCTAssertEqual(requestCount, 0) + XCTAssertFalse(directory.debugHasRetryTask) + } + + func test_prefetchIfNeeded_successUpdatesEntriesPersistsCacheAndSkipsImmediateForcedRefetch() async { + let csv = """ + relay url,lat,lon + refreshed.example,12,34 + """ + let harness = makeHarness(fetchCSV: csv) + let directory = GeoRelayDirectory(dependencies: harness.dependencies) + + directory.prefetchIfNeeded() + let refreshed = await waitUntil { + directory.entries == [GeoRelayDirectory.Entry(host: "refreshed.example", lat: 12, lon: 34)] + } + XCTAssertTrue(refreshed) + let requestCount = await harness.fetcher.recordedRequestCount() + XCTAssertEqual(requestCount, 1) + XCTAssertEqual(harness.fileStore.dataByURL[harness.cacheURL], csv.data(using: .utf8)) + XCTAssertEqual(harness.userDefaults.object(forKey: "georelay.lastFetchAt") as? Date, harness.clock.now) + XCTAssertEqual(directory.debugRetryAttempt, 0) + XCTAssertFalse(directory.debugHasRetryTask) + + directory.prefetchIfNeeded(force: true) + try? await Task.sleep(nanoseconds: 20_000_000) + let forcedRequestCount = await harness.fetcher.recordedRequestCount() + XCTAssertEqual(forcedRequestCount, 1) + } + + func test_prefetchIfNeeded_failureSchedulesRetryAndRecoversOnNextFetch() async { + let csv = """ + relay url,lat,lon + retry.example,5,6 + """ + let harness = makeHarness( + fetchResults: [ + .failure(GeoRelayTestError.network), + .success(csv.data(using: .utf8)!) + ] + ) + let directory = GeoRelayDirectory(dependencies: harness.dependencies) + + directory.prefetchIfNeeded() + + let recovered = await waitUntil { + directory.entries == [GeoRelayDirectory.Entry(host: "retry.example", lat: 5, lon: 6)] + } + XCTAssertTrue(recovered) + let requestCount = await harness.fetcher.recordedRequestCount() + let retryDelays = await harness.retryRecorder.recordedDelays() + XCTAssertEqual(requestCount, 2) + XCTAssertEqual(retryDelays, [5]) + XCTAssertEqual(directory.debugRetryAttempt, 0) + XCTAssertFalse(directory.debugHasRetryTask) + } + + func test_observers_triggerPrefetchesForTorReadyAndAppActivation() async { + let activeNotification = Notification.Name("GeoRelayDirectoryTests.didBecomeActive") + let harness = makeHarness( + fetchCSV: """ + relay url,lat,lon + observer.example,1,2 + """, + autoStart: true, + activeNotificationName: activeNotification + ) + var directory: GeoRelayDirectory? = GeoRelayDirectory(dependencies: harness.dependencies) + let initialFetch = await waitUntil { + await harness.fetcher.recordedRequestCount() == 1 + } + XCTAssertTrue(initialFetch) + XCTAssertEqual(directory?.debugObserverCount, 2) + + harness.clock.now = harness.clock.now.addingTimeInterval(6) + harness.notificationCenter.post(name: .TorDidBecomeReady, object: nil) + let torTriggered = await waitUntil { + await harness.fetcher.recordedRequestCount() == 2 + } + XCTAssertTrue(torTriggered) + + harness.clock.now = harness.clock.now.addingTimeInterval(61) + harness.notificationCenter.post(name: activeNotification, object: nil) + let activeTriggered = await waitUntil { + await harness.fetcher.recordedRequestCount() == 3 + } + XCTAssertTrue(activeTriggered) + + weak var weakDirectory: GeoRelayDirectory? + weakDirectory = directory + directory = nil + XCTAssertNil(weakDirectory) + } + + private func makeHarness( + cacheCSV: String? = nil, + bundleCSV: String? = nil, + workingDirectoryCSV: String? = nil, + fetchCSV: String? = nil, + fetchResults: [Result] = [], + autoStart: Bool = false, + activeNotificationName: Notification.Name? = nil + ) -> GeoRelayHarness { + let userDefaultsSuite = "GeoRelayDirectoryTests.\(UUID().uuidString)" + let userDefaults = UserDefaults(suiteName: userDefaultsSuite)! + userDefaults.removePersistentDomain(forName: userDefaultsSuite) + + let notificationCenter = NotificationCenter() + let clock = MutableGeoClock(now: Date(timeIntervalSince1970: 1_700_000_000)) + let fileStore = InMemoryFileStore() + let cacheURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString)-cache.csv") + let bundleURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString)-bundle.csv") + let cwd = "/tmp/\(UUID().uuidString)-cwd" + let cwdURL = URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv") + + if let cacheCSV { + fileStore.dataByURL[cacheURL] = Data(cacheCSV.utf8) + } + if let bundleCSV { + fileStore.dataByURL[bundleURL] = Data(bundleCSV.utf8) + } + if let workingDirectoryCSV { + fileStore.dataByURL[cwdURL] = Data(workingDirectoryCSV.utf8) + } + + let defaultFetchData = Data((fetchCSV ?? bundleCSV ?? cacheCSV ?? "relay url,lat,lon\nfallback.example,0,0\n").utf8) + let fetcher = FetchProbe(responses: fetchResults, defaultData: defaultFetchData) + let retryRecorder = RetryDelayRecorder() + + let dependencies = GeoRelayDirectoryDependencies( + userDefaults: userDefaults, + notificationCenter: notificationCenter, + now: { clock.now }, + remoteURL: URL(string: "https://example.com/nostr_relays.csv")!, + fetchInterval: 60, + refreshCheckInterval: 0, + retryInitialSeconds: 5, + retryMaxSeconds: 40, + awaitTorReady: { true }, + fetchData: { request in + try await fetcher.fetch(request) + }, + readData: { url in + fileStore.dataByURL[url] + }, + writeData: { data, url in + fileStore.dataByURL[url] = data + }, + cacheURL: { cacheURL }, + bundledCSVURLs: bundleCSV == nil ? { [] } : { [bundleURL] }, + currentDirectoryPath: workingDirectoryCSV == nil ? { nil } : { cwd }, + retrySleep: { delay in + await retryRecorder.record(delay) + }, + activeNotificationName: activeNotificationName, + autoStart: autoStart + ) + + return GeoRelayHarness( + dependencies: dependencies, + clock: clock, + fileStore: fileStore, + fetcher: fetcher, + retryRecorder: retryRecorder, + userDefaults: userDefaults, + notificationCenter: notificationCenter, + cacheURL: cacheURL + ) + } + + private func waitUntil( + timeout: TimeInterval = 1.0, + condition: @escaping @MainActor () async -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await condition() { + return true + } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return await condition() + } +} + +private struct GeoRelayHarness { + let dependencies: GeoRelayDirectoryDependencies + let clock: MutableGeoClock + let fileStore: InMemoryFileStore + let fetcher: FetchProbe + let retryRecorder: RetryDelayRecorder + let userDefaults: UserDefaults + let notificationCenter: NotificationCenter + let cacheURL: URL +} + +private final class MutableGeoClock { + var now: Date + + init(now: Date) { + self.now = now + } +} + +private final class InMemoryFileStore { + var dataByURL: [URL: Data] = [:] +} + +private actor FetchProbe { + private var responses: [Result] + private let defaultData: Data + private(set) var requestCount = 0 + + init(responses: [Result], defaultData: Data) { + self.responses = responses + self.defaultData = defaultData + } + + func fetch(_ request: URLRequest) async throws -> Data { + _ = request + requestCount += 1 + if !responses.isEmpty { + return try responses.removeFirst().get() + } + return defaultData + } + + func recordedRequestCount() -> Int { + requestCount + } +} + +private actor RetryDelayRecorder { + private(set) var delays: [TimeInterval] = [] + + func record(_ delay: TimeInterval) { + delays.append(delay) + } + + func recordedDelays() -> [TimeInterval] { + delays + } +} + +private enum GeoRelayTestError: Error { + case network +} diff --git a/bitchatTests/NostrProtocolTests.swift b/bitchatTests/NostrProtocolTests.swift index 68dae5f1..91f9217c 100644 --- a/bitchatTests/NostrProtocolTests.swift +++ b/bitchatTests/NostrProtocolTests.swift @@ -215,7 +215,7 @@ struct NostrProtocolTests { @Test func nostrEventSignatureVerification_roundTrip() throws { let identity = try NostrIdentity.generate() - var event = NostrEvent( + let event = NostrEvent( pubkey: identity.publicKeyHex, createdAt: Date(), kind: .ephemeralEvent, @@ -228,7 +228,7 @@ struct NostrProtocolTests { @Test func nostrEventSignatureVerification_detectsTamper() throws { let identity = try NostrIdentity.generate() - var event = NostrEvent( + let event = NostrEvent( pubkey: identity.publicKeyHex, createdAt: Date(), kind: .ephemeralEvent, @@ -240,6 +240,18 @@ struct NostrProtocolTests { #expect(!signed.isValidSignature()) } + @Test func geohashNotesSingleFilter_encodesExpectedTagShape() throws { + let since = Date(timeIntervalSince1970: 1_234_567) + let filter = NostrFilter.geohashNotes("u4pruyd", since: since, limit: 42) + let data = try JSONEncoder().encode(filter) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + + #expect(object["kinds"] as? [Int] == [1]) + #expect(object["#g"] as? [String] == ["u4pruyd"]) + #expect(object["since"] as? Int == 1_234_567) + #expect(object["limit"] as? Int == 42) + } + // MARK: - Helpers private static func base64URLDecode(_ s: String) -> Data? { var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index 100756a9..5d3ce8df 100644 --- a/bitchatTests/Services/NostrRelayManagerTests.swift +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -4,6 +4,21 @@ import XCTest @MainActor final class NostrRelayManagerTests: XCTestCase { + func test_connect_directMode_connectsExistingDefaultRelaysWhenActivationBecomesAllowed() async { + let context = makeContext(permission: .authorized, activationAllowed: false) + + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + context.activationAllowed.value = true + + context.manager.connect() + + let connected = await waitUntil { + context.sessionFactory.requestedURLs.count == 5 && + context.manager.relays.allSatisfy(\.isConnected) + } + XCTAssertTrue(connected) + } + func test_permissionPublisher_addsAndRemovesDefaultRelays() async { let context = makeContext(permission: .denied, favorites: []) @@ -43,6 +58,103 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertTrue(connectedAfterTorReady) } + func test_connect_whenTorReadinessFailsDoesNotCreateSessions() async { + let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false) + + context.manager.connect() + context.torWaiter.resolve(false) + try? await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + XCTAssertFalse(context.manager.isConnected) + } + + func test_sendEvent_waitsForTorReadinessBeforeSending() async throws { + let relayURL = "wss://tor-ready.example" + let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) + let event = try makeSignedEvent(content: "deferred") + + context.manager.sendEvent(event, to: [relayURL]) + + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + + context.torWaiter.resolve(true) + + let sentAfterTorReady = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 && + context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent == 1 + } + XCTAssertTrue(sentAfterTorReady) + } + + func test_sendEvent_queuesWhileBackgroundedAndFlushesWhenForegrounded() async throws { + let relayURL = "wss://queue-flush.example" + let context = makeContext( + permission: .denied, + userTorEnabled: true, + torEnforced: true, + torIsReady: true, + torIsForeground: false + ) + let event = try makeSignedEvent(content: "queued") + + context.manager.sendEvent(event, to: [relayURL]) + try? await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + context.torForeground.value = true + context.manager.ensureConnections(to: [relayURL]) + + let flushed = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 && + context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent == 1 + } + XCTAssertTrue(flushed) + } + + func test_sendEvent_sendFailureDoesNotIncrementMessageCount() async throws { + let relayURL = "wss://send-failure.example" + let context = makeContext(permission: .denied) + context.sessionFactory.sendErrorByURL[relayURL] = NSError(domain: "send", code: 1) + let event = try makeSignedEvent(content: "send failure") + + context.manager.sendEvent(event, to: [relayURL]) + + let attempted = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 + } + XCTAssertTrue(attempted) + + try? await Task.sleep(nanoseconds: 20_000_000) + XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent, 0) + } + + func test_sendEvent_queueIsPrunedWhenDefaultRelaysAreRevoked() async throws { + let context = makeContext( + permission: .authorized, + userTorEnabled: true, + torEnforced: true, + torIsReady: true, + torIsForeground: false + ) + let event = try makeSignedEvent(content: "queued default") + + context.manager.sendEvent(event) + + let queued = await waitUntil { + context.manager.debugPendingMessageQueueCount == 1 + } + XCTAssertTrue(queued) + + context.permissionSubject.send(.denied) + + let cleared = await waitUntil { + context.manager.debugPendingMessageQueueCount == 0 && + context.manager.relays.isEmpty + } + XCTAssertTrue(cleared) + } + func test_connect_doesNothingWhenActivationIsDisallowed() { let context = makeContext(permission: .authorized, activationAllowed: false) @@ -85,6 +197,81 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1) } + func test_subscribe_waitsForTorReadinessAndPreservesEOSECallback() async throws { + let relayURL = "wss://tor-subscribe.example" + let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) + var eoseCount = 0 + + context.manager.subscribe( + filter: makeFilter(), + id: "tor-eose", + relayUrls: [relayURL], + handler: { _ in }, + onEOSE: { eoseCount += 1 } + ) + + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + + context.torWaiter.resolve(true) + let subscribed = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 + } + XCTAssertTrue(subscribed) + + try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: "tor-eose") + let eoseCompleted = await waitUntil { eoseCount == 1 } + XCTAssertTrue(eoseCompleted) + } + + func test_subscribe_withoutAllowedRelays_callsEOSEImmediatelyAndDoesNotFlushLater() async { + let context = makeContext(permission: .denied) + var eoseCount = 0 + + context.manager.subscribe( + filter: makeFilter(), + id: "blocked-defaults", + handler: { _ in }, + onEOSE: { eoseCount += 1 } + ) + + XCTAssertEqual(eoseCount, 1) + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + + context.permissionSubject.send(.authorized) + let connected = await waitUntil { + context.sessionFactory.allConnections.count == 5 && + context.manager.relays.allSatisfy(\.isConnected) + } + XCTAssertTrue(connected) + XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.sentStrings.isEmpty }) + } + + func test_permissionRevocation_clearsQueuedDefaultSubscriptions() async { + let context = makeContext( + permission: .authorized, + userTorEnabled: true, + torEnforced: true, + torIsReady: true, + torIsForeground: false + ) + let defaultRelay = "wss://relay.damus.io" + + context.manager.subscribe(filter: makeFilter(), id: "queued-default", handler: { _ in }) + + let queued = await waitUntil { + context.manager.debugPendingSubscriptionCount(for: defaultRelay) == 1 + } + XCTAssertTrue(queued) + + context.permissionSubject.send(.denied) + + let cleared = await waitUntil { + context.manager.debugPendingSubscriptionCount(for: defaultRelay) == 0 && + context.manager.relays.isEmpty + } + XCTAssertTrue(cleared) + } + func test_unsubscribe_allowsResubscribeWithSameID() async { let relayURL = "wss://subscribe.example" let context = makeContext(permission: .denied) @@ -136,6 +323,88 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertEqual(receivedEvent?.id, event.id) } + func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws { + let relayURL = "wss://missing-handler.example" + let context = makeContext(permission: .denied) + let event = try makeSignedEvent(content: "unhandled") + + context.manager.ensureConnections(to: [relayURL]) + let connected = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL) != nil && + context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true + } + XCTAssertTrue(connected) + + try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "missing", event: event) + + let counted = await waitUntil { + context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1 + } + XCTAssertTrue(counted) + } + + func test_noticeAndMalformedMessages_keepReceiveLoopAliveForLaterEvents() async throws { + let relayURL = "wss://parser.example" + let context = makeContext(permission: .denied) + var receivedIDs: [String] = [] + let firstEvent = try makeSignedEvent(content: "after notice") + let secondEvent = try makeSignedEvent(content: "after malformed") + + context.manager.subscribe(filter: makeFilter(), id: "parser", relayUrls: [relayURL]) { event in + receivedIDs.append(event.id) + } + let subscribed = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 + } + XCTAssertTrue(subscribed) + + try context.sessionFactory.latestConnection(for: relayURL)?.emitNotice(message: "ignored") + try? await Task.sleep(nanoseconds: 20_000_000) + try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "parser", event: firstEvent) + + let firstDelivered = await waitUntil { + receivedIDs == [firstEvent.id] + } + XCTAssertTrue(firstDelivered) + + try context.sessionFactory.latestConnection(for: relayURL)?.emitRawString("not-json") + try? await Task.sleep(nanoseconds: 20_000_000) + try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "parser", event: secondEvent) + + let secondDelivered = await waitUntil { + receivedIDs == [firstEvent.id, secondEvent.id] + } + XCTAssertTrue(secondDelivered) + } + + func test_okMessages_clearPendingGiftWrapIDs() async throws { + let relayURL = "wss://ok.example" + let context = makeContext(permission: .denied) + let successID = "gift-wrap-success" + let failureID = "gift-wrap-failure" + + context.manager.ensureConnections(to: [relayURL]) + let connected = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL) != nil && + context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true + } + XCTAssertTrue(connected) + + NostrRelayManager.registerPendingGiftWrap(id: successID) + try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: successID, success: true, reason: "ok") + let successCleared = await waitUntil { + !NostrRelayManager.pendingGiftWrapIDs.contains(successID) + } + XCTAssertTrue(successCleared) + + NostrRelayManager.registerPendingGiftWrap(id: failureID) + try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: failureID, success: false, reason: "rejected") + let failureCleared = await waitUntil { + !NostrRelayManager.pendingGiftWrapIDs.contains(failureID) + } + XCTAssertTrue(failureCleared) + } + func test_eoseCallback_waitsForAllTargetedRelays() async throws { let relayOne = "wss://one.example" let relayTwo = "wss://two.example" @@ -166,6 +435,32 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertTrue(eoseCompleted) } + func test_eoseTimeout_invokesCallbackOnceAndIgnoresLateEOSE() async throws { + let relayURL = "wss://timeout.example" + let context = makeContext(permission: .denied) + var eoseCount = 0 + + context.manager.subscribe( + filter: makeFilter(), + id: "timeout", + relayUrls: [relayURL], + handler: { _ in }, + onEOSE: { eoseCount += 1 } + ) + + let subscribed = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 + } + XCTAssertTrue(subscribed) + + let timedOut = await waitUntil(timeout: 3.0) { eoseCount == 1 } + XCTAssertTrue(timedOut) + + try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: "timeout") + try? await Task.sleep(nanoseconds: 20_000_000) + XCTAssertEqual(eoseCount, 1) + } + func test_receiveFailure_schedulesReconnectWithBackoff() async { let relayURL = "wss://retry.example" let context = makeContext(permission: .denied) @@ -195,6 +490,30 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertTrue(retried) } + func test_receiveFailure_whenActivationBecomesDisallowedDoesNotScheduleReconnect() async { + let relayURL = "wss://no-retry.example" + let context = makeContext(permission: .denied) + + context.manager.ensureConnections(to: [relayURL]) + let connected = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL) != nil && + context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true + } + XCTAssertTrue(connected) + + context.activationAllowed.value = false + context.sessionFactory.latestConnection(for: relayURL)?.fail( + error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + ) + + let disconnected = await waitUntil { + context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == false + } + XCTAssertTrue(disconnected) + XCTAssertTrue(context.scheduler.scheduled.isEmpty) + XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1) + } + func test_disconnect_invalidatesScheduledReconnectGeneration() async { let relayURL = "wss://disconnect.example" let context = makeContext(permission: .denied) @@ -219,13 +538,172 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertEqual(context.sessionFactory.requestedURLs.count, requestCountBeforeDisconnect) } + func test_retryConnection_cancelsActiveConnectionBeforeReconnecting() async { + let relayURL = "wss://retry-now.example" + let context = makeContext(permission: .denied) + + context.manager.ensureConnections(to: [relayURL]) + let connected = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL) != nil && + context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true + } + XCTAssertTrue(connected) + + guard let firstConnection = context.sessionFactory.latestConnection(for: relayURL) else { + XCTFail("Expected initial connection") + return + } + let initialRequestCount = context.sessionFactory.requestedURLs.count + + context.manager.retryConnection(to: relayURL) + + let reconnected = await waitUntil { + guard let latest = context.sessionFactory.latestConnection(for: relayURL) else { return false } + return context.sessionFactory.requestedURLs.count == initialRequestCount + 1 && + latest !== firstConnection + } + XCTAssertTrue(reconnected) + XCTAssertEqual(firstConnection.cancelCallCount, 1) + } + + func test_retryConnection_whenTorReadinessFailsDoesNotReconnect() async { + let relayURL = "wss://retry-tor.example" + let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: true) + + context.manager.ensureConnections(to: [relayURL]) + let connected = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL) != nil && + context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true + } + XCTAssertTrue(connected) + + guard let firstConnection = context.sessionFactory.latestConnection(for: relayURL) else { + XCTFail("Expected initial connection") + return + } + + let initialRequestCount = context.sessionFactory.requestedURLs.count + context.torWaiter.isReady = false + context.manager.retryConnection(to: relayURL) + + XCTAssertEqual(firstConnection.cancelCallCount, 1) + XCTAssertEqual(context.sessionFactory.requestedURLs.count, initialRequestCount) + + context.torWaiter.resolve(false) + try? await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertEqual(context.sessionFactory.requestedURLs.count, initialRequestCount) + } + + func test_resetAllConnections_clearsRelayStateAndReconnects() async { + let relayURL = "wss://reset.example" + let context = makeContext(permission: .denied) + + context.manager.ensureConnections(to: [relayURL]) + let connected = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL) != nil && + context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true + } + XCTAssertTrue(connected) + + context.sessionFactory.latestConnection(for: relayURL)?.fail( + error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + ) + let failed = await waitUntil { + context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 1 && + context.manager.relays.first(where: { $0.url == relayURL })?.lastError != nil + } + XCTAssertTrue(failed) + + let requestCountBeforeReset = context.sessionFactory.requestedURLs.count + context.manager.resetAllConnections() + + let reset = await waitUntil { + context.sessionFactory.requestedURLs.count == requestCountBeforeReset + 1 && + context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true && + context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 0 && + context.manager.relays.first(where: { $0.url == relayURL })?.nextReconnectTime == nil && + context.manager.relays.first(where: { $0.url == relayURL })?.lastError == nil + } + XCTAssertTrue(reset) + } + + func test_debugFlushMessageQueue_flushesAllConnectedRelays() async throws { + let relayOne = "wss://flush-one.example" + let relayTwo = "wss://flush-two.example" + let context = makeContext( + permission: .denied, + userTorEnabled: true, + torEnforced: true, + torIsReady: true, + torIsForeground: false + ) + let event = try makeSignedEvent(content: "flush-all") + + context.manager.sendEvent(event, to: [relayOne, relayTwo]) + let queued = await waitUntil { + context.manager.debugPendingMessageQueueCount == 1 + } + XCTAssertTrue(queued) + + context.torForeground.value = true + context.manager.ensureConnections(to: [relayOne, relayTwo]) + context.manager.debugFlushMessageQueue() + + let flushed = await waitUntil { + context.manager.debugPendingMessageQueueCount == 0 && + context.sessionFactory.latestConnection(for: relayOne)?.sentStrings.count == 1 && + context.sessionFactory.latestConnection(for: relayTwo)?.sentStrings.count == 1 + } + XCTAssertTrue(flushed) + } + + func test_dnsPingFailure_marksRelayPermanentCallsEOSEImmediatelyAndManualRetryReconnects() async { + let relayURL = "wss://dns-failure.example" + let context = makeContext(permission: .denied) + context.sessionFactory.pingErrorByURL[relayURL] = NSError( + domain: NSURLErrorDomain, + code: NSURLErrorCannotFindHost, + userInfo: [NSLocalizedDescriptionKey: "DNS failure"] + ) + + context.manager.subscribe(filter: makeFilter(), id: "dns-sub", relayUrls: [relayURL], handler: { _ in }) + let permanentlyFailed = await waitUntil { + context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == TransportConfig.nostrRelayMaxReconnectAttempts && + context.scheduler.scheduled.isEmpty + } + XCTAssertTrue(permanentlyFailed) + + var immediateEOSE = 0 + context.manager.subscribe( + filter: makeFilter(), + id: "dns-eose", + relayUrls: [relayURL], + handler: { _ in }, + onEOSE: { immediateEOSE += 1 } + ) + XCTAssertEqual(immediateEOSE, 1) + + context.sessionFactory.pingErrorByURL[relayURL] = nil + let requestCountBeforeRetry = context.sessionFactory.requestedURLs.count + context.manager.retryConnection(to: relayURL) + + let reconnected = await waitUntil { + context.sessionFactory.requestedURLs.count == requestCountBeforeRetry + 1 && + context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true && + context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 0 + } + XCTAssertTrue(reconnected) + } + private func makeContext( permission: LocationChannelManager.PermissionState, favorites: Set = [], activationAllowed: Bool = true, userTorEnabled: Bool = false, torEnforced: Bool = false, - torIsReady: Bool = true + torIsReady: Bool = true, + torIsForeground: Bool = true ) -> RelayManagerTestContext { let permissionSubject = CurrentValueSubject(permission) let favoritesSubject = CurrentValueSubject, Never>(favorites) @@ -233,9 +711,11 @@ final class NostrRelayManagerTests: XCTestCase { let scheduler = MockRelayScheduler() let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000)) let torWaiter = MockTorWaiter(isReady: torIsReady) + let torForeground = MutableBool(value: torIsForeground) + let activationFlag = MutableBool(value: activationAllowed) let manager = NostrRelayManager( dependencies: NostrRelayManagerDependencies( - activationAllowed: { activationAllowed }, + activationAllowed: { activationFlag.value }, userTorEnabled: { userTorEnabled }, hasMutualFavorites: { !favoritesSubject.value.isEmpty }, hasLocationPermission: { permissionSubject.value == .authorized }, @@ -243,10 +723,12 @@ final class NostrRelayManagerTests: XCTestCase { locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(), torEnforced: { torEnforced }, torIsReady: { torWaiter.isReady }, - torIsForeground: { true }, + torIsForeground: { torForeground.value }, awaitTorReady: torWaiter.await(completion:), makeSession: { sessionFactory }, - scheduleAfter: scheduler.schedule(delay:action:), + scheduleAfter: { delay, action in + scheduler.schedule(delay: delay, action: action) + }, now: { clock.now } ) ) @@ -257,7 +739,9 @@ final class NostrRelayManagerTests: XCTestCase { sessionFactory: sessionFactory, scheduler: scheduler, clock: clock, - torWaiter: torWaiter + activationAllowed: activationFlag, + torWaiter: torWaiter, + torForeground: torForeground ) } @@ -303,7 +787,9 @@ private struct RelayManagerTestContext { let sessionFactory: MockRelaySessionFactory let scheduler: MockRelayScheduler let clock: MutableClock + let activationAllowed: MutableBool let torWaiter: MockTorWaiter + let torForeground: MutableBool } private final class MutableClock { @@ -314,6 +800,14 @@ private final class MutableClock { } } +private final class MutableBool { + var value: Bool + + init(value: Bool) { + self.value = value + } +} + private final class MockTorWaiter { private var completions: [(Bool) -> Void] = [] var isReady: Bool @@ -334,15 +828,15 @@ private final class MockTorWaiter { } } -private final class MockRelayScheduler { +private final class MockRelayScheduler: @unchecked Sendable { struct ScheduledAction { let delay: TimeInterval - let action: () -> Void + let action: @Sendable () -> Void } private(set) var scheduled: [ScheduledAction] = [] - func schedule(delay: TimeInterval, action: @escaping () -> Void) { + func schedule(delay: TimeInterval, action: @escaping @Sendable () -> Void) { scheduled.append(ScheduledAction(delay: delay, action: action)) } @@ -356,6 +850,8 @@ private final class MockRelayScheduler { private final class MockRelaySessionFactory: NostrRelaySessionProtocol { private(set) var requestedURLs: [String] = [] private(set) var connectionsByURL: [String: [MockRelayConnection]] = [:] + var pingErrorByURL: [String: Error?] = [:] + var sendErrorByURL: [String: Error?] = [:] var allConnections: [MockRelayConnection] { connectionsByURL.values.flatMap { $0 } @@ -363,7 +859,11 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol { func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol { requestedURLs.append(url.absoluteString) - let connection = MockRelayConnection(url: url.absoluteString) + let connection = MockRelayConnection( + url: url.absoluteString, + pingError: pingErrorByURL[url.absoluteString] ?? nil, + sendError: sendErrorByURL[url.absoluteString] ?? nil + ) connectionsByURL[url.absoluteString, default: []].append(connection) return connection } @@ -375,6 +875,8 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol { private final class MockRelayConnection: NostrRelayConnectionProtocol { private let url: String + private let pingError: Error? + private let sendError: Error? private var receiveHandler: ((Result) -> Void)? private(set) var resumeCallCount = 0 private(set) var cancelCallCount = 0 @@ -390,8 +892,10 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol { } } - init(url: String) { + init(url: String, pingError: Error? = nil, sendError: Error? = nil) { self.url = url + self.pingError = pingError + self.sendError = sendError } func resume() { @@ -404,7 +908,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol { func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) { sentMessages.append(message) - completionHandler(nil) + completionHandler(sendError) } func receive(completionHandler: @escaping (Result) -> Void) { @@ -412,7 +916,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol { } func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) { - pongReceiveHandler(nil) + pongReceiveHandler(pingError) } func fail(error: Error) { @@ -432,6 +936,20 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol { try emit(jsonObject: ["EOSE", subscriptionID]) } + func emitOK(eventID: String, success: Bool, reason: String) throws { + try emit(jsonObject: ["OK", eventID, success, reason]) + } + + func emitNotice(message: String) throws { + try emit(jsonObject: ["NOTICE", message]) + } + + func emitRawString(_ string: String) throws { + let handler = receiveHandler + receiveHandler = nil + handler?(.success(.string(string))) + } + private func emit(jsonObject: Any) throws { let data = try JSONSerialization.data(withJSONObject: jsonObject) let handler = receiveHandler diff --git a/bitchatTests/Services/SecureIdentityStateManagerTests.swift b/bitchatTests/Services/SecureIdentityStateManagerTests.swift index 1024d8cf..62507186 100644 --- a/bitchatTests/Services/SecureIdentityStateManagerTests.swift +++ b/bitchatTests/Services/SecureIdentityStateManagerTests.swift @@ -3,6 +3,57 @@ import XCTest @testable import bitchat final class SecureIdentityStateManagerTests: XCTestCase { + func test_upsertCryptographicIdentity_withoutClaimedNicknameDoesNotCreateSocialIdentity() async { + let manager = SecureIdentityStateManager(MockKeychain()) + let fingerprint = String(repeating: "aa", count: 32) + let peerID = PeerID(str: String(fingerprint.prefix(16))) + + manager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: Data(repeating: 0x11, count: 32), + signingPublicKey: Data(repeating: 0x22, count: 32), + claimedNickname: nil + ) + + let inserted = await waitUntil { + manager.getCryptoIdentitiesByPeerIDPrefix(peerID).count == 1 + } + XCTAssertTrue(inserted) + XCTAssertNil(manager.getSocialIdentity(for: fingerprint)) + } + + func test_upsertCryptographicIdentity_updatesExistingKeyAndPreservesSigningKey() async { + let manager = SecureIdentityStateManager(MockKeychain()) + let fingerprint = String(repeating: "ab", count: 32) + let peerID = PeerID(str: String(fingerprint.prefix(16))) + let originalNoiseKey = Data(repeating: 0x11, count: 32) + let updatedNoiseKey = Data(repeating: 0x33, count: 32) + let signingKey = Data(repeating: 0x22, count: 32) + + manager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: originalNoiseKey, + signingPublicKey: signingKey, + claimedNickname: nil + ) + _ = await waitUntil { + manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.publicKey == originalNoiseKey + } + + manager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: updatedNoiseKey, + signingPublicKey: nil, + claimedNickname: nil + ) + + let updated = await waitUntil { + guard let identity = manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first else { return false } + return identity.publicKey == updatedNoiseKey && identity.signingPublicKey == signingKey + } + XCTAssertTrue(updated) + } + func test_upsertCryptographicIdentity_tracksByPeerIDPrefixAndClaimedNickname() async { let manager = SecureIdentityStateManager(MockKeychain()) let noisePublicKey = Data(repeating: 0x11, count: 32) @@ -43,6 +94,12 @@ final class SecureIdentityStateManagerTests: XCTestCase { XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Unknown") } + func test_isBlocked_unknownFingerprintReturnsFalse() { + let manager = SecureIdentityStateManager(MockKeychain()) + + XCTAssertFalse(manager.isBlocked(fingerprint: String(repeating: "ff", count: 32))) + } + func test_setVerified_updatesTrustLevelAndVerifiedSet() async { let manager = SecureIdentityStateManager(MockKeychain()) let fingerprint = String(repeating: "cd", count: 32) @@ -71,6 +128,209 @@ final class SecureIdentityStateManagerTests: XCTestCase { XCTAssertTrue(reloaded.isFavorite(fingerprint: fingerprint)) } + func test_updateSocialIdentity_reindexesClaimedNickname() async { + let manager = SecureIdentityStateManager(MockKeychain()) + let fingerprint = String(repeating: "34", count: 32) + + manager.updateSocialIdentity( + SocialIdentity( + fingerprint: fingerprint, + localPetname: nil, + claimedNickname: "Alice", + trustLevel: .unknown, + isFavorite: false, + isBlocked: false, + notes: nil + ) + ) + let initialIndexed = await waitUntil { + manager.debugNicknameIndex["Alice"]?.contains(fingerprint) == true + } + XCTAssertTrue(initialIndexed) + + manager.updateSocialIdentity( + SocialIdentity( + fingerprint: fingerprint, + localPetname: "Friend", + claimedNickname: "Bob", + trustLevel: .trusted, + isFavorite: true, + isBlocked: false, + notes: "updated" + ) + ) + + let reindexed = await waitUntil { + manager.debugNicknameIndex["Alice"]?.contains(fingerprint) != true && + manager.debugNicknameIndex["Bob"]?.contains(fingerprint) == true + } + XCTAssertTrue(reindexed) + XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Bob") + } + + func test_upsertCryptographicIdentity_sameClaimedNicknamePreservesExistingSocialIdentity() async { + let manager = SecureIdentityStateManager(MockKeychain()) + let fingerprint = String(repeating: "35", count: 32) + + manager.updateSocialIdentity( + SocialIdentity( + fingerprint: fingerprint, + localPetname: "Pal", + claimedNickname: "Alice", + trustLevel: .trusted, + isFavorite: true, + isBlocked: false, + notes: "keep me" + ) + ) + _ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil } + + manager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: Data(repeating: 0x11, count: 32), + signingPublicKey: Data(repeating: 0x22, count: 32), + claimedNickname: "Alice" + ) + + let inserted = await waitUntil { + manager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: String(fingerprint.prefix(16)))).count == 1 + } + XCTAssertTrue(inserted) + XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.localPetname, "Pal") + XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.notes, "keep me") + XCTAssertTrue(manager.getSocialIdentity(for: fingerprint)?.isFavorite == true) + } + + func test_getFavorites_returnsOnlyFavoritedFingerprints() async { + let manager = SecureIdentityStateManager(MockKeychain()) + let favoriteOne = String(repeating: "45", count: 32) + let favoriteTwo = String(repeating: "56", count: 32) + let other = String(repeating: "67", count: 32) + + manager.setFavorite(favoriteOne, isFavorite: true) + manager.setFavorite(favoriteTwo, isFavorite: true) + manager.setFavorite(other, isFavorite: false) + + let favoritesLoaded = await waitUntil { + manager.getFavorites() == Set([favoriteOne, favoriteTwo]) + } + XCTAssertTrue(favoritesLoaded) + } + + func test_setFavorite_existingIdentityCanBeClearedWithoutChangingNickname() async { + let manager = SecureIdentityStateManager(MockKeychain()) + let fingerprint = String(repeating: "68", count: 32) + + manager.updateSocialIdentity( + SocialIdentity( + fingerprint: fingerprint, + localPetname: nil, + claimedNickname: "Alice", + trustLevel: .trusted, + isFavorite: false, + isBlocked: false, + notes: nil + ) + ) + _ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil } + + manager.setFavorite(fingerprint, isFavorite: true) + _ = await waitUntil { manager.isFavorite(fingerprint: fingerprint) } + + manager.setFavorite(fingerprint, isFavorite: false) + let cleared = await waitUntil { + !manager.isFavorite(fingerprint: fingerprint) && + manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "Alice" && + manager.getSocialIdentity(for: fingerprint)?.trustLevel == .trusted + } + XCTAssertTrue(cleared) + } + + func test_setBlocked_createsIdentityAndCanLaterUnblock() async { + let manager = SecureIdentityStateManager(MockKeychain()) + let fingerprint = String(repeating: "78", count: 32) + + manager.setBlocked(fingerprint, isBlocked: true) + let blocked = await waitUntil { + manager.isBlocked(fingerprint: fingerprint) + } + XCTAssertTrue(blocked) + XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Unknown") + + manager.setBlocked(fingerprint, isBlocked: false) + let unblocked = await waitUntil { + !manager.isBlocked(fingerprint: fingerprint) + } + XCTAssertTrue(unblocked) + } + + func test_setVerified_false_downgradesTrustLevelToCasual() async { + let manager = SecureIdentityStateManager(MockKeychain()) + let fingerprint = String(repeating: "89", count: 32) + + manager.updateSocialIdentity( + SocialIdentity( + fingerprint: fingerprint, + localPetname: nil, + claimedNickname: "Verifier", + trustLevel: .trusted, + isFavorite: false, + isBlocked: false, + notes: nil + ) + ) + _ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil } + + manager.setVerified(fingerprint: fingerprint, verified: true) + _ = await waitUntil { manager.isVerified(fingerprint: fingerprint) } + + manager.setVerified(fingerprint: fingerprint, verified: false) + let downgraded = await waitUntil { + !manager.isVerified(fingerprint: fingerprint) && + manager.getSocialIdentity(for: fingerprint)?.trustLevel == .casual + } + XCTAssertTrue(downgraded) + } + + func test_ephemeralSessionLifecycle_tracksHandshakeProgressAndLastInteraction() async { + let manager = SecureIdentityStateManager(MockKeychain()) + let peerID = PeerID(str: "1234567890abcdef") + let fingerprint = String(repeating: "90", count: 32) + + manager.registerEphemeralSession(peerID: peerID, handshakeState: .initiated) + let registered = await waitUntil { + if case .initiated? = manager.debugEphemeralSession(for: peerID)?.handshakeState { + return true + } + return false + } + XCTAssertTrue(registered) + + manager.updateHandshakeState(peerID: peerID, state: .inProgress) + let progressed = await waitUntil { + if case .inProgress? = manager.debugEphemeralSession(for: peerID)?.handshakeState { + return true + } + return false + } + XCTAssertTrue(progressed) + + manager.updateHandshakeState(peerID: peerID, state: .completed(fingerprint: fingerprint)) + let completed = await waitUntil { + if case .completed(let completedFingerprint)? = manager.debugEphemeralSession(for: peerID)?.handshakeState { + return completedFingerprint == fingerprint && manager.debugLastInteraction(for: fingerprint) != nil + } + return false + } + XCTAssertTrue(completed) + + manager.removeEphemeralSession(peerID: peerID) + let removed = await waitUntil { + manager.debugEphemeralSession(for: peerID) == nil + } + XCTAssertTrue(removed) + } + func test_setNostrBlocked_normalizesToLowercaseAndPersists() async { let keychain = MockKeychain() let manager = SecureIdentityStateManager(keychain) @@ -88,6 +348,21 @@ final class SecureIdentityStateManagerTests: XCTestCase { XCTAssertTrue(reloaded.isNostrBlocked(pubkeyHexLowercased: pubkey)) } + func test_setNostrBlocked_falseRemovesExistingKey() async { + let manager = SecureIdentityStateManager(MockKeychain()) + let pubkey = "ABCDEF1234" + + manager.setNostrBlocked(pubkey, isBlocked: true) + _ = await waitUntil { manager.isNostrBlocked(pubkeyHexLowercased: pubkey) } + + manager.setNostrBlocked(pubkey, isBlocked: false) + let cleared = await waitUntil { + !manager.isNostrBlocked(pubkeyHexLowercased: pubkey) && + manager.getBlockedNostrPubkeys().isEmpty + } + XCTAssertTrue(cleared) + } + func test_corruptPersistedCache_fallsBackToEmptyState() { let keychain = MockKeychain() _ = keychain.saveIdentityKey(Data(repeating: 0x01, count: 32), forKey: "identityCacheEncryptionKey") @@ -122,6 +397,21 @@ final class SecureIdentityStateManagerTests: XCTestCase { XCTAssertTrue(cleared) } + func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async { + let keychain = FailingCacheSaveKeychain() + let manager = SecureIdentityStateManager(keychain) + let fingerprint = String(repeating: "de", count: 32) + + manager.setFavorite(fingerprint, isFavorite: true) + let primed = await waitUntil { manager.isFavorite(fingerprint: fingerprint) } + XCTAssertTrue(primed) + + manager.forceSave() + + let reloaded = SecureIdentityStateManager(keychain) + XCTAssertFalse(reloaded.isFavorite(fingerprint: fingerprint)) + } + private func waitUntil( timeout: TimeInterval = 1.0, condition: @escaping () -> Bool @@ -136,3 +426,72 @@ final class SecureIdentityStateManagerTests: XCTestCase { return condition() } } + +private final class FailingCacheSaveKeychain: KeychainManagerProtocol { + private var storage: [String: Data] = [:] + private var serviceStorage: [String: [String: Data]] = [:] + + func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { + if key == "bitchat.identityCache.v2" { + return false + } + storage[key] = keyData + return true + } + + func getIdentityKey(forKey key: String) -> Data? { + storage[key] + } + + func deleteIdentityKey(forKey key: String) -> Bool { + storage.removeValue(forKey: key) + return true + } + + func deleteAllKeychainData() -> Bool { + storage.removeAll() + serviceStorage.removeAll() + return true + } + + func secureClear(_ data: inout Data) { + data = Data() + } + + func secureClear(_ string: inout String) { + string = "" + } + + func verifyIdentityKeyExists() -> Bool { + storage["identity_noiseStaticKey"] != nil + } + + func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { + if let data = storage[key] { + return .success(data) + } + return .itemNotFound + } + + func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { + if saveIdentityKey(keyData, forKey: key) { + return .success + } + return .otherError(OSStatus(-1)) + } + + func save(key: String, data: Data, service: String, accessible: CFString?) { + if serviceStorage[service] == nil { + serviceStorage[service] = [:] + } + serviceStorage[service]?[key] = data + } + + func load(key: String, service: String) -> Data? { + serviceStorage[service]?[key] + } + + func delete(key: String, service: String) { + serviceStorage[service]?.removeValue(forKey: key) + } +} diff --git a/bitchatTests/Sync/RequestSyncManagerTests.swift b/bitchatTests/Sync/RequestSyncManagerTests.swift new file mode 100644 index 00000000..572cccf7 --- /dev/null +++ b/bitchatTests/Sync/RequestSyncManagerTests.swift @@ -0,0 +1,85 @@ +import XCTest +@testable import bitchat + +final class RequestSyncManagerTests: XCTestCase { + func test_isValidResponse_returnsFalseWhenPacketIsNotRSR() { + let clock = MutableSyncClock(now: 1_000) + let manager = RequestSyncManager(responseWindow: 30, now: { clock.now }) + + manager.registerRequest(to: PeerID(str: "aaaaaaaaaaaaaaaa")) + XCTAssertFalse(manager.isValidResponse(from: PeerID(str: "aaaaaaaaaaaaaaaa"), isRSR: false)) + } + + func test_isValidResponse_returnsFalseForUnsolicitedRSR() { + let clock = MutableSyncClock(now: 1_000) + let manager = RequestSyncManager(responseWindow: 30, now: { clock.now }) + + XCTAssertFalse(manager.isValidResponse(from: PeerID(str: "bbbbbbbbbbbbbbbb"), isRSR: true)) + } + + func test_isValidResponse_acceptsRecentRequest() async { + let clock = MutableSyncClock(now: 1_000) + let manager = RequestSyncManager(responseWindow: 30, now: { clock.now }) + let peerID = PeerID(str: "cccccccccccccccc") + + manager.registerRequest(to: peerID) + let registered = await waitUntil { + manager.debugPendingRequestCount == 1 + } + XCTAssertTrue(registered) + + clock.now = 1_020 + XCTAssertTrue(manager.isValidResponse(from: peerID, isRSR: true)) + } + + func test_cleanup_removesExpiredRequestsAndPreservesFreshOnes() async { + let clock = MutableSyncClock(now: 1_000) + let manager = RequestSyncManager(responseWindow: 30, now: { clock.now }) + let expiredPeer = PeerID(str: "dddddddddddddddd") + let freshPeer = PeerID(str: "eeeeeeeeeeeeeeee") + + manager.registerRequest(to: expiredPeer) + _ = await waitUntil { manager.debugPendingRequestCount == 1 } + + clock.now = 1_010 + manager.registerRequest(to: freshPeer) + let bothRegistered = await waitUntil { + manager.debugPendingRequestCount == 2 + } + XCTAssertTrue(bothRegistered) + + clock.now = 1_035 + XCTAssertFalse(manager.isValidResponse(from: expiredPeer, isRSR: true)) + XCTAssertTrue(manager.isValidResponse(from: freshPeer, isRSR: true)) + + manager.cleanup() + let cleaned = await waitUntil { + manager.debugPendingRequestCount == 1 + } + XCTAssertTrue(cleaned) + XCTAssertFalse(manager.isValidResponse(from: expiredPeer, isRSR: true)) + XCTAssertTrue(manager.isValidResponse(from: freshPeer, isRSR: true)) + } + + 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() + } +} + +private final class MutableSyncClock { + var now: TimeInterval + + init(now: TimeInterval) { + self.now = now + } +}