mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:05:20 +00:00
Expand Nostr and identity coverage (#1059)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -331,16 +331,15 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
|
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
||||||
self.cache.socialIdentities[identity.fingerprint] = identity
|
self.cache.socialIdentities[identity.fingerprint] = identity
|
||||||
|
|
||||||
// Update nickname index
|
// Update nickname index
|
||||||
if let existingIdentity = self.cache.socialIdentities[identity.fingerprint] {
|
if let previousClaimedNickname,
|
||||||
// Remove old nickname from index if changed
|
previousClaimedNickname != identity.claimedNickname {
|
||||||
if existingIdentity.claimedNickname != identity.claimedNickname {
|
self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint)
|
||||||
self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint)
|
if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true {
|
||||||
if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true {
|
self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname)
|
||||||
self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,4 +531,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
return cache.verifiedFingerprints
|
return cache.verifiedFingerprints
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var debugNicknameIndex: [String: Set<String>] {
|
||||||
|
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] }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,108 @@ import AppKit
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
/// 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
|
@MainActor
|
||||||
final class GeoRelayDirectory {
|
final class GeoRelayDirectory {
|
||||||
|
private final class CleanupState {
|
||||||
|
let notificationCenter: NotificationCenter
|
||||||
|
var observers: [NSObjectProtocol] = []
|
||||||
|
var refreshTimer: Timer?
|
||||||
|
var retryTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(notificationCenter: NotificationCenter) {
|
||||||
|
self.notificationCenter = notificationCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
observers.forEach { notificationCenter.removeObserver($0) }
|
||||||
|
refreshTimer?.invalidate()
|
||||||
|
retryTask?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct Entry: Hashable {
|
struct Entry: Hashable {
|
||||||
let host: String
|
let host: String
|
||||||
let lat: Double
|
let lat: Double
|
||||||
@@ -19,28 +119,33 @@ final class GeoRelayDirectory {
|
|||||||
static let shared = GeoRelayDirectory()
|
static let shared = GeoRelayDirectory()
|
||||||
|
|
||||||
private(set) var entries: [Entry] = []
|
private(set) var entries: [Entry] = []
|
||||||
private let cacheFileName = "georelays_cache.csv"
|
|
||||||
private let lastFetchKey = "georelay.lastFetchAt"
|
private let lastFetchKey = "georelay.lastFetchAt"
|
||||||
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
private let dependencies: GeoRelayDirectoryDependencies
|
||||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
|
private let cleanupState: CleanupState
|
||||||
|
|
||||||
private var refreshTimer: Timer?
|
|
||||||
private var retryTask: Task<Void, Never>?
|
|
||||||
private var retryAttempt: Int = 0
|
private var retryAttempt: Int = 0
|
||||||
private var isFetching: Bool = false
|
private var isFetching: Bool = false
|
||||||
private var observers: [NSObjectProtocol] = []
|
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
|
self.dependencies = .live()
|
||||||
|
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
||||||
entries = loadLocalEntries()
|
entries = loadLocalEntries()
|
||||||
|
if dependencies.autoStart {
|
||||||
registerObservers()
|
registerObservers()
|
||||||
startRefreshTimer()
|
startRefreshTimer()
|
||||||
prefetchIfNeeded()
|
prefetchIfNeeded()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
deinit {
|
internal init(dependencies: GeoRelayDirectoryDependencies) {
|
||||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
self.dependencies = dependencies
|
||||||
refreshTimer?.invalidate()
|
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
||||||
retryTask?.cancel()
|
entries = loadLocalEntries()
|
||||||
|
if dependencies.autoStart {
|
||||||
|
registerObservers()
|
||||||
|
startRefreshTimer()
|
||||||
|
prefetchIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||||
@@ -83,13 +188,13 @@ final class GeoRelayDirectory {
|
|||||||
func prefetchIfNeeded(force: Bool = false) {
|
func prefetchIfNeeded(force: Bool = false) {
|
||||||
guard !isFetching else { return }
|
guard !isFetching else { return }
|
||||||
|
|
||||||
let now = Date()
|
let now = dependencies.now()
|
||||||
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||||
|
|
||||||
if !force {
|
if !force {
|
||||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return }
|
||||||
} else if last != .distantPast,
|
} else if last != .distantPast,
|
||||||
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
|
now.timeIntervalSince(last) < dependencies.retryInitialSeconds {
|
||||||
// Skip forced fetches if we just refreshed moments ago.
|
// Skip forced fetches if we just refreshed moments ago.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -103,36 +208,36 @@ final class GeoRelayDirectory {
|
|||||||
isFetching = true
|
isFetching = true
|
||||||
|
|
||||||
let request = URLRequest(
|
let request = URLRequest(
|
||||||
url: remoteURL,
|
url: dependencies.remoteURL,
|
||||||
cachePolicy: .reloadIgnoringLocalCacheData,
|
cachePolicy: .reloadIgnoringLocalCacheData,
|
||||||
timeoutInterval: 15
|
timeoutInterval: 15
|
||||||
)
|
)
|
||||||
|
|
||||||
Task.detached { [weak self] in
|
Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|
||||||
let ready = await TorManager.shared.awaitReady()
|
let ready = await self.dependencies.awaitTorReady()
|
||||||
if !ready {
|
if !ready {
|
||||||
await self.handleFetchFailure(.torNotReady)
|
self.handleFetchFailure(.torNotReady)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
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 {
|
guard let text = String(data: data, encoding: .utf8) else {
|
||||||
await self.handleFetchFailure(.invalidData)
|
self.handleFetchFailure(.invalidData)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||||
guard !parsed.isEmpty else {
|
guard !parsed.isEmpty else {
|
||||||
await self.handleFetchFailure(.invalidData)
|
self.handleFetchFailure(.invalidData)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await self.handleFetchSuccess(entries: parsed, csv: text)
|
self.handleFetchSuccess(entries: parsed, csv: text)
|
||||||
} catch {
|
} 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) {
|
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||||
entries = parsed
|
entries = parsed
|
||||||
persistCache(csv)
|
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)
|
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||||
isFetching = false
|
isFetching = false
|
||||||
retryAttempt = 0
|
retryAttempt = 0
|
||||||
@@ -171,32 +276,34 @@ final class GeoRelayDirectory {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private func scheduleRetry() {
|
private func scheduleRetry() {
|
||||||
retryAttempt = min(retryAttempt + 1, 10)
|
retryAttempt = min(retryAttempt + 1, 10)
|
||||||
let base = TransportConfig.geoRelayRetryInitialSeconds
|
let base = dependencies.retryInitialSeconds
|
||||||
let maxDelay = TransportConfig.geoRelayRetryMaxSeconds
|
let maxDelay = dependencies.retryMaxSeconds
|
||||||
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
||||||
let calculated = base * multiplier
|
let calculated = base * multiplier
|
||||||
let delay = min(maxDelay, max(base, calculated))
|
let delay = min(maxDelay, max(base, calculated))
|
||||||
|
|
||||||
cancelRetry()
|
cancelRetry()
|
||||||
retryTask = Task { [weak self] in
|
cleanupState.retryTask = Task { [weak self] in
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
guard let self else { return }
|
||||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
await self.dependencies.retrySleep(delay)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self?.prefetchIfNeeded(force: true)
|
self.prefetchIfNeeded(force: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func cancelRetry() {
|
private func cancelRetry() {
|
||||||
retryTask?.cancel()
|
cleanupState.retryTask?.cancel()
|
||||||
retryTask = nil
|
cleanupState.retryTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistCache(_ text: String) {
|
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 {
|
do {
|
||||||
try text.data(using: .utf8)?.write(to: url, options: .atomic)
|
try dependencies.writeData(data, url)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
||||||
}
|
}
|
||||||
@@ -205,22 +312,18 @@ final class GeoRelayDirectory {
|
|||||||
// MARK: - Loading
|
// MARK: - Loading
|
||||||
private func loadLocalEntries() -> [Entry] {
|
private func loadLocalEntries() -> [Entry] {
|
||||||
// Prefer cached file if present
|
// Prefer cached file if present
|
||||||
if let cache = cacheURL(),
|
if let cache = dependencies.cacheURL(),
|
||||||
let data = try? Data(contentsOf: cache),
|
let data = dependencies.readData(cache),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try bundled resource(s)
|
// Try bundled resource(s)
|
||||||
let bundleCandidates = [
|
let bundleCandidates = dependencies.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 }
|
|
||||||
|
|
||||||
for url in bundleCandidates {
|
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 text = String(data: data, encoding: .utf8) {
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
@@ -228,8 +331,8 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try filesystem path (development/test)
|
// Try filesystem path (development/test)
|
||||||
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
if let cwd = dependencies.currentDirectoryPath(),
|
||||||
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
return Self.parseCSV(text)
|
return Self.parseCSV(text)
|
||||||
}
|
}
|
||||||
@@ -259,25 +362,9 @@ final class GeoRelayDirectory {
|
|||||||
return Array(result)
|
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
|
// MARK: - Observers & Timers
|
||||||
private func registerObservers() {
|
private func registerObservers() {
|
||||||
let center = NotificationCenter.default
|
let center = dependencies.notificationCenter
|
||||||
|
|
||||||
let torReady = center.addObserver(
|
let torReady = center.addObserver(
|
||||||
forName: .TorDidBecomeReady,
|
forName: .TorDidBecomeReady,
|
||||||
@@ -289,11 +376,11 @@ final class GeoRelayDirectory {
|
|||||||
self.prefetchIfNeeded(force: true)
|
self.prefetchIfNeeded(force: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
observers.append(torReady)
|
cleanupState.observers.append(torReady)
|
||||||
|
|
||||||
#if os(iOS)
|
if let activeNotificationName = dependencies.activeNotificationName {
|
||||||
let didBecomeActive = center.addObserver(
|
let didBecomeActive = center.addObserver(
|
||||||
forName: UIApplication.didBecomeActiveNotification,
|
forName: activeNotificationName,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
@@ -302,25 +389,13 @@ final class GeoRelayDirectory {
|
|||||||
self.prefetchIfNeeded()
|
self.prefetchIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
observers.append(didBecomeActive)
|
cleanupState.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() {
|
private func startRefreshTimer() {
|
||||||
refreshTimer?.invalidate()
|
cleanupState.refreshTimer?.invalidate()
|
||||||
let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds
|
let interval = dependencies.refreshCheckInterval
|
||||||
guard interval > 0 else { return }
|
guard interval > 0 else { return }
|
||||||
|
|
||||||
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
@@ -329,9 +404,13 @@ final class GeoRelayDirectory {
|
|||||||
self.prefetchIfNeeded()
|
self.prefetchIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refreshTimer = timer
|
cleanupState.refreshTimer = timer
|
||||||
RunLoop.main.add(timer, forMode: .common)
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var debugRetryAttempt: Int { retryAttempt }
|
||||||
|
var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
|
||||||
|
var debugObserverCount: Int { cleanupState.observers.count }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Distance
|
// MARK: - Distance
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ struct NostrRelayManagerDependencies {
|
|||||||
var torIsForeground: () -> Bool
|
var torIsForeground: () -> Bool
|
||||||
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
|
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
|
||||||
var makeSession: () -> NostrRelaySessionProtocol
|
var makeSession: () -> NostrRelaySessionProtocol
|
||||||
var scheduleAfter: (TimeInterval, @escaping () -> Void) -> Void
|
var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
||||||
var now: () -> Date
|
var now: () -> Date
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +412,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
for url in urls where !existingSet.contains(url) {
|
for url in urls where !existingSet.contains(url) {
|
||||||
relays.append(Relay(url: url))
|
relays.append(Relay(url: url))
|
||||||
}
|
}
|
||||||
for url in candidateUrls {
|
for url in urls {
|
||||||
var map = self.pendingSubscriptions[url] ?? [:]
|
var map = self.pendingSubscriptions[url] ?? [:]
|
||||||
map[id] = messageString
|
map[id] = messageString
|
||||||
self.pendingSubscriptions[url] = map
|
self.pendingSubscriptions[url] = map
|
||||||
@@ -471,6 +471,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
connections.removeValue(forKey: url)
|
connections.removeValue(forKey: url)
|
||||||
subscriptions.removeValue(forKey: url)
|
subscriptions.removeValue(forKey: url)
|
||||||
|
pendingSubscriptions.removeValue(forKey: url)
|
||||||
}
|
}
|
||||||
messageQueueLock.lock()
|
messageQueueLock.lock()
|
||||||
for index in (0..<messageQueue.count).reversed() {
|
for index in (0..<messageQueue.count).reversed() {
|
||||||
@@ -784,6 +785,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Schedule reconnection with exponential backoff
|
// Schedule reconnection with exponential backoff
|
||||||
let gen = connectionGeneration
|
let gen = connectionGeneration
|
||||||
dependencies.scheduleAfter(backoffInterval) { [weak self] in
|
dependencies.scheduleAfter(backoffInterval) { [weak self] in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
// Ignore stale scheduled reconnects from a previous generation
|
// Ignore stale scheduled reconnects from a previous generation
|
||||||
guard gen == self.connectionGeneration else { return }
|
guard gen == self.connectionGeneration else { return }
|
||||||
@@ -793,6 +795,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Public Utility Methods
|
// MARK: - Public Utility Methods
|
||||||
|
|
||||||
@@ -803,6 +806,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Reset reconnection attempts
|
// Reset reconnection attempts
|
||||||
relays[index].reconnectAttempts = 0
|
relays[index].reconnectAttempts = 0
|
||||||
relays[index].nextReconnectTime = nil
|
relays[index].nextReconnectTime = nil
|
||||||
|
relays[index].lastError = nil
|
||||||
|
|
||||||
// Disconnect if connected
|
// Disconnect if connected
|
||||||
if let connection = connections[relayUrl] {
|
if let connection = connections[relayUrl] {
|
||||||
@@ -824,6 +828,20 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var debugPendingMessageQueueCount: Int {
|
||||||
|
messageQueueLock.lock()
|
||||||
|
defer { messageQueueLock.unlock() }
|
||||||
|
return messageQueue.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
|
||||||
|
pendingSubscriptions[relayUrl]?.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func debugFlushMessageQueue() {
|
||||||
|
flushMessageQueue(for: nil)
|
||||||
|
}
|
||||||
|
|
||||||
/// Reset all relay connections
|
/// Reset all relay connections
|
||||||
func resetAllConnections() {
|
func resetAllConnections() {
|
||||||
disconnect()
|
disconnect()
|
||||||
|
|||||||
@@ -43,10 +43,7 @@ private final class CLLocationManagerAdapter: NSObject, LocationStateManaging {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var authorizationStatus: CLAuthorizationStatus {
|
var authorizationStatus: CLAuthorizationStatus {
|
||||||
if #available(iOS 14.0, macOS 11.0, *) {
|
base.authorizationStatus
|
||||||
return base.authorizationStatus
|
|
||||||
}
|
|
||||||
return CLLocationManager.authorizationStatus()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestWhenInUseAuthorization() {
|
func requestWhenInUseAuthorization() {
|
||||||
|
|||||||
@@ -17,14 +17,21 @@ final class RequestSyncManager {
|
|||||||
|
|
||||||
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
|
||||||
private var pendingRequests: [PeerID: TimeInterval] = [:]
|
private var pendingRequests: [PeerID: TimeInterval] = [:]
|
||||||
|
private let responseWindow: TimeInterval
|
||||||
|
private let now: () -> TimeInterval
|
||||||
|
|
||||||
// Allow responses for 30s after request
|
init(
|
||||||
private let responseWindow: TimeInterval = 30.0
|
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.
|
/// Register that we are sending a sync request to a peer.
|
||||||
/// - Parameter peerID: The peer we are requesting sync from
|
/// - Parameter peerID: The peer we are requesting sync from
|
||||||
func registerRequest(to peerID: PeerID) {
|
func registerRequest(to peerID: PeerID) {
|
||||||
let now = Date().timeIntervalSince1970
|
let now = self.now()
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))…", category: .sync)
|
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))…", category: .sync)
|
||||||
self.pendingRequests[peerID] = now
|
self.pendingRequests[peerID] = now
|
||||||
@@ -46,7 +53,7 @@ final class RequestSyncManager {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Date().timeIntervalSince1970
|
let now = self.now()
|
||||||
if now - requestTime > responseWindow {
|
if now - requestTime > responseWindow {
|
||||||
SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security)
|
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
|
// 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
|
/// Periodic cleanup of expired requests
|
||||||
func cleanup() {
|
func cleanup() {
|
||||||
let now = Date().timeIntervalSince1970
|
let now = self.now()
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
let originalCount = self.pendingRequests.count
|
let originalCount = self.pendingRequests.count
|
||||||
self.pendingRequests = self.pendingRequests.filter { _, timestamp in
|
self.pendingRequests = self.pendingRequests.filter { _, timestamp in
|
||||||
@@ -71,4 +78,8 @@ final class RequestSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var debugPendingRequestCount: Int {
|
||||||
|
queue.sync { pendingRequests.count }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ struct CommandProcessorTests {
|
|||||||
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
|
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
|
||||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
||||||
|
|
||||||
let result = try await withSelectedChannel(channel) {
|
let result = await withSelectedChannel(channel) {
|
||||||
processor.process("/who")
|
processor.process("/who")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,16 +209,18 @@ extension FragmentationTests {
|
|||||||
private var expectedPublicMessageCount: Int = 0
|
private var expectedPublicMessageCount: Int = 0
|
||||||
private var expectedReceivedMessageCount: Int = 0
|
private var expectedReceivedMessageCount: Int = 0
|
||||||
|
|
||||||
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] {
|
private func withLock<T>(_ body: () -> T) -> T {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
return _publicMessages
|
return body()
|
||||||
|
}
|
||||||
|
|
||||||
|
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] {
|
||||||
|
withLock { _publicMessages }
|
||||||
}
|
}
|
||||||
|
|
||||||
var receivedMessages: [BitchatMessage] {
|
var receivedMessages: [BitchatMessage] {
|
||||||
lock.lock()
|
withLock { _receivedMessages }
|
||||||
defer { lock.unlock() }
|
|
||||||
return _receivedMessages
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceiveMessage(_ message: BitchatMessage) {
|
func didReceiveMessage(_ message: BitchatMessage) {
|
||||||
@@ -255,27 +257,32 @@ extension FragmentationTests {
|
|||||||
|
|
||||||
/// Waits for the specified number of public messages to be received
|
/// Waits for the specified number of public messages to be received
|
||||||
func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
|
func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
|
||||||
lock.lock()
|
let isAlreadySatisfied = withLock { () -> Bool in
|
||||||
if _publicMessages.count >= count {
|
if _publicMessages.count >= count {
|
||||||
lock.unlock()
|
return true
|
||||||
return
|
|
||||||
}
|
}
|
||||||
expectedPublicMessageCount = count
|
expectedPublicMessageCount = count
|
||||||
lock.unlock()
|
return false
|
||||||
|
}
|
||||||
|
if isAlreadySatisfied {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||||
group.addTask {
|
group.addTask {
|
||||||
await withCheckedContinuation { continuation in
|
await withCheckedContinuation { continuation in
|
||||||
self.lock.lock()
|
let shouldResumeImmediately = self.withLock {
|
||||||
// Recheck count after acquiring lock to avoid race condition
|
// Recheck count after acquiring lock to avoid race condition
|
||||||
// where message arrives between initial check and continuation install
|
// where message arrives between initial check and continuation install
|
||||||
if self._publicMessages.count >= count {
|
if self._publicMessages.count >= count {
|
||||||
self.lock.unlock()
|
return true
|
||||||
continuation.resume()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
self.publicMessageContinuation = continuation
|
self.publicMessageContinuation = continuation
|
||||||
self.lock.unlock()
|
return false
|
||||||
|
}
|
||||||
|
if shouldResumeImmediately {
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
group.addTask {
|
group.addTask {
|
||||||
@@ -289,27 +296,32 @@ extension FragmentationTests {
|
|||||||
|
|
||||||
/// Waits for the specified number of received messages
|
/// Waits for the specified number of received messages
|
||||||
func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
|
func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
|
||||||
lock.lock()
|
let isAlreadySatisfied = withLock { () -> Bool in
|
||||||
if _receivedMessages.count >= count {
|
if _receivedMessages.count >= count {
|
||||||
lock.unlock()
|
return true
|
||||||
return
|
|
||||||
}
|
}
|
||||||
expectedReceivedMessageCount = count
|
expectedReceivedMessageCount = count
|
||||||
lock.unlock()
|
return false
|
||||||
|
}
|
||||||
|
if isAlreadySatisfied {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||||
group.addTask {
|
group.addTask {
|
||||||
await withCheckedContinuation { continuation in
|
await withCheckedContinuation { continuation in
|
||||||
self.lock.lock()
|
let shouldResumeImmediately = self.withLock {
|
||||||
// Recheck count after acquiring lock to avoid race condition
|
// Recheck count after acquiring lock to avoid race condition
|
||||||
// where message arrives between initial check and continuation install
|
// where message arrives between initial check and continuation install
|
||||||
if self._receivedMessages.count >= count {
|
if self._receivedMessages.count >= count {
|
||||||
self.lock.unlock()
|
return true
|
||||||
continuation.resume()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
self.receivedMessageContinuation = continuation
|
self.receivedMessageContinuation = continuation
|
||||||
self.lock.unlock()
|
return false
|
||||||
|
}
|
||||||
|
if shouldResumeImmediately {
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
group.addTask {
|
group.addTask {
|
||||||
|
|||||||
@@ -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<Data, Error>] = [],
|
||||||
|
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<Data, Error>]
|
||||||
|
private let defaultData: Data
|
||||||
|
private(set) var requestCount = 0
|
||||||
|
|
||||||
|
init(responses: [Result<Data, Error>], 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
|
||||||
|
}
|
||||||
@@ -215,7 +215,7 @@ struct NostrProtocolTests {
|
|||||||
|
|
||||||
@Test func nostrEventSignatureVerification_roundTrip() throws {
|
@Test func nostrEventSignatureVerification_roundTrip() throws {
|
||||||
let identity = try NostrIdentity.generate()
|
let identity = try NostrIdentity.generate()
|
||||||
var event = NostrEvent(
|
let event = NostrEvent(
|
||||||
pubkey: identity.publicKeyHex,
|
pubkey: identity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: .ephemeralEvent,
|
kind: .ephemeralEvent,
|
||||||
@@ -228,7 +228,7 @@ struct NostrProtocolTests {
|
|||||||
|
|
||||||
@Test func nostrEventSignatureVerification_detectsTamper() throws {
|
@Test func nostrEventSignatureVerification_detectsTamper() throws {
|
||||||
let identity = try NostrIdentity.generate()
|
let identity = try NostrIdentity.generate()
|
||||||
var event = NostrEvent(
|
let event = NostrEvent(
|
||||||
pubkey: identity.publicKeyHex,
|
pubkey: identity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: .ephemeralEvent,
|
kind: .ephemeralEvent,
|
||||||
@@ -240,6 +240,18 @@ struct NostrProtocolTests {
|
|||||||
#expect(!signed.isValidSignature())
|
#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
|
// MARK: - Helpers
|
||||||
private static func base64URLDecode(_ s: String) -> Data? {
|
private static func base64URLDecode(_ s: String) -> Data? {
|
||||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
|
|||||||
@@ -4,6 +4,21 @@ import XCTest
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class NostrRelayManagerTests: XCTestCase {
|
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 {
|
func test_permissionPublisher_addsAndRemovesDefaultRelays() async {
|
||||||
let context = makeContext(permission: .denied, favorites: [])
|
let context = makeContext(permission: .denied, favorites: [])
|
||||||
|
|
||||||
@@ -43,6 +58,103 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertTrue(connectedAfterTorReady)
|
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() {
|
func test_connect_doesNothingWhenActivationIsDisallowed() {
|
||||||
let context = makeContext(permission: .authorized, activationAllowed: false)
|
let context = makeContext(permission: .authorized, activationAllowed: false)
|
||||||
|
|
||||||
@@ -85,6 +197,81 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
|
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 {
|
func test_unsubscribe_allowsResubscribeWithSameID() async {
|
||||||
let relayURL = "wss://subscribe.example"
|
let relayURL = "wss://subscribe.example"
|
||||||
let context = makeContext(permission: .denied)
|
let context = makeContext(permission: .denied)
|
||||||
@@ -136,6 +323,88 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(receivedEvent?.id, event.id)
|
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 {
|
func test_eoseCallback_waitsForAllTargetedRelays() async throws {
|
||||||
let relayOne = "wss://one.example"
|
let relayOne = "wss://one.example"
|
||||||
let relayTwo = "wss://two.example"
|
let relayTwo = "wss://two.example"
|
||||||
@@ -166,6 +435,32 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertTrue(eoseCompleted)
|
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 {
|
func test_receiveFailure_schedulesReconnectWithBackoff() async {
|
||||||
let relayURL = "wss://retry.example"
|
let relayURL = "wss://retry.example"
|
||||||
let context = makeContext(permission: .denied)
|
let context = makeContext(permission: .denied)
|
||||||
@@ -195,6 +490,30 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertTrue(retried)
|
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 {
|
func test_disconnect_invalidatesScheduledReconnectGeneration() async {
|
||||||
let relayURL = "wss://disconnect.example"
|
let relayURL = "wss://disconnect.example"
|
||||||
let context = makeContext(permission: .denied)
|
let context = makeContext(permission: .denied)
|
||||||
@@ -219,13 +538,172 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(context.sessionFactory.requestedURLs.count, requestCountBeforeDisconnect)
|
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(
|
private func makeContext(
|
||||||
permission: LocationChannelManager.PermissionState,
|
permission: LocationChannelManager.PermissionState,
|
||||||
favorites: Set<Data> = [],
|
favorites: Set<Data> = [],
|
||||||
activationAllowed: Bool = true,
|
activationAllowed: Bool = true,
|
||||||
userTorEnabled: Bool = false,
|
userTorEnabled: Bool = false,
|
||||||
torEnforced: Bool = false,
|
torEnforced: Bool = false,
|
||||||
torIsReady: Bool = true
|
torIsReady: Bool = true,
|
||||||
|
torIsForeground: Bool = true
|
||||||
) -> RelayManagerTestContext {
|
) -> RelayManagerTestContext {
|
||||||
let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)
|
let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)
|
||||||
let favoritesSubject = CurrentValueSubject<Set<Data>, Never>(favorites)
|
let favoritesSubject = CurrentValueSubject<Set<Data>, Never>(favorites)
|
||||||
@@ -233,9 +711,11 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
let scheduler = MockRelayScheduler()
|
let scheduler = MockRelayScheduler()
|
||||||
let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000))
|
let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000))
|
||||||
let torWaiter = MockTorWaiter(isReady: torIsReady)
|
let torWaiter = MockTorWaiter(isReady: torIsReady)
|
||||||
|
let torForeground = MutableBool(value: torIsForeground)
|
||||||
|
let activationFlag = MutableBool(value: activationAllowed)
|
||||||
let manager = NostrRelayManager(
|
let manager = NostrRelayManager(
|
||||||
dependencies: NostrRelayManagerDependencies(
|
dependencies: NostrRelayManagerDependencies(
|
||||||
activationAllowed: { activationAllowed },
|
activationAllowed: { activationFlag.value },
|
||||||
userTorEnabled: { userTorEnabled },
|
userTorEnabled: { userTorEnabled },
|
||||||
hasMutualFavorites: { !favoritesSubject.value.isEmpty },
|
hasMutualFavorites: { !favoritesSubject.value.isEmpty },
|
||||||
hasLocationPermission: { permissionSubject.value == .authorized },
|
hasLocationPermission: { permissionSubject.value == .authorized },
|
||||||
@@ -243,10 +723,12 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
||||||
torEnforced: { torEnforced },
|
torEnforced: { torEnforced },
|
||||||
torIsReady: { torWaiter.isReady },
|
torIsReady: { torWaiter.isReady },
|
||||||
torIsForeground: { true },
|
torIsForeground: { torForeground.value },
|
||||||
awaitTorReady: torWaiter.await(completion:),
|
awaitTorReady: torWaiter.await(completion:),
|
||||||
makeSession: { sessionFactory },
|
makeSession: { sessionFactory },
|
||||||
scheduleAfter: scheduler.schedule(delay:action:),
|
scheduleAfter: { delay, action in
|
||||||
|
scheduler.schedule(delay: delay, action: action)
|
||||||
|
},
|
||||||
now: { clock.now }
|
now: { clock.now }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -257,7 +739,9 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
sessionFactory: sessionFactory,
|
sessionFactory: sessionFactory,
|
||||||
scheduler: scheduler,
|
scheduler: scheduler,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
torWaiter: torWaiter
|
activationAllowed: activationFlag,
|
||||||
|
torWaiter: torWaiter,
|
||||||
|
torForeground: torForeground
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +787,9 @@ private struct RelayManagerTestContext {
|
|||||||
let sessionFactory: MockRelaySessionFactory
|
let sessionFactory: MockRelaySessionFactory
|
||||||
let scheduler: MockRelayScheduler
|
let scheduler: MockRelayScheduler
|
||||||
let clock: MutableClock
|
let clock: MutableClock
|
||||||
|
let activationAllowed: MutableBool
|
||||||
let torWaiter: MockTorWaiter
|
let torWaiter: MockTorWaiter
|
||||||
|
let torForeground: MutableBool
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class MutableClock {
|
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 final class MockTorWaiter {
|
||||||
private var completions: [(Bool) -> Void] = []
|
private var completions: [(Bool) -> Void] = []
|
||||||
var isReady: Bool
|
var isReady: Bool
|
||||||
@@ -334,15 +828,15 @@ private final class MockTorWaiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class MockRelayScheduler {
|
private final class MockRelayScheduler: @unchecked Sendable {
|
||||||
struct ScheduledAction {
|
struct ScheduledAction {
|
||||||
let delay: TimeInterval
|
let delay: TimeInterval
|
||||||
let action: () -> Void
|
let action: @Sendable () -> Void
|
||||||
}
|
}
|
||||||
|
|
||||||
private(set) var scheduled: [ScheduledAction] = []
|
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))
|
scheduled.append(ScheduledAction(delay: delay, action: action))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +850,8 @@ private final class MockRelayScheduler {
|
|||||||
private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
|
private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
|
||||||
private(set) var requestedURLs: [String] = []
|
private(set) var requestedURLs: [String] = []
|
||||||
private(set) var connectionsByURL: [String: [MockRelayConnection]] = [:]
|
private(set) var connectionsByURL: [String: [MockRelayConnection]] = [:]
|
||||||
|
var pingErrorByURL: [String: Error?] = [:]
|
||||||
|
var sendErrorByURL: [String: Error?] = [:]
|
||||||
|
|
||||||
var allConnections: [MockRelayConnection] {
|
var allConnections: [MockRelayConnection] {
|
||||||
connectionsByURL.values.flatMap { $0 }
|
connectionsByURL.values.flatMap { $0 }
|
||||||
@@ -363,7 +859,11 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
|
|||||||
|
|
||||||
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
|
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
|
||||||
requestedURLs.append(url.absoluteString)
|
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)
|
connectionsByURL[url.absoluteString, default: []].append(connection)
|
||||||
return connection
|
return connection
|
||||||
}
|
}
|
||||||
@@ -375,6 +875,8 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
|
|||||||
|
|
||||||
private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||||
private let url: String
|
private let url: String
|
||||||
|
private let pingError: Error?
|
||||||
|
private let sendError: Error?
|
||||||
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||||
private(set) var resumeCallCount = 0
|
private(set) var resumeCallCount = 0
|
||||||
private(set) var cancelCallCount = 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.url = url
|
||||||
|
self.pingError = pingError
|
||||||
|
self.sendError = sendError
|
||||||
}
|
}
|
||||||
|
|
||||||
func resume() {
|
func resume() {
|
||||||
@@ -404,7 +908,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
|||||||
|
|
||||||
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
|
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
|
||||||
sentMessages.append(message)
|
sentMessages.append(message)
|
||||||
completionHandler(nil)
|
completionHandler(sendError)
|
||||||
}
|
}
|
||||||
|
|
||||||
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||||
@@ -412,7 +916,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
|
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
|
||||||
pongReceiveHandler(nil)
|
pongReceiveHandler(pingError)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fail(error: Error) {
|
func fail(error: Error) {
|
||||||
@@ -432,6 +936,20 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
|||||||
try emit(jsonObject: ["EOSE", subscriptionID])
|
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 {
|
private func emit(jsonObject: Any) throws {
|
||||||
let data = try JSONSerialization.data(withJSONObject: jsonObject)
|
let data = try JSONSerialization.data(withJSONObject: jsonObject)
|
||||||
let handler = receiveHandler
|
let handler = receiveHandler
|
||||||
|
|||||||
@@ -3,6 +3,57 @@ import XCTest
|
|||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class SecureIdentityStateManagerTests: XCTestCase {
|
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 {
|
func test_upsertCryptographicIdentity_tracksByPeerIDPrefixAndClaimedNickname() async {
|
||||||
let manager = SecureIdentityStateManager(MockKeychain())
|
let manager = SecureIdentityStateManager(MockKeychain())
|
||||||
let noisePublicKey = Data(repeating: 0x11, count: 32)
|
let noisePublicKey = Data(repeating: 0x11, count: 32)
|
||||||
@@ -43,6 +94,12 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Unknown")
|
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 {
|
func test_setVerified_updatesTrustLevelAndVerifiedSet() async {
|
||||||
let manager = SecureIdentityStateManager(MockKeychain())
|
let manager = SecureIdentityStateManager(MockKeychain())
|
||||||
let fingerprint = String(repeating: "cd", count: 32)
|
let fingerprint = String(repeating: "cd", count: 32)
|
||||||
@@ -71,6 +128,209 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
|||||||
XCTAssertTrue(reloaded.isFavorite(fingerprint: fingerprint))
|
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 {
|
func test_setNostrBlocked_normalizesToLowercaseAndPersists() async {
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
let manager = SecureIdentityStateManager(keychain)
|
let manager = SecureIdentityStateManager(keychain)
|
||||||
@@ -88,6 +348,21 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
|||||||
XCTAssertTrue(reloaded.isNostrBlocked(pubkeyHexLowercased: pubkey))
|
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() {
|
func test_corruptPersistedCache_fallsBackToEmptyState() {
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
_ = keychain.saveIdentityKey(Data(repeating: 0x01, count: 32), forKey: "identityCacheEncryptionKey")
|
_ = keychain.saveIdentityKey(Data(repeating: 0x01, count: 32), forKey: "identityCacheEncryptionKey")
|
||||||
@@ -122,6 +397,21 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
|||||||
XCTAssertTrue(cleared)
|
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(
|
private func waitUntil(
|
||||||
timeout: TimeInterval = 1.0,
|
timeout: TimeInterval = 1.0,
|
||||||
condition: @escaping () -> Bool
|
condition: @escaping () -> Bool
|
||||||
@@ -136,3 +426,72 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
|||||||
return condition()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user