mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
Expand coverage for relay, identity, and location flows (#1055)
* Expand coverage for relay, identity, and location flows * Fix macOS SwiftPM CI failures --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
final class FavoritesPersistenceServiceTests: XCTestCase {
|
||||
private let storageKey = "chat.bitchat.favorites"
|
||||
private let serviceKey = "chat.bitchat.favorites"
|
||||
|
||||
func test_addFavorite_persistsAndPostsNotification() throws {
|
||||
let keychain = MockKeychain()
|
||||
let service = FavoritesPersistenceService(keychain: keychain)
|
||||
let peerKey = Data((0..<32).map(UInt8.init))
|
||||
let expectation = expectation(forNotification: .favoriteStatusChanged, object: nil)
|
||||
|
||||
service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1alice", peerNickname: "Alice")
|
||||
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
XCTAssertTrue(service.isFavorite(peerKey))
|
||||
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Alice")
|
||||
XCTAssertNotNil(keychain.load(key: storageKey, service: serviceKey))
|
||||
}
|
||||
|
||||
func test_removeFavorite_preservesRelationshipWhenPeerStillFavoritesUs() {
|
||||
let service = FavoritesPersistenceService(keychain: MockKeychain())
|
||||
let peerKey = Data((32..<64).map(UInt8.init))
|
||||
|
||||
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Bob")
|
||||
service.addFavorite(peerNoisePublicKey: peerKey, peerNickname: "Bob")
|
||||
service.removeFavorite(peerNoisePublicKey: peerKey)
|
||||
|
||||
let relationship = service.getFavoriteStatus(for: peerKey)
|
||||
XCTAssertNotNil(relationship)
|
||||
XCTAssertEqual(relationship?.peerNickname, "Bob")
|
||||
XCTAssertFalse(relationship?.isFavorite ?? true)
|
||||
XCTAssertTrue(relationship?.theyFavoritedUs ?? false)
|
||||
}
|
||||
|
||||
func test_updatePeerFavoritedUs_removesRelationshipWhenNeitherSideFavorites() {
|
||||
let service = FavoritesPersistenceService(keychain: MockKeychain())
|
||||
let peerKey = Data((64..<96).map(UInt8.init))
|
||||
|
||||
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Carol")
|
||||
XCTAssertNotNil(service.getFavoriteStatus(for: peerKey))
|
||||
|
||||
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: false, peerNickname: "Carol")
|
||||
|
||||
XCTAssertNil(service.getFavoriteStatus(for: peerKey))
|
||||
XCTAssertFalse(service.isMutualFavorite(peerKey))
|
||||
}
|
||||
|
||||
func test_getFavoriteStatus_forPeerID_returnsMutualFavorite() {
|
||||
let service = FavoritesPersistenceService(keychain: MockKeychain())
|
||||
let peerKey = Data((96..<128).map(UInt8.init))
|
||||
|
||||
service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1dan", peerNickname: "Dan")
|
||||
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Dan")
|
||||
|
||||
let relationship = service.getFavoriteStatus(forPeerID: PeerID(publicKey: peerKey))
|
||||
XCTAssertEqual(relationship?.peerNickname, "Dan")
|
||||
XCTAssertTrue(service.isMutualFavorite(peerKey))
|
||||
}
|
||||
|
||||
func test_init_deduplicatesPersistedRelationshipsByPublicKey() throws {
|
||||
let keychain = MockKeychain()
|
||||
let peerKey = Data((128..<160).map(UInt8.init))
|
||||
let older = FavoritesPersistenceService.FavoriteRelationship(
|
||||
peerNoisePublicKey: peerKey,
|
||||
peerNostrPublicKey: nil,
|
||||
peerNickname: "Older",
|
||||
isFavorite: true,
|
||||
theyFavoritedUs: false,
|
||||
favoritedAt: Date(timeIntervalSince1970: 100),
|
||||
lastUpdated: Date(timeIntervalSince1970: 100)
|
||||
)
|
||||
let newer = FavoritesPersistenceService.FavoriteRelationship(
|
||||
peerNoisePublicKey: peerKey,
|
||||
peerNostrPublicKey: "npub1newer",
|
||||
peerNickname: "Newer",
|
||||
isFavorite: true,
|
||||
theyFavoritedUs: true,
|
||||
favoritedAt: Date(timeIntervalSince1970: 100),
|
||||
lastUpdated: Date(timeIntervalSince1970: 200)
|
||||
)
|
||||
let encoded = try JSONEncoder().encode([older, newer])
|
||||
keychain.save(key: storageKey, data: encoded, service: serviceKey, accessible: nil)
|
||||
|
||||
let service = FavoritesPersistenceService(keychain: keychain)
|
||||
|
||||
XCTAssertEqual(service.favorites.count, 1)
|
||||
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Newer")
|
||||
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNostrPublicKey, "npub1newer")
|
||||
|
||||
let cleaned = try XCTUnwrap(keychain.load(key: storageKey, service: serviceKey))
|
||||
let decoded = try JSONDecoder().decode([FavoritesPersistenceService.FavoriteRelationship].self, from: cleaned)
|
||||
XCTAssertEqual(decoded.count, 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import Combine
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
final class GeohashPresenceServiceTests: XCTestCase {
|
||||
func test_start_schedulesHeartbeatUsingConfiguredInterval() {
|
||||
let scheduler = MockGeohashPresenceScheduler()
|
||||
let service = makeService(scheduler: scheduler, loopMinInterval: 42, loopMaxInterval: 42)
|
||||
|
||||
service.start()
|
||||
|
||||
XCTAssertEqual(scheduler.intervals, [42])
|
||||
}
|
||||
|
||||
func test_handleLocationChange_invalidatesExistingTimerAndSchedulesQuickRefresh() {
|
||||
let scheduler = MockGeohashPresenceScheduler()
|
||||
let service = makeService(scheduler: scheduler, loopMinInterval: 40, loopMaxInterval: 40)
|
||||
|
||||
service.start()
|
||||
let originalTimer = scheduler.timers.first
|
||||
|
||||
service.handleLocationChange()
|
||||
|
||||
XCTAssertEqual(scheduler.intervals, [40, 5])
|
||||
XCTAssertEqual(originalTimer?.invalidateCallCount, 1)
|
||||
}
|
||||
|
||||
func test_handleConnectivityChange_onlySchedulesWhenExistingTimerIsMissingOrInvalid() {
|
||||
let scheduler = MockGeohashPresenceScheduler()
|
||||
let service = makeService(scheduler: scheduler, loopMinInterval: 33, loopMaxInterval: 33)
|
||||
|
||||
service.start()
|
||||
service.handleConnectivityChange()
|
||||
XCTAssertEqual(scheduler.intervals, [33])
|
||||
|
||||
scheduler.timers.last?.invalidate()
|
||||
service.handleConnectivityChange()
|
||||
XCTAssertEqual(scheduler.intervals, [33, 33])
|
||||
}
|
||||
|
||||
func test_performHeartbeat_broadcastsOnlyAllowedPrecisionChannels() async throws {
|
||||
let identity = try NostrIdentity.generate()
|
||||
let scheduler = MockGeohashPresenceScheduler()
|
||||
var sentGeohashes: [String] = []
|
||||
var lookedUpGeohashes: [String] = []
|
||||
var sleptNanoseconds: [UInt64] = []
|
||||
let channels = [
|
||||
GeohashChannel(level: .region, geohash: "9q"),
|
||||
GeohashChannel(level: .province, geohash: "9q8y"),
|
||||
GeohashChannel(level: .city, geohash: "9q8yy"),
|
||||
GeohashChannel(level: .neighborhood, geohash: "9q8yyk"),
|
||||
GeohashChannel(level: .block, geohash: "9q8yyk8"),
|
||||
GeohashChannel(level: .building, geohash: "9q8yyk8y")
|
||||
]
|
||||
let service = makeService(
|
||||
scheduler: scheduler,
|
||||
availableChannels: channels,
|
||||
deriveIdentity: { _ in identity },
|
||||
relayLookup: { geohash, _ in
|
||||
lookedUpGeohashes.append(geohash)
|
||||
return ["wss://\(geohash).example"]
|
||||
},
|
||||
relaySender: { event, _ in
|
||||
let geohash = event.tags.first(where: { $0.first == "g" })?[1]
|
||||
if let geohash {
|
||||
sentGeohashes.append(geohash)
|
||||
}
|
||||
},
|
||||
sleeper: { nanoseconds in
|
||||
sleptNanoseconds.append(nanoseconds)
|
||||
},
|
||||
loopMinInterval: 17,
|
||||
loopMaxInterval: 17,
|
||||
burstMinDelay: 0,
|
||||
burstMaxDelay: 0
|
||||
)
|
||||
|
||||
service.performHeartbeat()
|
||||
|
||||
let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 }
|
||||
XCTAssertTrue(sentAllAllowedChannels)
|
||||
XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
||||
XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
||||
XCTAssertEqual(sleptNanoseconds.count, 3)
|
||||
XCTAssertEqual(scheduler.intervals, [17])
|
||||
}
|
||||
|
||||
func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async {
|
||||
let scheduler = MockGeohashPresenceScheduler()
|
||||
var sendCount = 0
|
||||
let service = makeService(
|
||||
scheduler: scheduler,
|
||||
torIsReady: { false },
|
||||
relaySender: { _, _ in sendCount += 1 },
|
||||
loopMinInterval: 21,
|
||||
loopMaxInterval: 21
|
||||
)
|
||||
|
||||
service.performHeartbeat()
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertEqual(sendCount, 0)
|
||||
XCTAssertEqual(scheduler.intervals, [21])
|
||||
}
|
||||
|
||||
func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async {
|
||||
let scheduler = MockGeohashPresenceScheduler()
|
||||
var sendCount = 0
|
||||
let service = makeService(
|
||||
scheduler: scheduler,
|
||||
torIsForeground: { false },
|
||||
relaySender: { _, _ in sendCount += 1 },
|
||||
loopMinInterval: 22,
|
||||
loopMaxInterval: 22
|
||||
)
|
||||
|
||||
service.performHeartbeat()
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertEqual(sendCount, 0)
|
||||
XCTAssertEqual(scheduler.intervals, [22])
|
||||
}
|
||||
|
||||
func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws {
|
||||
let identity = try NostrIdentity.generate()
|
||||
var sendCount = 0
|
||||
let service = makeService(
|
||||
scheduler: MockGeohashPresenceScheduler(),
|
||||
deriveIdentity: { _ in identity },
|
||||
relayLookup: { _, _ in [] },
|
||||
relaySender: { _, _ in sendCount += 1 }
|
||||
)
|
||||
|
||||
service.broadcastPresence(for: "9q8yy")
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertEqual(sendCount, 0)
|
||||
}
|
||||
|
||||
func test_broadcastPresence_skipsSendWhenIdentityDerivationFails() async {
|
||||
enum PresenceError: Error { case failed }
|
||||
|
||||
var sendCount = 0
|
||||
let service = makeService(
|
||||
scheduler: MockGeohashPresenceScheduler(),
|
||||
deriveIdentity: { _ in throw PresenceError.failed },
|
||||
relaySender: { _, _ in sendCount += 1 }
|
||||
)
|
||||
|
||||
service.broadcastPresence(for: "9q8yy")
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertEqual(sendCount, 0)
|
||||
}
|
||||
|
||||
private func makeService(
|
||||
scheduler: MockGeohashPresenceScheduler,
|
||||
availableChannels: [GeohashChannel] = [
|
||||
GeohashChannel(level: .city, geohash: "9q8yy")
|
||||
],
|
||||
torIsReady: @escaping () -> Bool = { true },
|
||||
torIsForeground: @escaping () -> Bool = { true },
|
||||
deriveIdentity: @escaping (String) throws -> NostrIdentity = { _ in try NostrIdentity.generate() },
|
||||
relayLookup: @escaping (String, Int) -> [String] = { geohash, _ in ["wss://\(geohash).example"] },
|
||||
relaySender: @escaping (NostrEvent, [String]) -> Void = { _, _ in },
|
||||
sleeper: @escaping (UInt64) async -> Void = { _ in },
|
||||
loopMinInterval: TimeInterval = 40,
|
||||
loopMaxInterval: TimeInterval = 40,
|
||||
burstMinDelay: TimeInterval = 0,
|
||||
burstMaxDelay: TimeInterval = 0
|
||||
) -> GeohashPresenceService {
|
||||
let locationSubject = PassthroughSubject<[GeohashChannel], Never>()
|
||||
let torReadySubject = PassthroughSubject<Void, Never>()
|
||||
return GeohashPresenceService(
|
||||
availableChannelsProvider: { availableChannels },
|
||||
locationChanges: locationSubject.eraseToAnyPublisher(),
|
||||
torReadyPublisher: torReadySubject.eraseToAnyPublisher(),
|
||||
torIsReady: torIsReady,
|
||||
torIsForeground: torIsForeground,
|
||||
deriveIdentity: deriveIdentity,
|
||||
relayLookup: relayLookup,
|
||||
relaySender: relaySender,
|
||||
sleeper: sleeper,
|
||||
scheduleTimer: scheduler.schedule(interval:handler:),
|
||||
loopMinInterval: loopMinInterval,
|
||||
loopMaxInterval: loopMaxInterval,
|
||||
burstMinDelay: burstMinDelay,
|
||||
burstMaxDelay: burstMaxDelay
|
||||
)
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if condition() {
|
||||
return true
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
return condition()
|
||||
}
|
||||
}
|
||||
|
||||
private final class MockGeohashPresenceScheduler {
|
||||
private(set) var intervals: [TimeInterval] = []
|
||||
private(set) var timers: [MockGeohashPresenceTimer] = []
|
||||
|
||||
func schedule(interval: TimeInterval, handler: @escaping () -> Void) -> GeohashPresenceTimerProtocol {
|
||||
intervals.append(interval)
|
||||
let timer = MockGeohashPresenceTimer(handler: handler)
|
||||
timers.append(timer)
|
||||
return timer
|
||||
}
|
||||
}
|
||||
|
||||
private final class MockGeohashPresenceTimer: GeohashPresenceTimerProtocol {
|
||||
private let handler: () -> Void
|
||||
private(set) var isValid = true
|
||||
private(set) var invalidateCallCount = 0
|
||||
|
||||
init(handler: @escaping () -> Void) {
|
||||
self.handler = handler
|
||||
}
|
||||
|
||||
func invalidate() {
|
||||
invalidateCallCount += 1
|
||||
isValid = false
|
||||
}
|
||||
|
||||
func fire() {
|
||||
handler()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import CoreLocation
|
||||
import MapKit
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
final class LocationStateManagerTests: XCTestCase {
|
||||
func test_loadPersistedState_normalizesBookmarksAndRestoresTeleportedSelection() async throws {
|
||||
let storage = makeStorage()
|
||||
let selected = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pru"))
|
||||
storage.set(try JSONEncoder().encode(selected), forKey: "locationChannel.selected")
|
||||
storage.set(try JSONEncoder().encode(["u4pru"]), forKey: "locationChannel.teleportedSet")
|
||||
storage.set(try JSONEncoder().encode(["#U4PRU", "u4pru", ""]), forKey: "locationChannel.bookmarks")
|
||||
|
||||
let manager = LocationStateManager(
|
||||
storage: storage,
|
||||
locationManager: MockLocationManager(authorizationStatus: .denied),
|
||||
geocoder: MockLocationGeocoder(),
|
||||
shouldInitializeCoreLocation: true
|
||||
)
|
||||
|
||||
let deniedLoaded = await waitUntil { manager.permissionState == .denied }
|
||||
XCTAssertTrue(deniedLoaded)
|
||||
XCTAssertEqual(manager.bookmarks, ["u4pru"])
|
||||
XCTAssertEqual(manager.selectedChannel, selected)
|
||||
let teleportedLoaded = await waitUntil { manager.teleported }
|
||||
XCTAssertTrue(teleportedLoaded)
|
||||
}
|
||||
|
||||
func test_enableLocationChannels_requestsAuthorizationWhenStatusIsUndetermined() {
|
||||
let locationManager = MockLocationManager(authorizationStatus: .notDetermined)
|
||||
let manager = LocationStateManager(
|
||||
storage: makeStorage(),
|
||||
locationManager: locationManager,
|
||||
geocoder: MockLocationGeocoder(),
|
||||
shouldInitializeCoreLocation: true
|
||||
)
|
||||
|
||||
manager.enableLocationChannels()
|
||||
|
||||
XCTAssertEqual(locationManager.requestAuthorizationCallCount, 1)
|
||||
XCTAssertEqual(locationManager.requestLocationCallCount, 0)
|
||||
}
|
||||
|
||||
func test_enableLocationChannels_requestsOneShotLocationWhenAuthorized() async {
|
||||
let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways)
|
||||
let manager = LocationStateManager(
|
||||
storage: makeStorage(),
|
||||
locationManager: locationManager,
|
||||
geocoder: MockLocationGeocoder(),
|
||||
shouldInitializeCoreLocation: true
|
||||
)
|
||||
|
||||
let authorizedLoaded = await waitUntil { manager.permissionState == .authorized }
|
||||
XCTAssertTrue(authorizedLoaded)
|
||||
|
||||
manager.enableLocationChannels()
|
||||
|
||||
XCTAssertEqual(locationManager.requestLocationCallCount, 1)
|
||||
XCTAssertEqual(manager.permissionState, .authorized)
|
||||
}
|
||||
|
||||
func test_beginAndEndLiveRefresh_adjustLocationManagerMode() async {
|
||||
let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways)
|
||||
let manager = LocationStateManager(
|
||||
storage: makeStorage(),
|
||||
locationManager: locationManager,
|
||||
geocoder: MockLocationGeocoder(),
|
||||
shouldInitializeCoreLocation: true
|
||||
)
|
||||
|
||||
let authorizedLoaded = await waitUntil { manager.permissionState == .authorized }
|
||||
XCTAssertTrue(authorizedLoaded)
|
||||
|
||||
manager.beginLiveRefresh()
|
||||
|
||||
XCTAssertEqual(locationManager.startUpdatingLocationCallCount, 1)
|
||||
XCTAssertEqual(locationManager.requestLocationCallCount, 1)
|
||||
XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyNearestTenMeters)
|
||||
XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterLiveMeters)
|
||||
|
||||
manager.endLiveRefresh()
|
||||
|
||||
XCTAssertEqual(locationManager.stopUpdatingLocationCallCount, 1)
|
||||
XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyHundredMeters)
|
||||
XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterMeters)
|
||||
}
|
||||
|
||||
func test_didUpdateLocations_computesChannelsAndReverseGeocodesFriendlyNames() async {
|
||||
let geocoder = MockLocationGeocoder()
|
||||
geocoder.enqueue(
|
||||
placemarks: [
|
||||
makePlacemark(
|
||||
country: "United States",
|
||||
administrativeArea: "Hawaii",
|
||||
locality: "Honolulu",
|
||||
subLocality: "Waikiki",
|
||||
name: "Hilton Hawaiian Village"
|
||||
)
|
||||
]
|
||||
)
|
||||
let manager = LocationStateManager(
|
||||
storage: makeStorage(),
|
||||
locationManager: MockLocationManager(authorizationStatus: .authorizedAlways),
|
||||
geocoder: geocoder,
|
||||
shouldInitializeCoreLocation: true
|
||||
)
|
||||
let location = CLLocation(latitude: 21.2850, longitude: -157.8357)
|
||||
|
||||
manager.locationManager(CLLocationManager(), didUpdateLocations: [location])
|
||||
|
||||
let channelsAndNamesLoaded = await waitUntil {
|
||||
manager.availableChannels.count == GeohashChannelLevel.allCases.count &&
|
||||
manager.locationNames[.city] == "Honolulu" &&
|
||||
manager.locationNames[.building] == "Hilton Hawaiian Village"
|
||||
}
|
||||
XCTAssertTrue(channelsAndNamesLoaded)
|
||||
XCTAssertEqual(geocoder.cancelCallCount, 1)
|
||||
XCTAssertEqual(geocoder.reverseRequests.count, 1)
|
||||
XCTAssertEqual(manager.availableChannels.map(\.geohash.count), GeohashChannelLevel.allCases.map(\.precision))
|
||||
XCTAssertEqual(manager.locationNames[.region], "United States")
|
||||
XCTAssertEqual(manager.locationNames[.province], "Hawaii")
|
||||
XCTAssertEqual(manager.locationNames[.city], "Honolulu")
|
||||
XCTAssertEqual(manager.locationNames[.neighborhood], "Waikiki")
|
||||
XCTAssertEqual(manager.locationNames[.block], "Waikiki")
|
||||
XCTAssertEqual(manager.locationNames[.building], "Hilton Hawaiian Village")
|
||||
}
|
||||
|
||||
func test_selectingInRegionChannel_clearsTeleportedPersistence() async {
|
||||
let storage = makeStorage()
|
||||
let manager = LocationStateManager(
|
||||
storage: storage,
|
||||
locationManager: MockLocationManager(authorizationStatus: .authorizedAlways),
|
||||
geocoder: MockLocationGeocoder(),
|
||||
shouldInitializeCoreLocation: true
|
||||
)
|
||||
let coordinate = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
|
||||
let cityGeohash = Geohash.encode(
|
||||
latitude: coordinate.latitude,
|
||||
longitude: coordinate.longitude,
|
||||
precision: GeohashChannelLevel.city.precision
|
||||
)
|
||||
let channel = GeohashChannel(level: .city, geohash: cityGeohash)
|
||||
|
||||
manager.locationManager(CLLocationManager(), didUpdateLocations: [CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)])
|
||||
let channelAvailable = await waitUntil { manager.availableChannels.contains(channel) }
|
||||
XCTAssertTrue(channelAvailable)
|
||||
|
||||
manager.markTeleported(for: cityGeohash, true)
|
||||
manager.select(.location(channel))
|
||||
|
||||
let selectionSettled = await waitUntil {
|
||||
manager.selectedChannel == .location(channel) && !manager.teleported
|
||||
}
|
||||
XCTAssertTrue(selectionSettled)
|
||||
|
||||
let reloaded = LocationStateManager(
|
||||
storage: storage,
|
||||
locationManager: MockLocationManager(authorizationStatus: .denied),
|
||||
geocoder: MockLocationGeocoder(),
|
||||
shouldInitializeCoreLocation: true
|
||||
)
|
||||
|
||||
let reloadedDenied = await waitUntil { reloaded.permissionState == .denied }
|
||||
XCTAssertTrue(reloadedDenied)
|
||||
XCTAssertEqual(reloaded.selectedChannel, .location(channel))
|
||||
XCTAssertFalse(reloaded.teleported)
|
||||
}
|
||||
|
||||
func test_addBookmark_lowPrecisionResolvesCompositeAdminName() async {
|
||||
let geocoder = MockLocationGeocoder()
|
||||
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")])
|
||||
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Nevada")])
|
||||
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")])
|
||||
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Arizona")])
|
||||
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Nevada")])
|
||||
let manager = LocationStateManager(
|
||||
storage: makeStorage(),
|
||||
locationManager: MockLocationManager(authorizationStatus: .denied),
|
||||
geocoder: geocoder,
|
||||
shouldInitializeCoreLocation: false
|
||||
)
|
||||
|
||||
manager.addBookmark("9q")
|
||||
|
||||
let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q"] == "California and Nevada" }
|
||||
XCTAssertTrue(bookmarkResolved)
|
||||
XCTAssertEqual(geocoder.reverseRequests.count, 5)
|
||||
XCTAssertEqual(manager.bookmarks, ["9q"])
|
||||
}
|
||||
|
||||
private func makeStorage() -> UserDefaults {
|
||||
let suiteName = "LocationStateManagerTests-\(UUID().uuidString)"
|
||||
let storage = UserDefaults(suiteName: suiteName)!
|
||||
storage.removePersistentDomain(forName: suiteName)
|
||||
addTeardownBlock {
|
||||
storage.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
return storage
|
||||
}
|
||||
|
||||
private func makePlacemark(
|
||||
country: String? = nil,
|
||||
administrativeArea: String? = nil,
|
||||
locality: String? = nil,
|
||||
subLocality: String? = nil,
|
||||
name: String? = nil
|
||||
) -> CLPlacemark {
|
||||
var address: [String: Any] = [:]
|
||||
if let country {
|
||||
address["Country"] = country
|
||||
}
|
||||
if let administrativeArea {
|
||||
address["State"] = administrativeArea
|
||||
}
|
||||
if let locality {
|
||||
address["City"] = locality
|
||||
}
|
||||
if let subLocality {
|
||||
address["SubLocality"] = subLocality
|
||||
}
|
||||
if let name {
|
||||
address["Name"] = name
|
||||
}
|
||||
let placemark = MKPlacemark(
|
||||
coordinate: CLLocationCoordinate2D(latitude: 21.2850, longitude: -157.8357),
|
||||
addressDictionary: address
|
||||
)
|
||||
return CLPlacemark(placemark: placemark)
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if condition() {
|
||||
return true
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
return condition()
|
||||
}
|
||||
}
|
||||
|
||||
private final class MockLocationManager: LocationStateManaging {
|
||||
weak var delegate: CLLocationManagerDelegate?
|
||||
var desiredAccuracy: CLLocationAccuracy = 0
|
||||
var distanceFilter: CLLocationDistance = 0
|
||||
var authorizationStatus: CLAuthorizationStatus
|
||||
private(set) var requestAuthorizationCallCount = 0
|
||||
private(set) var requestLocationCallCount = 0
|
||||
private(set) var startUpdatingLocationCallCount = 0
|
||||
private(set) var stopUpdatingLocationCallCount = 0
|
||||
|
||||
init(authorizationStatus: CLAuthorizationStatus) {
|
||||
self.authorizationStatus = authorizationStatus
|
||||
}
|
||||
|
||||
func requestWhenInUseAuthorization() {
|
||||
requestAuthorizationCallCount += 1
|
||||
}
|
||||
|
||||
func requestLocation() {
|
||||
requestLocationCallCount += 1
|
||||
}
|
||||
|
||||
func startUpdatingLocation() {
|
||||
startUpdatingLocationCallCount += 1
|
||||
}
|
||||
|
||||
func stopUpdatingLocation() {
|
||||
stopUpdatingLocationCallCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
private final class MockLocationGeocoder: LocationStateGeocoding {
|
||||
private struct Response {
|
||||
let placemarks: [CLPlacemark]?
|
||||
let error: Error?
|
||||
}
|
||||
|
||||
private(set) var cancelCallCount = 0
|
||||
private(set) var reverseRequests: [CLLocation] = []
|
||||
private var responses: [Response] = []
|
||||
|
||||
func enqueue(placemarks: [CLPlacemark]?, error: Error? = nil) {
|
||||
responses.append(Response(placemarks: placemarks, error: error))
|
||||
}
|
||||
|
||||
func cancelGeocode() {
|
||||
cancelCallCount += 1
|
||||
}
|
||||
|
||||
func reverseGeocodeLocation(
|
||||
_ location: CLLocation,
|
||||
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
|
||||
) {
|
||||
reverseRequests.append(location)
|
||||
let response = responses.isEmpty ? Response(placemarks: nil, error: nil) : responses.removeFirst()
|
||||
completionHandler(response.placemarks, response.error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import Combine
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
final class NetworkActivationServiceTests: XCTestCase {
|
||||
private let torPreferenceKey = "networkActivationService.userTorEnabled"
|
||||
|
||||
func test_start_leavesNetworkDisabledWithoutPermissionOrFavorites() {
|
||||
let context = makeService(permission: .denied, favorites: [])
|
||||
|
||||
context.service.start()
|
||||
|
||||
XCTAssertFalse(context.service.activationAllowed)
|
||||
XCTAssertEqual(context.torController.autoStartAllowedValues, [false])
|
||||
XCTAssertEqual(context.proxyController.proxyModes, [false])
|
||||
XCTAssertEqual(context.torController.startIfNeededCallCount, 0)
|
||||
XCTAssertEqual(context.torController.shutdownCompletelyCallCount, 1)
|
||||
XCTAssertEqual(context.relayController.connectCallCount, 0)
|
||||
XCTAssertEqual(context.relayController.disconnectCallCount, 1)
|
||||
}
|
||||
|
||||
func test_start_enablesTorAndRelaysWhenAuthorized() {
|
||||
let context = makeService(permission: .authorized, favorites: [])
|
||||
|
||||
context.service.start()
|
||||
|
||||
XCTAssertTrue(context.service.activationAllowed)
|
||||
XCTAssertEqual(context.torController.autoStartAllowedValues, [true])
|
||||
XCTAssertEqual(context.proxyController.proxyModes, [true])
|
||||
XCTAssertEqual(context.torController.startIfNeededCallCount, 1)
|
||||
XCTAssertEqual(context.relayController.connectCallCount, 1)
|
||||
XCTAssertEqual(context.relayController.disconnectCallCount, 0)
|
||||
}
|
||||
|
||||
func test_start_respectsStoredTorPreferenceForDirectMode() {
|
||||
let context = makeService(permission: .authorized, favorites: [])
|
||||
context.storage.set(false, forKey: torPreferenceKey)
|
||||
|
||||
context.service.start()
|
||||
|
||||
XCTAssertTrue(context.service.activationAllowed)
|
||||
XCTAssertFalse(context.service.userTorEnabled)
|
||||
XCTAssertEqual(context.torController.autoStartAllowedValues, [false])
|
||||
XCTAssertEqual(context.proxyController.proxyModes, [false])
|
||||
XCTAssertEqual(context.torController.startIfNeededCallCount, 0)
|
||||
XCTAssertEqual(context.torController.shutdownCompletelyCallCount, 1)
|
||||
XCTAssertEqual(context.relayController.connectCallCount, 1)
|
||||
}
|
||||
|
||||
func test_setUserTorEnabled_postsNotificationAndReconnectsOnTransportSwitch() {
|
||||
let context = makeService(permission: .authorized, favorites: [])
|
||||
let notified = expectation(description: "Tor preference notification")
|
||||
let token = context.notificationCenter.addObserver(
|
||||
forName: .TorUserPreferenceChanged,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { note in
|
||||
XCTAssertEqual(note.userInfo?["enabled"] as? Bool, false)
|
||||
notified.fulfill()
|
||||
}
|
||||
|
||||
context.service.start()
|
||||
context.service.setUserTorEnabled(false)
|
||||
|
||||
wait(for: [notified], timeout: 1.0)
|
||||
context.notificationCenter.removeObserver(token)
|
||||
|
||||
XCTAssertFalse(context.service.userTorEnabled)
|
||||
XCTAssertEqual(context.storage.object(forKey: torPreferenceKey) as? Bool, false)
|
||||
XCTAssertEqual(Array(context.proxyController.proxyModes.suffix(2)), [true, false])
|
||||
XCTAssertEqual(Array(context.torController.autoStartAllowedValues.suffix(2)), [true, false])
|
||||
XCTAssertEqual(context.relayController.disconnectCallCount, 1)
|
||||
XCTAssertEqual(context.relayController.connectCallCount, 2)
|
||||
}
|
||||
|
||||
func test_mutualFavoritesPublisher_reactivatesNetwork() async {
|
||||
let context = makeService(permission: .denied, favorites: [])
|
||||
|
||||
context.service.start()
|
||||
XCTAssertFalse(context.service.activationAllowed)
|
||||
|
||||
context.favoritesSubject.send([Data([0x01])])
|
||||
let becameActive = await waitUntil { context.service.activationAllowed }
|
||||
XCTAssertTrue(becameActive)
|
||||
|
||||
XCTAssertTrue(context.service.activationAllowed)
|
||||
XCTAssertTrue(context.torController.autoStartAllowedValues.contains(true))
|
||||
XCTAssertTrue(context.proxyController.proxyModes.contains(true))
|
||||
XCTAssertGreaterThanOrEqual(context.torController.startIfNeededCallCount, 1)
|
||||
XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1)
|
||||
}
|
||||
|
||||
private func makeService(
|
||||
permission: LocationChannelManager.PermissionState,
|
||||
favorites: Set<Data>
|
||||
) -> NetworkActivationTestContext {
|
||||
let suiteName = "NetworkActivationServiceTests-\(UUID().uuidString)"
|
||||
let storage = UserDefaults(suiteName: suiteName)!
|
||||
storage.removePersistentDomain(forName: suiteName)
|
||||
|
||||
let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)
|
||||
let favoritesSubject = CurrentValueSubject<Set<Data>, Never>(favorites)
|
||||
let torController = MockNetworkActivationTorController()
|
||||
let relayController = MockNetworkActivationRelayController()
|
||||
let proxyController = MockNetworkActivationProxyController()
|
||||
let notificationCenter = NotificationCenter()
|
||||
let service = NetworkActivationService(
|
||||
storage: storage,
|
||||
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
||||
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
||||
permissionProvider: { permissionSubject.value },
|
||||
mutualFavoritesProvider: { favoritesSubject.value },
|
||||
torController: torController,
|
||||
relayController: relayController,
|
||||
proxyController: proxyController,
|
||||
notificationCenter: notificationCenter
|
||||
)
|
||||
return NetworkActivationTestContext(
|
||||
service: service,
|
||||
storage: storage,
|
||||
permissionSubject: permissionSubject,
|
||||
favoritesSubject: favoritesSubject,
|
||||
torController: torController,
|
||||
relayController: relayController,
|
||||
proxyController: proxyController,
|
||||
notificationCenter: notificationCenter
|
||||
)
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if condition() {
|
||||
return true
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
return condition()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct NetworkActivationTestContext {
|
||||
let service: NetworkActivationService
|
||||
let storage: UserDefaults
|
||||
let permissionSubject: CurrentValueSubject<LocationChannelManager.PermissionState, Never>
|
||||
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
|
||||
let torController: MockNetworkActivationTorController
|
||||
let relayController: MockNetworkActivationRelayController
|
||||
let proxyController: MockNetworkActivationProxyController
|
||||
let notificationCenter: NotificationCenter
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockNetworkActivationTorController: NetworkActivationTorControlling {
|
||||
private(set) var autoStartAllowedValues: [Bool] = []
|
||||
private(set) var startIfNeededCallCount = 0
|
||||
private(set) var shutdownCompletelyCallCount = 0
|
||||
|
||||
func setAutoStartAllowed(_ allowed: Bool) {
|
||||
autoStartAllowedValues.append(allowed)
|
||||
}
|
||||
|
||||
func startIfNeeded() {
|
||||
startIfNeededCallCount += 1
|
||||
}
|
||||
|
||||
func shutdownCompletely() {
|
||||
shutdownCompletelyCallCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockNetworkActivationRelayController: NetworkActivationRelayControlling {
|
||||
private(set) var connectCallCount = 0
|
||||
private(set) var disconnectCallCount = 0
|
||||
|
||||
func connect() {
|
||||
connectCallCount += 1
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
disconnectCallCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
private final class MockNetworkActivationProxyController: NetworkActivationProxyControlling {
|
||||
private(set) var proxyModes: [Bool] = []
|
||||
|
||||
func setProxyMode(useTor: Bool) {
|
||||
proxyModes.append(useTor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import Combine
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
final class NostrRelayManagerTests: XCTestCase {
|
||||
func test_permissionPublisher_addsAndRemovesDefaultRelays() async {
|
||||
let context = makeContext(permission: .denied, favorites: [])
|
||||
|
||||
XCTAssertEqual(context.manager.getRelayStatuses().count, 0)
|
||||
|
||||
context.permissionSubject.send(.authorized)
|
||||
|
||||
let defaultRelaysConnected = await waitUntil {
|
||||
context.manager.getRelayStatuses().count == 5 &&
|
||||
context.manager.relays.allSatisfy(\.isConnected)
|
||||
}
|
||||
XCTAssertTrue(defaultRelaysConnected)
|
||||
|
||||
context.permissionSubject.send(.denied)
|
||||
|
||||
let defaultRelaysRemoved = await waitUntil {
|
||||
context.manager.getRelayStatuses().isEmpty
|
||||
}
|
||||
XCTAssertTrue(defaultRelaysRemoved)
|
||||
XCTAssertEqual(context.sessionFactory.allConnections.count, 5)
|
||||
XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.cancelCallCount >= 1 })
|
||||
}
|
||||
|
||||
func test_connect_waitsForTorReadinessBeforeCreatingSessions() async {
|
||||
let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
|
||||
context.manager.connect()
|
||||
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
|
||||
context.torWaiter.resolve(true)
|
||||
|
||||
let connectedAfterTorReady = await waitUntil {
|
||||
context.sessionFactory.requestedURLs.count == 5 &&
|
||||
context.manager.relays.allSatisfy(\.isConnected)
|
||||
}
|
||||
XCTAssertTrue(connectedAfterTorReady)
|
||||
}
|
||||
|
||||
func test_connect_doesNothingWhenActivationIsDisallowed() {
|
||||
let context = makeContext(permission: .authorized, activationAllowed: false)
|
||||
|
||||
context.manager.connect()
|
||||
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
XCTAssertFalse(context.manager.isConnected)
|
||||
}
|
||||
|
||||
func test_ensureConnections_deduplicatesRelayURLs() async {
|
||||
let relayOne = "wss://relay-one.example"
|
||||
let relayTwo = "wss://relay-two.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
|
||||
context.manager.ensureConnections(to: [relayOne, relayOne, relayTwo])
|
||||
|
||||
let connected = await waitUntil {
|
||||
Set(context.manager.getRelayStatuses().map(\.url)) == Set([relayOne, relayTwo]) &&
|
||||
context.manager.relays.allSatisfy(\.isConnected)
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
XCTAssertEqual(context.sessionFactory.requestedURLs, [relayOne, relayTwo])
|
||||
}
|
||||
|
||||
func test_subscribe_coalescesRapidDuplicateRequests() async {
|
||||
let relayURL = "wss://subscribe.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let filter = makeFilter()
|
||||
|
||||
context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in })
|
||||
|
||||
let firstSent = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(firstSent)
|
||||
|
||||
context.clock.now = context.clock.now.addingTimeInterval(0.5)
|
||||
context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in })
|
||||
|
||||
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
|
||||
}
|
||||
|
||||
func test_unsubscribe_allowsResubscribeWithSameID() async {
|
||||
let relayURL = "wss://subscribe.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let filter = makeFilter()
|
||||
|
||||
context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in })
|
||||
let initialSubscribeSent = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(initialSubscribeSent)
|
||||
|
||||
context.manager.unsubscribe(id: "sub")
|
||||
let closeSent = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 2
|
||||
}
|
||||
XCTAssertTrue(closeSent)
|
||||
|
||||
context.clock.now = context.clock.now.addingTimeInterval(0.2)
|
||||
context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in })
|
||||
|
||||
let resubscribed = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 3
|
||||
}
|
||||
XCTAssertTrue(resubscribed)
|
||||
}
|
||||
|
||||
func test_receiveEvent_deliversHandlerAndTracksReceivedCount() async throws {
|
||||
let relayURL = "wss://events.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let filter = makeFilter()
|
||||
let event = try makeSignedEvent(content: "hello")
|
||||
var receivedEvent: NostrEvent?
|
||||
|
||||
context.manager.subscribe(filter: filter, id: "events", relayUrls: [relayURL]) { event in
|
||||
receivedEvent = event
|
||||
}
|
||||
let subscriptionSent = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(subscriptionSent)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
||||
|
||||
let delivered = await waitUntil {
|
||||
receivedEvent?.id == event.id &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1
|
||||
}
|
||||
XCTAssertTrue(delivered)
|
||||
XCTAssertEqual(receivedEvent?.id, event.id)
|
||||
}
|
||||
|
||||
func test_eoseCallback_waitsForAllTargetedRelays() async throws {
|
||||
let relayOne = "wss://one.example"
|
||||
let relayTwo = "wss://two.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
var eoseCount = 0
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "eose",
|
||||
relayUrls: [relayOne, relayTwo],
|
||||
handler: { _ in },
|
||||
onEOSE: { eoseCount += 1 }
|
||||
)
|
||||
|
||||
let bothConnected = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayOne)?.sentStrings.count == 1 &&
|
||||
context.sessionFactory.latestConnection(for: relayTwo)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(bothConnected)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: relayOne)?.emitEOSE(subscriptionID: "eose")
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
XCTAssertEqual(eoseCount, 0)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: relayTwo)?.emitEOSE(subscriptionID: "eose")
|
||||
|
||||
let eoseCompleted = await waitUntil { eoseCount == 1 }
|
||||
XCTAssertTrue(eoseCompleted)
|
||||
}
|
||||
|
||||
func test_receiveFailure_schedulesReconnectWithBackoff() async {
|
||||
let relayURL = "wss://retry.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let firstConnected = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL) != nil
|
||||
}
|
||||
XCTAssertTrue(firstConnected)
|
||||
|
||||
let firstConnection = context.sessionFactory.latestConnection(for: relayURL)
|
||||
firstConnection?.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut))
|
||||
|
||||
let retryScheduled = await waitUntil {
|
||||
context.scheduler.scheduled.count == 1 &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 1
|
||||
}
|
||||
XCTAssertTrue(retryScheduled)
|
||||
XCTAssertEqual(context.scheduler.scheduled.first?.delay, TransportConfig.nostrRelayInitialBackoffSeconds)
|
||||
|
||||
let initialRequestCount = context.sessionFactory.requestedURLs.count
|
||||
context.scheduler.runNext()
|
||||
|
||||
let retried = await waitUntil {
|
||||
context.sessionFactory.requestedURLs.count == initialRequestCount + 1
|
||||
}
|
||||
XCTAssertTrue(retried)
|
||||
}
|
||||
|
||||
func test_disconnect_invalidatesScheduledReconnectGeneration() async {
|
||||
let relayURL = "wss://disconnect.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let firstConnected = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL) != nil
|
||||
}
|
||||
XCTAssertTrue(firstConnected)
|
||||
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.fail(
|
||||
error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
|
||||
)
|
||||
let retryScheduled = await waitUntil { context.scheduler.scheduled.count == 1 }
|
||||
XCTAssertTrue(retryScheduled)
|
||||
|
||||
let requestCountBeforeDisconnect = context.sessionFactory.requestedURLs.count
|
||||
context.manager.disconnect()
|
||||
context.scheduler.runNext()
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertEqual(context.sessionFactory.requestedURLs.count, requestCountBeforeDisconnect)
|
||||
}
|
||||
|
||||
private func makeContext(
|
||||
permission: LocationChannelManager.PermissionState,
|
||||
favorites: Set<Data> = [],
|
||||
activationAllowed: Bool = true,
|
||||
userTorEnabled: Bool = false,
|
||||
torEnforced: Bool = false,
|
||||
torIsReady: Bool = true
|
||||
) -> RelayManagerTestContext {
|
||||
let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)
|
||||
let favoritesSubject = CurrentValueSubject<Set<Data>, Never>(favorites)
|
||||
let sessionFactory = MockRelaySessionFactory()
|
||||
let scheduler = MockRelayScheduler()
|
||||
let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000))
|
||||
let torWaiter = MockTorWaiter(isReady: torIsReady)
|
||||
let manager = NostrRelayManager(
|
||||
dependencies: NostrRelayManagerDependencies(
|
||||
activationAllowed: { activationAllowed },
|
||||
userTorEnabled: { userTorEnabled },
|
||||
hasMutualFavorites: { !favoritesSubject.value.isEmpty },
|
||||
hasLocationPermission: { permissionSubject.value == .authorized },
|
||||
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
||||
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
||||
torEnforced: { torEnforced },
|
||||
torIsReady: { torWaiter.isReady },
|
||||
torIsForeground: { true },
|
||||
awaitTorReady: torWaiter.await(completion:),
|
||||
makeSession: { sessionFactory },
|
||||
scheduleAfter: scheduler.schedule(delay:action:),
|
||||
now: { clock.now }
|
||||
)
|
||||
)
|
||||
return RelayManagerTestContext(
|
||||
manager: manager,
|
||||
permissionSubject: permissionSubject,
|
||||
favoritesSubject: favoritesSubject,
|
||||
sessionFactory: sessionFactory,
|
||||
scheduler: scheduler,
|
||||
clock: clock,
|
||||
torWaiter: torWaiter
|
||||
)
|
||||
}
|
||||
|
||||
private func makeFilter() -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [NostrProtocol.EventKind.textNote.rawValue]
|
||||
filter.limit = 10
|
||||
return filter
|
||||
}
|
||||
|
||||
private func makeSignedEvent(content: String) throws -> NostrEvent {
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .textNote,
|
||||
tags: [],
|
||||
content: content
|
||||
)
|
||||
return try event.sign(with: identity.schnorrSigningKey())
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if condition() {
|
||||
return true
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
return condition()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct RelayManagerTestContext {
|
||||
let manager: NostrRelayManager
|
||||
let permissionSubject: CurrentValueSubject<LocationChannelManager.PermissionState, Never>
|
||||
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
|
||||
let sessionFactory: MockRelaySessionFactory
|
||||
let scheduler: MockRelayScheduler
|
||||
let clock: MutableClock
|
||||
let torWaiter: MockTorWaiter
|
||||
}
|
||||
|
||||
private final class MutableClock {
|
||||
var now: Date
|
||||
|
||||
init(now: Date) {
|
||||
self.now = now
|
||||
}
|
||||
}
|
||||
|
||||
private final class MockTorWaiter {
|
||||
private var completions: [(Bool) -> Void] = []
|
||||
var isReady: Bool
|
||||
|
||||
init(isReady: Bool) {
|
||||
self.isReady = isReady
|
||||
}
|
||||
|
||||
func await(completion: @escaping (Bool) -> Void) {
|
||||
completions.append(completion)
|
||||
}
|
||||
|
||||
func resolve(_ ready: Bool) {
|
||||
isReady = ready
|
||||
let pending = completions
|
||||
completions.removeAll()
|
||||
pending.forEach { $0(ready) }
|
||||
}
|
||||
}
|
||||
|
||||
private final class MockRelayScheduler {
|
||||
struct ScheduledAction {
|
||||
let delay: TimeInterval
|
||||
let action: () -> Void
|
||||
}
|
||||
|
||||
private(set) var scheduled: [ScheduledAction] = []
|
||||
|
||||
func schedule(delay: TimeInterval, action: @escaping () -> Void) {
|
||||
scheduled.append(ScheduledAction(delay: delay, action: action))
|
||||
}
|
||||
|
||||
func runNext() {
|
||||
guard !scheduled.isEmpty else { return }
|
||||
let next = scheduled.removeFirst()
|
||||
next.action()
|
||||
}
|
||||
}
|
||||
|
||||
private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
|
||||
private(set) var requestedURLs: [String] = []
|
||||
private(set) var connectionsByURL: [String: [MockRelayConnection]] = [:]
|
||||
|
||||
var allConnections: [MockRelayConnection] {
|
||||
connectionsByURL.values.flatMap { $0 }
|
||||
}
|
||||
|
||||
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
|
||||
requestedURLs.append(url.absoluteString)
|
||||
let connection = MockRelayConnection(url: url.absoluteString)
|
||||
connectionsByURL[url.absoluteString, default: []].append(connection)
|
||||
return connection
|
||||
}
|
||||
|
||||
func latestConnection(for url: String) -> MockRelayConnection? {
|
||||
connectionsByURL[url]?.last
|
||||
}
|
||||
}
|
||||
|
||||
private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
private let url: String
|
||||
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||
private(set) var resumeCallCount = 0
|
||||
private(set) var cancelCallCount = 0
|
||||
private(set) var sentMessages: [URLSessionWebSocketTask.Message] = []
|
||||
|
||||
var sentStrings: [String] {
|
||||
sentMessages.compactMap {
|
||||
switch $0 {
|
||||
case .string(let string): string
|
||||
case .data(let data): String(data: data, encoding: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(url: String) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
func resume() {
|
||||
resumeCallCount += 1
|
||||
}
|
||||
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
cancelCallCount += 1
|
||||
}
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
|
||||
sentMessages.append(message)
|
||||
completionHandler(nil)
|
||||
}
|
||||
|
||||
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||
receiveHandler = completionHandler
|
||||
}
|
||||
|
||||
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
|
||||
pongReceiveHandler(nil)
|
||||
}
|
||||
|
||||
func fail(error: Error) {
|
||||
let handler = receiveHandler
|
||||
receiveHandler = nil
|
||||
handler?(.failure(error))
|
||||
}
|
||||
|
||||
func emitEventMessage(subscriptionID: String, event: NostrEvent) throws {
|
||||
let eventData = try JSONEncoder().encode(event)
|
||||
let eventJSONObject = try JSONSerialization.jsonObject(with: eventData) as! [String: Any]
|
||||
let payload: [Any] = ["EVENT", subscriptionID, eventJSONObject]
|
||||
try emit(jsonObject: payload)
|
||||
}
|
||||
|
||||
func emitEOSE(subscriptionID: String) throws {
|
||||
try emit(jsonObject: ["EOSE", subscriptionID])
|
||||
}
|
||||
|
||||
private func emit(jsonObject: Any) throws {
|
||||
let data = try JSONSerialization.data(withJSONObject: jsonObject)
|
||||
let handler = receiveHandler
|
||||
receiveHandler = nil
|
||||
handler?(.success(.data(data)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import XCTest
|
||||
import UserNotifications
|
||||
@testable import bitchat
|
||||
|
||||
final class NotificationServiceTests: XCTestCase {
|
||||
func test_requestAuthorization_skipsWhenRunningTests() {
|
||||
let authorizer = RecordingNotificationAuthorizer()
|
||||
let service = NotificationService(
|
||||
isRunningTestsProvider: { true },
|
||||
authorizer: authorizer,
|
||||
requestDeliverer: RecordingNotificationRequestDeliverer()
|
||||
)
|
||||
|
||||
service.requestAuthorization()
|
||||
|
||||
XCTAssertEqual(authorizer.requestCallCount, 0)
|
||||
}
|
||||
|
||||
func test_requestAuthorization_requestsAlertSoundAndBadgePermissions() {
|
||||
let authorizer = RecordingNotificationAuthorizer()
|
||||
let service = NotificationService(
|
||||
isRunningTestsProvider: { false },
|
||||
authorizer: authorizer,
|
||||
requestDeliverer: RecordingNotificationRequestDeliverer()
|
||||
)
|
||||
|
||||
service.requestAuthorization()
|
||||
|
||||
XCTAssertEqual(authorizer.requestCallCount, 1)
|
||||
XCTAssertEqual(authorizer.lastOptions, [.alert, .sound, .badge])
|
||||
}
|
||||
|
||||
func test_sendLocalNotification_buildsImmediateRequestWithUserInfo() {
|
||||
let deliverer = RecordingNotificationRequestDeliverer()
|
||||
let service = NotificationService(
|
||||
isRunningTestsProvider: { false },
|
||||
authorizer: RecordingNotificationAuthorizer(),
|
||||
requestDeliverer: deliverer
|
||||
)
|
||||
|
||||
service.sendLocalNotification(
|
||||
title: "Hello",
|
||||
body: "World",
|
||||
identifier: "custom-id",
|
||||
userInfo: ["peerID": "abcd"],
|
||||
interruptionLevel: .timeSensitive
|
||||
)
|
||||
|
||||
let request = deliverer.requests.singleValue
|
||||
XCTAssertEqual(request?.identifier, "custom-id")
|
||||
XCTAssertEqual(request?.content.title, "Hello")
|
||||
XCTAssertEqual(request?.content.body, "World")
|
||||
XCTAssertEqual(request?.content.userInfo["peerID"] as? String, "abcd")
|
||||
XCTAssertEqual(request?.content.interruptionLevel, .timeSensitive)
|
||||
XCTAssertNil(request?.trigger)
|
||||
}
|
||||
|
||||
func test_sendPrivateMessageNotification_populatesPeerMetadata() {
|
||||
let deliverer = RecordingNotificationRequestDeliverer()
|
||||
let service = NotificationService(
|
||||
isRunningTestsProvider: { false },
|
||||
authorizer: RecordingNotificationAuthorizer(),
|
||||
requestDeliverer: deliverer
|
||||
)
|
||||
let peerID = PeerID(str: "deadbeefdeadbeef")
|
||||
|
||||
service.sendPrivateMessageNotification(from: "Alice", message: "hi", peerID: peerID)
|
||||
|
||||
let request = deliverer.requests.singleValue
|
||||
XCTAssertEqual(request?.content.title, "🔒 DM from Alice")
|
||||
XCTAssertEqual(request?.content.body, "hi")
|
||||
XCTAssertEqual(request?.content.userInfo["peerID"] as? String, peerID.id)
|
||||
XCTAssertEqual(request?.content.userInfo["senderName"] as? String, "Alice")
|
||||
}
|
||||
|
||||
func test_wrapperNotifications_setExpectedIdentifiersAndDeepLinks() {
|
||||
let deliverer = RecordingNotificationRequestDeliverer()
|
||||
let service = NotificationService(
|
||||
isRunningTestsProvider: { false },
|
||||
authorizer: RecordingNotificationAuthorizer(),
|
||||
requestDeliverer: deliverer
|
||||
)
|
||||
|
||||
service.sendGeohashActivityNotification(geohash: "87yv", bodyPreview: "Someone is here")
|
||||
service.sendNetworkAvailableNotification(peerCount: 2)
|
||||
|
||||
XCTAssertEqual(deliverer.requests.count, 2)
|
||||
XCTAssertEqual(deliverer.requests[0].content.userInfo["deeplink"] as? String, "bitchat://geohash/87yv")
|
||||
XCTAssertTrue(deliverer.requests[0].identifier.hasPrefix("geo-activity-87yv-"))
|
||||
XCTAssertEqual(deliverer.requests[1].identifier, "network-available")
|
||||
XCTAssertEqual(deliverer.requests[1].content.interruptionLevel, .timeSensitive)
|
||||
XCTAssertEqual(deliverer.requests[1].content.body, "2 people around")
|
||||
}
|
||||
}
|
||||
|
||||
private final class RecordingNotificationAuthorizer: NotificationAuthorizing {
|
||||
private(set) var requestCallCount = 0
|
||||
private(set) var lastOptions: UNAuthorizationOptions?
|
||||
|
||||
func requestAuthorization(
|
||||
options: UNAuthorizationOptions,
|
||||
completionHandler: @escaping (Bool, Error?) -> Void
|
||||
) {
|
||||
requestCallCount += 1
|
||||
lastOptions = options
|
||||
completionHandler(true, nil)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RecordingNotificationRequestDeliverer: NotificationRequestDelivering {
|
||||
private(set) var requests: [UNNotificationRequest] = []
|
||||
|
||||
func add(_ request: UNNotificationRequest) {
|
||||
requests.append(request)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
var singleValue: Element? {
|
||||
count == 1 ? self[0] : nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
func test_upsertCryptographicIdentity_tracksByPeerIDPrefixAndClaimedNickname() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let noisePublicKey = Data(repeating: 0x11, count: 32)
|
||||
let signingPublicKey = Data(repeating: 0x22, count: 32)
|
||||
let fingerprint = noisePublicKey.sha256Fingerprint()
|
||||
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
claimedNickname: "Alice"
|
||||
)
|
||||
|
||||
let socialIdentityLoaded = await waitUntil {
|
||||
manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "Alice"
|
||||
}
|
||||
XCTAssertTrue(socialIdentityLoaded)
|
||||
let matches = manager.getCryptoIdentitiesByPeerIDPrefix(PeerID(publicKey: noisePublicKey))
|
||||
XCTAssertEqual(matches.count, 1)
|
||||
XCTAssertEqual(matches.first?.fingerprint, fingerprint)
|
||||
XCTAssertEqual(matches.first?.publicKey, noisePublicKey)
|
||||
XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey)
|
||||
}
|
||||
|
||||
func test_setBlocked_clearsFavoriteState() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "ab", count: 32)
|
||||
|
||||
manager.setFavorite(fingerprint, isFavorite: true)
|
||||
let favoriteSet = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }
|
||||
XCTAssertTrue(favoriteSet)
|
||||
|
||||
manager.setBlocked(fingerprint, isBlocked: true)
|
||||
let blockedSet = await waitUntil { manager.isBlocked(fingerprint: fingerprint) }
|
||||
XCTAssertTrue(blockedSet)
|
||||
|
||||
XCTAssertFalse(manager.isFavorite(fingerprint: fingerprint))
|
||||
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Unknown")
|
||||
}
|
||||
|
||||
func test_setVerified_updatesTrustLevelAndVerifiedSet() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "cd", count: 32)
|
||||
|
||||
manager.setFavorite(fingerprint, isFavorite: false)
|
||||
_ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }
|
||||
manager.setVerified(fingerprint: fingerprint, verified: true)
|
||||
|
||||
let verifiedSet = await waitUntil { manager.isVerified(fingerprint: fingerprint) }
|
||||
XCTAssertTrue(verifiedSet)
|
||||
XCTAssertTrue(manager.getVerifiedFingerprints().contains(fingerprint))
|
||||
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.trustLevel, .verified)
|
||||
}
|
||||
|
||||
func test_forceSave_persistsFavoriteStateAcrossReinit() async {
|
||||
let keychain = MockKeychain()
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
let fingerprint = String(repeating: "ef", count: 32)
|
||||
|
||||
manager.setFavorite(fingerprint, isFavorite: true)
|
||||
let favoriteSet = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }
|
||||
XCTAssertTrue(favoriteSet)
|
||||
manager.forceSave()
|
||||
|
||||
let reloaded = SecureIdentityStateManager(keychain)
|
||||
XCTAssertTrue(reloaded.isFavorite(fingerprint: fingerprint))
|
||||
}
|
||||
|
||||
func test_setNostrBlocked_normalizesToLowercaseAndPersists() async {
|
||||
let keychain = MockKeychain()
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
let pubkey = "ABCDEF1234"
|
||||
|
||||
manager.setNostrBlocked(pubkey, isBlocked: true)
|
||||
let nostrBlocked = await waitUntil {
|
||||
manager.isNostrBlocked(pubkeyHexLowercased: pubkey.lowercased())
|
||||
}
|
||||
XCTAssertTrue(nostrBlocked)
|
||||
manager.forceSave()
|
||||
|
||||
let reloaded = SecureIdentityStateManager(keychain)
|
||||
XCTAssertEqual(reloaded.getBlockedNostrPubkeys(), Set([pubkey.lowercased()]))
|
||||
XCTAssertTrue(reloaded.isNostrBlocked(pubkeyHexLowercased: pubkey))
|
||||
}
|
||||
|
||||
func test_corruptPersistedCache_fallsBackToEmptyState() {
|
||||
let keychain = MockKeychain()
|
||||
_ = keychain.saveIdentityKey(Data(repeating: 0x01, count: 32), forKey: "identityCacheEncryptionKey")
|
||||
_ = keychain.saveIdentityKey(Data([0xFF, 0x00, 0xAA]), forKey: "bitchat.identityCache.v2")
|
||||
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
|
||||
XCTAssertTrue(manager.getFavorites().isEmpty)
|
||||
XCTAssertTrue(manager.getVerifiedFingerprints().isEmpty)
|
||||
XCTAssertTrue(manager.getBlockedNostrPubkeys().isEmpty)
|
||||
}
|
||||
|
||||
func test_clearAllIdentityData_removesCachedState() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "12", count: 32)
|
||||
|
||||
manager.setFavorite(fingerprint, isFavorite: true)
|
||||
manager.setVerified(fingerprint: fingerprint, verified: true)
|
||||
manager.setNostrBlocked("ABCD", isBlocked: true)
|
||||
let primed = await waitUntil {
|
||||
manager.isFavorite(fingerprint: fingerprint) &&
|
||||
manager.isVerified(fingerprint: fingerprint)
|
||||
}
|
||||
XCTAssertTrue(primed)
|
||||
|
||||
manager.clearAllIdentityData()
|
||||
let cleared = await waitUntil {
|
||||
!manager.isFavorite(fingerprint: fingerprint) &&
|
||||
!manager.isVerified(fingerprint: fingerprint) &&
|
||||
manager.getBlockedNostrPubkeys().isEmpty
|
||||
}
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if condition() {
|
||||
return true
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
return condition()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
final class VerificationServiceTests: XCTestCase {
|
||||
func test_buildMyQRString_roundTripsSuccessfully() throws {
|
||||
let (service, noise) = makeService()
|
||||
let nickname = "alice-\(UUID().uuidString)"
|
||||
let npub = "npub1testvalue"
|
||||
|
||||
let qrString = try XCTUnwrap(service.buildMyQRString(nickname: nickname, npub: npub))
|
||||
let parsed = try XCTUnwrap(service.verifyScannedQR(qrString))
|
||||
|
||||
XCTAssertEqual(parsed.nickname, nickname)
|
||||
XCTAssertEqual(parsed.npub, npub)
|
||||
XCTAssertEqual(parsed.noiseKeyHex, noise.getStaticPublicKeyData().hexEncodedString())
|
||||
XCTAssertEqual(parsed.signKeyHex, noise.getSigningPublicKeyData().hexEncodedString())
|
||||
}
|
||||
|
||||
func test_buildMyQRString_returnsCachedValueForSameInputs() throws {
|
||||
let (service, _) = makeService()
|
||||
let nickname = "cache-\(UUID().uuidString)"
|
||||
|
||||
let first = try XCTUnwrap(service.buildMyQRString(nickname: nickname, npub: nil))
|
||||
let second = try XCTUnwrap(service.buildMyQRString(nickname: nickname, npub: nil))
|
||||
|
||||
XCTAssertEqual(first, second)
|
||||
}
|
||||
|
||||
func test_verifyScannedQR_rejectsExpiredPayload() throws {
|
||||
let (service, noise) = makeService()
|
||||
let oldTimestamp = Int64(Date().addingTimeInterval(-3600).timeIntervalSince1970)
|
||||
let qrString = try makeSignedQR(
|
||||
noise: noise,
|
||||
nickname: "expired-\(UUID().uuidString)",
|
||||
npub: nil,
|
||||
ts: oldTimestamp
|
||||
)
|
||||
|
||||
XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60))
|
||||
}
|
||||
|
||||
func test_verifyScannedQR_rejectsTamperedSignature() throws {
|
||||
let (service, noise) = makeService()
|
||||
let badSignature = Data(repeating: 0xAA, count: 64)
|
||||
let qrString = try makeSignedQR(
|
||||
noise: noise,
|
||||
nickname: "tampered-\(UUID().uuidString)",
|
||||
npub: nil,
|
||||
ts: Int64(Date().timeIntervalSince1970),
|
||||
signatureOverride: badSignature
|
||||
)
|
||||
|
||||
XCTAssertNil(service.verifyScannedQR(qrString))
|
||||
}
|
||||
|
||||
func test_buildVerifyChallenge_roundTripsThroughNoisePayload() throws {
|
||||
let (service, _) = makeService()
|
||||
let noiseKeyHex = String(repeating: "ab", count: 32)
|
||||
let nonce = Data([0x01, 0x02, 0x03, 0x04])
|
||||
|
||||
let encoded = service.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonce)
|
||||
let payload = try XCTUnwrap(NoisePayload.decode(encoded))
|
||||
let parsed = try XCTUnwrap(service.parseVerifyChallenge(payload.data))
|
||||
|
||||
XCTAssertEqual(payload.type, .verifyChallenge)
|
||||
XCTAssertEqual(parsed.noiseKeyHex, noiseKeyHex)
|
||||
XCTAssertEqual(parsed.nonceA, nonce)
|
||||
}
|
||||
|
||||
func test_buildVerifyResponse_roundTripsAndVerifiesSignature() throws {
|
||||
let (service, noise) = makeService()
|
||||
let noiseKeyHex = String(repeating: "cd", count: 32)
|
||||
let nonce = Data([0x10, 0x20, 0x30, 0x40, 0x50])
|
||||
|
||||
let encoded = try XCTUnwrap(service.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonce))
|
||||
let payload = try XCTUnwrap(NoisePayload.decode(encoded))
|
||||
let parsed = try XCTUnwrap(service.parseVerifyResponse(payload.data))
|
||||
|
||||
XCTAssertEqual(payload.type, .verifyResponse)
|
||||
XCTAssertEqual(parsed.noiseKeyHex, noiseKeyHex)
|
||||
XCTAssertEqual(parsed.nonceA, nonce)
|
||||
XCTAssertTrue(
|
||||
service.verifyResponseSignature(
|
||||
noiseKeyHex: parsed.noiseKeyHex,
|
||||
nonceA: parsed.nonceA,
|
||||
signature: parsed.signature,
|
||||
signerPublicKeyHex: noise.getSigningPublicKeyData().hexEncodedString()
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
service.verifyResponseSignature(
|
||||
noiseKeyHex: parsed.noiseKeyHex,
|
||||
nonceA: Data([0xFF]),
|
||||
signature: parsed.signature,
|
||||
signerPublicKeyHex: noise.getSigningPublicKeyData().hexEncodedString()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func makeService() -> (VerificationService, NoiseEncryptionService) {
|
||||
let noise = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let service = VerificationService()
|
||||
service.configure(with: noise)
|
||||
return (service, noise)
|
||||
}
|
||||
|
||||
private func makeSignedQR(
|
||||
noise: NoiseEncryptionService,
|
||||
nickname: String,
|
||||
npub: String?,
|
||||
ts: Int64,
|
||||
signatureOverride: Data? = nil
|
||||
) throws -> String {
|
||||
var payload = VerificationService.VerificationQR(
|
||||
v: 1,
|
||||
noiseKeyHex: noise.getStaticPublicKeyData().hexEncodedString(),
|
||||
signKeyHex: noise.getSigningPublicKeyData().hexEncodedString(),
|
||||
npub: npub,
|
||||
nickname: nickname,
|
||||
ts: ts,
|
||||
nonceB64: Data((0..<16).map(UInt8.init)).base64EncodedString(),
|
||||
sigHex: ""
|
||||
)
|
||||
let signature = try XCTUnwrap(signatureOverride ?? noise.signData(payload.canonicalBytes()))
|
||||
payload.sigHex = signature.hexEncodedString()
|
||||
return payload.toURLString()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user