mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:45:19 +00:00
Expand Nostr and identity coverage (#1059)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -4,6 +4,21 @@ import XCTest
|
||||
|
||||
@MainActor
|
||||
final class NostrRelayManagerTests: XCTestCase {
|
||||
func test_connect_directMode_connectsExistingDefaultRelaysWhenActivationBecomesAllowed() async {
|
||||
let context = makeContext(permission: .authorized, activationAllowed: false)
|
||||
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
context.activationAllowed.value = true
|
||||
|
||||
context.manager.connect()
|
||||
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.requestedURLs.count == 5 &&
|
||||
context.manager.relays.allSatisfy(\.isConnected)
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
}
|
||||
|
||||
func test_permissionPublisher_addsAndRemovesDefaultRelays() async {
|
||||
let context = makeContext(permission: .denied, favorites: [])
|
||||
|
||||
@@ -43,6 +58,103 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertTrue(connectedAfterTorReady)
|
||||
}
|
||||
|
||||
func test_connect_whenTorReadinessFailsDoesNotCreateSessions() async {
|
||||
let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
|
||||
context.manager.connect()
|
||||
context.torWaiter.resolve(false)
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
XCTAssertFalse(context.manager.isConnected)
|
||||
}
|
||||
|
||||
func test_sendEvent_waitsForTorReadinessBeforeSending() async throws {
|
||||
let relayURL = "wss://tor-ready.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
let event = try makeSignedEvent(content: "deferred")
|
||||
|
||||
context.manager.sendEvent(event, to: [relayURL])
|
||||
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
|
||||
context.torWaiter.resolve(true)
|
||||
|
||||
let sentAfterTorReady = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent == 1
|
||||
}
|
||||
XCTAssertTrue(sentAfterTorReady)
|
||||
}
|
||||
|
||||
func test_sendEvent_queuesWhileBackgroundedAndFlushesWhenForegrounded() async throws {
|
||||
let relayURL = "wss://queue-flush.example"
|
||||
let context = makeContext(
|
||||
permission: .denied,
|
||||
userTorEnabled: true,
|
||||
torEnforced: true,
|
||||
torIsReady: true,
|
||||
torIsForeground: false
|
||||
)
|
||||
let event = try makeSignedEvent(content: "queued")
|
||||
|
||||
context.manager.sendEvent(event, to: [relayURL])
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
context.torForeground.value = true
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
|
||||
let flushed = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent == 1
|
||||
}
|
||||
XCTAssertTrue(flushed)
|
||||
}
|
||||
|
||||
func test_sendEvent_sendFailureDoesNotIncrementMessageCount() async throws {
|
||||
let relayURL = "wss://send-failure.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
context.sessionFactory.sendErrorByURL[relayURL] = NSError(domain: "send", code: 1)
|
||||
let event = try makeSignedEvent(content: "send failure")
|
||||
|
||||
context.manager.sendEvent(event, to: [relayURL])
|
||||
|
||||
let attempted = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(attempted)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent, 0)
|
||||
}
|
||||
|
||||
func test_sendEvent_queueIsPrunedWhenDefaultRelaysAreRevoked() async throws {
|
||||
let context = makeContext(
|
||||
permission: .authorized,
|
||||
userTorEnabled: true,
|
||||
torEnforced: true,
|
||||
torIsReady: true,
|
||||
torIsForeground: false
|
||||
)
|
||||
let event = try makeSignedEvent(content: "queued default")
|
||||
|
||||
context.manager.sendEvent(event)
|
||||
|
||||
let queued = await waitUntil {
|
||||
context.manager.debugPendingMessageQueueCount == 1
|
||||
}
|
||||
XCTAssertTrue(queued)
|
||||
|
||||
context.permissionSubject.send(.denied)
|
||||
|
||||
let cleared = await waitUntil {
|
||||
context.manager.debugPendingMessageQueueCount == 0 &&
|
||||
context.manager.relays.isEmpty
|
||||
}
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
func test_connect_doesNothingWhenActivationIsDisallowed() {
|
||||
let context = makeContext(permission: .authorized, activationAllowed: false)
|
||||
|
||||
@@ -85,6 +197,81 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
|
||||
}
|
||||
|
||||
func test_subscribe_waitsForTorReadinessAndPreservesEOSECallback() async throws {
|
||||
let relayURL = "wss://tor-subscribe.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
var eoseCount = 0
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "tor-eose",
|
||||
relayUrls: [relayURL],
|
||||
handler: { _ in },
|
||||
onEOSE: { eoseCount += 1 }
|
||||
)
|
||||
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
|
||||
context.torWaiter.resolve(true)
|
||||
let subscribed = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(subscribed)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: "tor-eose")
|
||||
let eoseCompleted = await waitUntil { eoseCount == 1 }
|
||||
XCTAssertTrue(eoseCompleted)
|
||||
}
|
||||
|
||||
func test_subscribe_withoutAllowedRelays_callsEOSEImmediatelyAndDoesNotFlushLater() async {
|
||||
let context = makeContext(permission: .denied)
|
||||
var eoseCount = 0
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "blocked-defaults",
|
||||
handler: { _ in },
|
||||
onEOSE: { eoseCount += 1 }
|
||||
)
|
||||
|
||||
XCTAssertEqual(eoseCount, 1)
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
|
||||
context.permissionSubject.send(.authorized)
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.allConnections.count == 5 &&
|
||||
context.manager.relays.allSatisfy(\.isConnected)
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.sentStrings.isEmpty })
|
||||
}
|
||||
|
||||
func test_permissionRevocation_clearsQueuedDefaultSubscriptions() async {
|
||||
let context = makeContext(
|
||||
permission: .authorized,
|
||||
userTorEnabled: true,
|
||||
torEnforced: true,
|
||||
torIsReady: true,
|
||||
torIsForeground: false
|
||||
)
|
||||
let defaultRelay = "wss://relay.damus.io"
|
||||
|
||||
context.manager.subscribe(filter: makeFilter(), id: "queued-default", handler: { _ in })
|
||||
|
||||
let queued = await waitUntil {
|
||||
context.manager.debugPendingSubscriptionCount(for: defaultRelay) == 1
|
||||
}
|
||||
XCTAssertTrue(queued)
|
||||
|
||||
context.permissionSubject.send(.denied)
|
||||
|
||||
let cleared = await waitUntil {
|
||||
context.manager.debugPendingSubscriptionCount(for: defaultRelay) == 0 &&
|
||||
context.manager.relays.isEmpty
|
||||
}
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
func test_unsubscribe_allowsResubscribeWithSameID() async {
|
||||
let relayURL = "wss://subscribe.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
@@ -136,6 +323,88 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertEqual(receivedEvent?.id, event.id)
|
||||
}
|
||||
|
||||
func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws {
|
||||
let relayURL = "wss://missing-handler.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let event = try makeSignedEvent(content: "unhandled")
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL) != nil &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "missing", event: event)
|
||||
|
||||
let counted = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1
|
||||
}
|
||||
XCTAssertTrue(counted)
|
||||
}
|
||||
|
||||
func test_noticeAndMalformedMessages_keepReceiveLoopAliveForLaterEvents() async throws {
|
||||
let relayURL = "wss://parser.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
var receivedIDs: [String] = []
|
||||
let firstEvent = try makeSignedEvent(content: "after notice")
|
||||
let secondEvent = try makeSignedEvent(content: "after malformed")
|
||||
|
||||
context.manager.subscribe(filter: makeFilter(), id: "parser", relayUrls: [relayURL]) { event in
|
||||
receivedIDs.append(event.id)
|
||||
}
|
||||
let subscribed = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(subscribed)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitNotice(message: "ignored")
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "parser", event: firstEvent)
|
||||
|
||||
let firstDelivered = await waitUntil {
|
||||
receivedIDs == [firstEvent.id]
|
||||
}
|
||||
XCTAssertTrue(firstDelivered)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitRawString("not-json")
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "parser", event: secondEvent)
|
||||
|
||||
let secondDelivered = await waitUntil {
|
||||
receivedIDs == [firstEvent.id, secondEvent.id]
|
||||
}
|
||||
XCTAssertTrue(secondDelivered)
|
||||
}
|
||||
|
||||
func test_okMessages_clearPendingGiftWrapIDs() async throws {
|
||||
let relayURL = "wss://ok.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let successID = "gift-wrap-success"
|
||||
let failureID = "gift-wrap-failure"
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL) != nil &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
|
||||
NostrRelayManager.registerPendingGiftWrap(id: successID)
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: successID, success: true, reason: "ok")
|
||||
let successCleared = await waitUntil {
|
||||
!NostrRelayManager.pendingGiftWrapIDs.contains(successID)
|
||||
}
|
||||
XCTAssertTrue(successCleared)
|
||||
|
||||
NostrRelayManager.registerPendingGiftWrap(id: failureID)
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: failureID, success: false, reason: "rejected")
|
||||
let failureCleared = await waitUntil {
|
||||
!NostrRelayManager.pendingGiftWrapIDs.contains(failureID)
|
||||
}
|
||||
XCTAssertTrue(failureCleared)
|
||||
}
|
||||
|
||||
func test_eoseCallback_waitsForAllTargetedRelays() async throws {
|
||||
let relayOne = "wss://one.example"
|
||||
let relayTwo = "wss://two.example"
|
||||
@@ -166,6 +435,32 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertTrue(eoseCompleted)
|
||||
}
|
||||
|
||||
func test_eoseTimeout_invokesCallbackOnceAndIgnoresLateEOSE() async throws {
|
||||
let relayURL = "wss://timeout.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
var eoseCount = 0
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "timeout",
|
||||
relayUrls: [relayURL],
|
||||
handler: { _ in },
|
||||
onEOSE: { eoseCount += 1 }
|
||||
)
|
||||
|
||||
let subscribed = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(subscribed)
|
||||
|
||||
let timedOut = await waitUntil(timeout: 3.0) { eoseCount == 1 }
|
||||
XCTAssertTrue(timedOut)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: "timeout")
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
XCTAssertEqual(eoseCount, 1)
|
||||
}
|
||||
|
||||
func test_receiveFailure_schedulesReconnectWithBackoff() async {
|
||||
let relayURL = "wss://retry.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
@@ -195,6 +490,30 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertTrue(retried)
|
||||
}
|
||||
|
||||
func test_receiveFailure_whenActivationBecomesDisallowedDoesNotScheduleReconnect() async {
|
||||
let relayURL = "wss://no-retry.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL) != nil &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
|
||||
context.activationAllowed.value = false
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.fail(
|
||||
error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
|
||||
)
|
||||
|
||||
let disconnected = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == false
|
||||
}
|
||||
XCTAssertTrue(disconnected)
|
||||
XCTAssertTrue(context.scheduler.scheduled.isEmpty)
|
||||
XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1)
|
||||
}
|
||||
|
||||
func test_disconnect_invalidatesScheduledReconnectGeneration() async {
|
||||
let relayURL = "wss://disconnect.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
@@ -219,13 +538,172 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertEqual(context.sessionFactory.requestedURLs.count, requestCountBeforeDisconnect)
|
||||
}
|
||||
|
||||
func test_retryConnection_cancelsActiveConnectionBeforeReconnecting() async {
|
||||
let relayURL = "wss://retry-now.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL) != nil &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
|
||||
guard let firstConnection = context.sessionFactory.latestConnection(for: relayURL) else {
|
||||
XCTFail("Expected initial connection")
|
||||
return
|
||||
}
|
||||
let initialRequestCount = context.sessionFactory.requestedURLs.count
|
||||
|
||||
context.manager.retryConnection(to: relayURL)
|
||||
|
||||
let reconnected = await waitUntil {
|
||||
guard let latest = context.sessionFactory.latestConnection(for: relayURL) else { return false }
|
||||
return context.sessionFactory.requestedURLs.count == initialRequestCount + 1 &&
|
||||
latest !== firstConnection
|
||||
}
|
||||
XCTAssertTrue(reconnected)
|
||||
XCTAssertEqual(firstConnection.cancelCallCount, 1)
|
||||
}
|
||||
|
||||
func test_retryConnection_whenTorReadinessFailsDoesNotReconnect() async {
|
||||
let relayURL = "wss://retry-tor.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: true)
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL) != nil &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
|
||||
guard let firstConnection = context.sessionFactory.latestConnection(for: relayURL) else {
|
||||
XCTFail("Expected initial connection")
|
||||
return
|
||||
}
|
||||
|
||||
let initialRequestCount = context.sessionFactory.requestedURLs.count
|
||||
context.torWaiter.isReady = false
|
||||
context.manager.retryConnection(to: relayURL)
|
||||
|
||||
XCTAssertEqual(firstConnection.cancelCallCount, 1)
|
||||
XCTAssertEqual(context.sessionFactory.requestedURLs.count, initialRequestCount)
|
||||
|
||||
context.torWaiter.resolve(false)
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertEqual(context.sessionFactory.requestedURLs.count, initialRequestCount)
|
||||
}
|
||||
|
||||
func test_resetAllConnections_clearsRelayStateAndReconnects() async {
|
||||
let relayURL = "wss://reset.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL) != nil &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.fail(
|
||||
error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
|
||||
)
|
||||
let failed = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 1 &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.lastError != nil
|
||||
}
|
||||
XCTAssertTrue(failed)
|
||||
|
||||
let requestCountBeforeReset = context.sessionFactory.requestedURLs.count
|
||||
context.manager.resetAllConnections()
|
||||
|
||||
let reset = await waitUntil {
|
||||
context.sessionFactory.requestedURLs.count == requestCountBeforeReset + 1 &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 0 &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.nextReconnectTime == nil &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.lastError == nil
|
||||
}
|
||||
XCTAssertTrue(reset)
|
||||
}
|
||||
|
||||
func test_debugFlushMessageQueue_flushesAllConnectedRelays() async throws {
|
||||
let relayOne = "wss://flush-one.example"
|
||||
let relayTwo = "wss://flush-two.example"
|
||||
let context = makeContext(
|
||||
permission: .denied,
|
||||
userTorEnabled: true,
|
||||
torEnforced: true,
|
||||
torIsReady: true,
|
||||
torIsForeground: false
|
||||
)
|
||||
let event = try makeSignedEvent(content: "flush-all")
|
||||
|
||||
context.manager.sendEvent(event, to: [relayOne, relayTwo])
|
||||
let queued = await waitUntil {
|
||||
context.manager.debugPendingMessageQueueCount == 1
|
||||
}
|
||||
XCTAssertTrue(queued)
|
||||
|
||||
context.torForeground.value = true
|
||||
context.manager.ensureConnections(to: [relayOne, relayTwo])
|
||||
context.manager.debugFlushMessageQueue()
|
||||
|
||||
let flushed = await waitUntil {
|
||||
context.manager.debugPendingMessageQueueCount == 0 &&
|
||||
context.sessionFactory.latestConnection(for: relayOne)?.sentStrings.count == 1 &&
|
||||
context.sessionFactory.latestConnection(for: relayTwo)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(flushed)
|
||||
}
|
||||
|
||||
func test_dnsPingFailure_marksRelayPermanentCallsEOSEImmediatelyAndManualRetryReconnects() async {
|
||||
let relayURL = "wss://dns-failure.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
context.sessionFactory.pingErrorByURL[relayURL] = NSError(
|
||||
domain: NSURLErrorDomain,
|
||||
code: NSURLErrorCannotFindHost,
|
||||
userInfo: [NSLocalizedDescriptionKey: "DNS failure"]
|
||||
)
|
||||
|
||||
context.manager.subscribe(filter: makeFilter(), id: "dns-sub", relayUrls: [relayURL], handler: { _ in })
|
||||
let permanentlyFailed = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == TransportConfig.nostrRelayMaxReconnectAttempts &&
|
||||
context.scheduler.scheduled.isEmpty
|
||||
}
|
||||
XCTAssertTrue(permanentlyFailed)
|
||||
|
||||
var immediateEOSE = 0
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "dns-eose",
|
||||
relayUrls: [relayURL],
|
||||
handler: { _ in },
|
||||
onEOSE: { immediateEOSE += 1 }
|
||||
)
|
||||
XCTAssertEqual(immediateEOSE, 1)
|
||||
|
||||
context.sessionFactory.pingErrorByURL[relayURL] = nil
|
||||
let requestCountBeforeRetry = context.sessionFactory.requestedURLs.count
|
||||
context.manager.retryConnection(to: relayURL)
|
||||
|
||||
let reconnected = await waitUntil {
|
||||
context.sessionFactory.requestedURLs.count == requestCountBeforeRetry + 1 &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 0
|
||||
}
|
||||
XCTAssertTrue(reconnected)
|
||||
}
|
||||
|
||||
private func makeContext(
|
||||
permission: LocationChannelManager.PermissionState,
|
||||
favorites: Set<Data> = [],
|
||||
activationAllowed: Bool = true,
|
||||
userTorEnabled: Bool = false,
|
||||
torEnforced: Bool = false,
|
||||
torIsReady: Bool = true
|
||||
torIsReady: Bool = true,
|
||||
torIsForeground: Bool = true
|
||||
) -> RelayManagerTestContext {
|
||||
let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)
|
||||
let favoritesSubject = CurrentValueSubject<Set<Data>, Never>(favorites)
|
||||
@@ -233,9 +711,11 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
let scheduler = MockRelayScheduler()
|
||||
let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000))
|
||||
let torWaiter = MockTorWaiter(isReady: torIsReady)
|
||||
let torForeground = MutableBool(value: torIsForeground)
|
||||
let activationFlag = MutableBool(value: activationAllowed)
|
||||
let manager = NostrRelayManager(
|
||||
dependencies: NostrRelayManagerDependencies(
|
||||
activationAllowed: { activationAllowed },
|
||||
activationAllowed: { activationFlag.value },
|
||||
userTorEnabled: { userTorEnabled },
|
||||
hasMutualFavorites: { !favoritesSubject.value.isEmpty },
|
||||
hasLocationPermission: { permissionSubject.value == .authorized },
|
||||
@@ -243,10 +723,12 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
||||
torEnforced: { torEnforced },
|
||||
torIsReady: { torWaiter.isReady },
|
||||
torIsForeground: { true },
|
||||
torIsForeground: { torForeground.value },
|
||||
awaitTorReady: torWaiter.await(completion:),
|
||||
makeSession: { sessionFactory },
|
||||
scheduleAfter: scheduler.schedule(delay:action:),
|
||||
scheduleAfter: { delay, action in
|
||||
scheduler.schedule(delay: delay, action: action)
|
||||
},
|
||||
now: { clock.now }
|
||||
)
|
||||
)
|
||||
@@ -257,7 +739,9 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
sessionFactory: sessionFactory,
|
||||
scheduler: scheduler,
|
||||
clock: clock,
|
||||
torWaiter: torWaiter
|
||||
activationAllowed: activationFlag,
|
||||
torWaiter: torWaiter,
|
||||
torForeground: torForeground
|
||||
)
|
||||
}
|
||||
|
||||
@@ -303,7 +787,9 @@ private struct RelayManagerTestContext {
|
||||
let sessionFactory: MockRelaySessionFactory
|
||||
let scheduler: MockRelayScheduler
|
||||
let clock: MutableClock
|
||||
let activationAllowed: MutableBool
|
||||
let torWaiter: MockTorWaiter
|
||||
let torForeground: MutableBool
|
||||
}
|
||||
|
||||
private final class MutableClock {
|
||||
@@ -314,6 +800,14 @@ private final class MutableClock {
|
||||
}
|
||||
}
|
||||
|
||||
private final class MutableBool {
|
||||
var value: Bool
|
||||
|
||||
init(value: Bool) {
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
||||
private final class MockTorWaiter {
|
||||
private var completions: [(Bool) -> Void] = []
|
||||
var isReady: Bool
|
||||
@@ -334,15 +828,15 @@ private final class MockTorWaiter {
|
||||
}
|
||||
}
|
||||
|
||||
private final class MockRelayScheduler {
|
||||
private final class MockRelayScheduler: @unchecked Sendable {
|
||||
struct ScheduledAction {
|
||||
let delay: TimeInterval
|
||||
let action: () -> Void
|
||||
let action: @Sendable () -> Void
|
||||
}
|
||||
|
||||
private(set) var scheduled: [ScheduledAction] = []
|
||||
|
||||
func schedule(delay: TimeInterval, action: @escaping () -> Void) {
|
||||
func schedule(delay: TimeInterval, action: @escaping @Sendable () -> Void) {
|
||||
scheduled.append(ScheduledAction(delay: delay, action: action))
|
||||
}
|
||||
|
||||
@@ -356,6 +850,8 @@ private final class MockRelayScheduler {
|
||||
private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
|
||||
private(set) var requestedURLs: [String] = []
|
||||
private(set) var connectionsByURL: [String: [MockRelayConnection]] = [:]
|
||||
var pingErrorByURL: [String: Error?] = [:]
|
||||
var sendErrorByURL: [String: Error?] = [:]
|
||||
|
||||
var allConnections: [MockRelayConnection] {
|
||||
connectionsByURL.values.flatMap { $0 }
|
||||
@@ -363,7 +859,11 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
|
||||
|
||||
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
|
||||
requestedURLs.append(url.absoluteString)
|
||||
let connection = MockRelayConnection(url: url.absoluteString)
|
||||
let connection = MockRelayConnection(
|
||||
url: url.absoluteString,
|
||||
pingError: pingErrorByURL[url.absoluteString] ?? nil,
|
||||
sendError: sendErrorByURL[url.absoluteString] ?? nil
|
||||
)
|
||||
connectionsByURL[url.absoluteString, default: []].append(connection)
|
||||
return connection
|
||||
}
|
||||
@@ -375,6 +875,8 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
|
||||
|
||||
private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
private let url: String
|
||||
private let pingError: Error?
|
||||
private let sendError: Error?
|
||||
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||
private(set) var resumeCallCount = 0
|
||||
private(set) var cancelCallCount = 0
|
||||
@@ -390,8 +892,10 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
init(url: String) {
|
||||
init(url: String, pingError: Error? = nil, sendError: Error? = nil) {
|
||||
self.url = url
|
||||
self.pingError = pingError
|
||||
self.sendError = sendError
|
||||
}
|
||||
|
||||
func resume() {
|
||||
@@ -404,7 +908,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
|
||||
sentMessages.append(message)
|
||||
completionHandler(nil)
|
||||
completionHandler(sendError)
|
||||
}
|
||||
|
||||
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||
@@ -412,7 +916,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
}
|
||||
|
||||
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
|
||||
pongReceiveHandler(nil)
|
||||
pongReceiveHandler(pingError)
|
||||
}
|
||||
|
||||
func fail(error: Error) {
|
||||
@@ -432,6 +936,20 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
try emit(jsonObject: ["EOSE", subscriptionID])
|
||||
}
|
||||
|
||||
func emitOK(eventID: String, success: Bool, reason: String) throws {
|
||||
try emit(jsonObject: ["OK", eventID, success, reason])
|
||||
}
|
||||
|
||||
func emitNotice(message: String) throws {
|
||||
try emit(jsonObject: ["NOTICE", message])
|
||||
}
|
||||
|
||||
func emitRawString(_ string: String) throws {
|
||||
let handler = receiveHandler
|
||||
receiveHandler = nil
|
||||
handler?(.success(.string(string)))
|
||||
}
|
||||
|
||||
private func emit(jsonObject: Any) throws {
|
||||
let data = try JSONSerialization.data(withJSONObject: jsonObject)
|
||||
let handler = receiveHandler
|
||||
|
||||
@@ -3,6 +3,57 @@ import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
func test_upsertCryptographicIdentity_withoutClaimedNicknameDoesNotCreateSocialIdentity() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "aa", count: 32)
|
||||
let peerID = PeerID(str: String(fingerprint.prefix(16)))
|
||||
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||
claimedNickname: nil
|
||||
)
|
||||
|
||||
let inserted = await waitUntil {
|
||||
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).count == 1
|
||||
}
|
||||
XCTAssertTrue(inserted)
|
||||
XCTAssertNil(manager.getSocialIdentity(for: fingerprint))
|
||||
}
|
||||
|
||||
func test_upsertCryptographicIdentity_updatesExistingKeyAndPreservesSigningKey() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "ab", count: 32)
|
||||
let peerID = PeerID(str: String(fingerprint.prefix(16)))
|
||||
let originalNoiseKey = Data(repeating: 0x11, count: 32)
|
||||
let updatedNoiseKey = Data(repeating: 0x33, count: 32)
|
||||
let signingKey = Data(repeating: 0x22, count: 32)
|
||||
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: originalNoiseKey,
|
||||
signingPublicKey: signingKey,
|
||||
claimedNickname: nil
|
||||
)
|
||||
_ = await waitUntil {
|
||||
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.publicKey == originalNoiseKey
|
||||
}
|
||||
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: updatedNoiseKey,
|
||||
signingPublicKey: nil,
|
||||
claimedNickname: nil
|
||||
)
|
||||
|
||||
let updated = await waitUntil {
|
||||
guard let identity = manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first else { return false }
|
||||
return identity.publicKey == updatedNoiseKey && identity.signingPublicKey == signingKey
|
||||
}
|
||||
XCTAssertTrue(updated)
|
||||
}
|
||||
|
||||
func test_upsertCryptographicIdentity_tracksByPeerIDPrefixAndClaimedNickname() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let noisePublicKey = Data(repeating: 0x11, count: 32)
|
||||
@@ -43,6 +94,12 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Unknown")
|
||||
}
|
||||
|
||||
func test_isBlocked_unknownFingerprintReturnsFalse() {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
|
||||
XCTAssertFalse(manager.isBlocked(fingerprint: String(repeating: "ff", count: 32)))
|
||||
}
|
||||
|
||||
func test_setVerified_updatesTrustLevelAndVerifiedSet() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "cd", count: 32)
|
||||
@@ -71,6 +128,209 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
XCTAssertTrue(reloaded.isFavorite(fingerprint: fingerprint))
|
||||
}
|
||||
|
||||
func test_updateSocialIdentity_reindexesClaimedNickname() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "34", count: 32)
|
||||
|
||||
manager.updateSocialIdentity(
|
||||
SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: "Alice",
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
)
|
||||
let initialIndexed = await waitUntil {
|
||||
manager.debugNicknameIndex["Alice"]?.contains(fingerprint) == true
|
||||
}
|
||||
XCTAssertTrue(initialIndexed)
|
||||
|
||||
manager.updateSocialIdentity(
|
||||
SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: "Friend",
|
||||
claimedNickname: "Bob",
|
||||
trustLevel: .trusted,
|
||||
isFavorite: true,
|
||||
isBlocked: false,
|
||||
notes: "updated"
|
||||
)
|
||||
)
|
||||
|
||||
let reindexed = await waitUntil {
|
||||
manager.debugNicknameIndex["Alice"]?.contains(fingerprint) != true &&
|
||||
manager.debugNicknameIndex["Bob"]?.contains(fingerprint) == true
|
||||
}
|
||||
XCTAssertTrue(reindexed)
|
||||
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Bob")
|
||||
}
|
||||
|
||||
func test_upsertCryptographicIdentity_sameClaimedNicknamePreservesExistingSocialIdentity() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "35", count: 32)
|
||||
|
||||
manager.updateSocialIdentity(
|
||||
SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: "Pal",
|
||||
claimedNickname: "Alice",
|
||||
trustLevel: .trusted,
|
||||
isFavorite: true,
|
||||
isBlocked: false,
|
||||
notes: "keep me"
|
||||
)
|
||||
)
|
||||
_ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }
|
||||
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||
claimedNickname: "Alice"
|
||||
)
|
||||
|
||||
let inserted = await waitUntil {
|
||||
manager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: String(fingerprint.prefix(16)))).count == 1
|
||||
}
|
||||
XCTAssertTrue(inserted)
|
||||
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.localPetname, "Pal")
|
||||
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.notes, "keep me")
|
||||
XCTAssertTrue(manager.getSocialIdentity(for: fingerprint)?.isFavorite == true)
|
||||
}
|
||||
|
||||
func test_getFavorites_returnsOnlyFavoritedFingerprints() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let favoriteOne = String(repeating: "45", count: 32)
|
||||
let favoriteTwo = String(repeating: "56", count: 32)
|
||||
let other = String(repeating: "67", count: 32)
|
||||
|
||||
manager.setFavorite(favoriteOne, isFavorite: true)
|
||||
manager.setFavorite(favoriteTwo, isFavorite: true)
|
||||
manager.setFavorite(other, isFavorite: false)
|
||||
|
||||
let favoritesLoaded = await waitUntil {
|
||||
manager.getFavorites() == Set([favoriteOne, favoriteTwo])
|
||||
}
|
||||
XCTAssertTrue(favoritesLoaded)
|
||||
}
|
||||
|
||||
func test_setFavorite_existingIdentityCanBeClearedWithoutChangingNickname() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "68", count: 32)
|
||||
|
||||
manager.updateSocialIdentity(
|
||||
SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: "Alice",
|
||||
trustLevel: .trusted,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
)
|
||||
_ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }
|
||||
|
||||
manager.setFavorite(fingerprint, isFavorite: true)
|
||||
_ = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }
|
||||
|
||||
manager.setFavorite(fingerprint, isFavorite: false)
|
||||
let cleared = await waitUntil {
|
||||
!manager.isFavorite(fingerprint: fingerprint) &&
|
||||
manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "Alice" &&
|
||||
manager.getSocialIdentity(for: fingerprint)?.trustLevel == .trusted
|
||||
}
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
func test_setBlocked_createsIdentityAndCanLaterUnblock() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "78", count: 32)
|
||||
|
||||
manager.setBlocked(fingerprint, isBlocked: true)
|
||||
let blocked = await waitUntil {
|
||||
manager.isBlocked(fingerprint: fingerprint)
|
||||
}
|
||||
XCTAssertTrue(blocked)
|
||||
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Unknown")
|
||||
|
||||
manager.setBlocked(fingerprint, isBlocked: false)
|
||||
let unblocked = await waitUntil {
|
||||
!manager.isBlocked(fingerprint: fingerprint)
|
||||
}
|
||||
XCTAssertTrue(unblocked)
|
||||
}
|
||||
|
||||
func test_setVerified_false_downgradesTrustLevelToCasual() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "89", count: 32)
|
||||
|
||||
manager.updateSocialIdentity(
|
||||
SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: "Verifier",
|
||||
trustLevel: .trusted,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
)
|
||||
_ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }
|
||||
|
||||
manager.setVerified(fingerprint: fingerprint, verified: true)
|
||||
_ = await waitUntil { manager.isVerified(fingerprint: fingerprint) }
|
||||
|
||||
manager.setVerified(fingerprint: fingerprint, verified: false)
|
||||
let downgraded = await waitUntil {
|
||||
!manager.isVerified(fingerprint: fingerprint) &&
|
||||
manager.getSocialIdentity(for: fingerprint)?.trustLevel == .casual
|
||||
}
|
||||
XCTAssertTrue(downgraded)
|
||||
}
|
||||
|
||||
func test_ephemeralSessionLifecycle_tracksHandshakeProgressAndLastInteraction() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let peerID = PeerID(str: "1234567890abcdef")
|
||||
let fingerprint = String(repeating: "90", count: 32)
|
||||
|
||||
manager.registerEphemeralSession(peerID: peerID, handshakeState: .initiated)
|
||||
let registered = await waitUntil {
|
||||
if case .initiated? = manager.debugEphemeralSession(for: peerID)?.handshakeState {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
XCTAssertTrue(registered)
|
||||
|
||||
manager.updateHandshakeState(peerID: peerID, state: .inProgress)
|
||||
let progressed = await waitUntil {
|
||||
if case .inProgress? = manager.debugEphemeralSession(for: peerID)?.handshakeState {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
XCTAssertTrue(progressed)
|
||||
|
||||
manager.updateHandshakeState(peerID: peerID, state: .completed(fingerprint: fingerprint))
|
||||
let completed = await waitUntil {
|
||||
if case .completed(let completedFingerprint)? = manager.debugEphemeralSession(for: peerID)?.handshakeState {
|
||||
return completedFingerprint == fingerprint && manager.debugLastInteraction(for: fingerprint) != nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
XCTAssertTrue(completed)
|
||||
|
||||
manager.removeEphemeralSession(peerID: peerID)
|
||||
let removed = await waitUntil {
|
||||
manager.debugEphemeralSession(for: peerID) == nil
|
||||
}
|
||||
XCTAssertTrue(removed)
|
||||
}
|
||||
|
||||
func test_setNostrBlocked_normalizesToLowercaseAndPersists() async {
|
||||
let keychain = MockKeychain()
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
@@ -88,6 +348,21 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
XCTAssertTrue(reloaded.isNostrBlocked(pubkeyHexLowercased: pubkey))
|
||||
}
|
||||
|
||||
func test_setNostrBlocked_falseRemovesExistingKey() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let pubkey = "ABCDEF1234"
|
||||
|
||||
manager.setNostrBlocked(pubkey, isBlocked: true)
|
||||
_ = await waitUntil { manager.isNostrBlocked(pubkeyHexLowercased: pubkey) }
|
||||
|
||||
manager.setNostrBlocked(pubkey, isBlocked: false)
|
||||
let cleared = await waitUntil {
|
||||
!manager.isNostrBlocked(pubkeyHexLowercased: pubkey) &&
|
||||
manager.getBlockedNostrPubkeys().isEmpty
|
||||
}
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
func test_corruptPersistedCache_fallsBackToEmptyState() {
|
||||
let keychain = MockKeychain()
|
||||
_ = keychain.saveIdentityKey(Data(repeating: 0x01, count: 32), forKey: "identityCacheEncryptionKey")
|
||||
@@ -122,6 +397,21 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async {
|
||||
let keychain = FailingCacheSaveKeychain()
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
let fingerprint = String(repeating: "de", count: 32)
|
||||
|
||||
manager.setFavorite(fingerprint, isFavorite: true)
|
||||
let primed = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }
|
||||
XCTAssertTrue(primed)
|
||||
|
||||
manager.forceSave()
|
||||
|
||||
let reloaded = SecureIdentityStateManager(keychain)
|
||||
XCTAssertFalse(reloaded.isFavorite(fingerprint: fingerprint))
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping () -> Bool
|
||||
@@ -136,3 +426,72 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
return condition()
|
||||
}
|
||||
}
|
||||
|
||||
private final class FailingCacheSaveKeychain: KeychainManagerProtocol {
|
||||
private var storage: [String: Data] = [:]
|
||||
private var serviceStorage: [String: [String: Data]] = [:]
|
||||
|
||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||
if key == "bitchat.identityCache.v2" {
|
||||
return false
|
||||
}
|
||||
storage[key] = keyData
|
||||
return true
|
||||
}
|
||||
|
||||
func getIdentityKey(forKey key: String) -> Data? {
|
||||
storage[key]
|
||||
}
|
||||
|
||||
func deleteIdentityKey(forKey key: String) -> Bool {
|
||||
storage.removeValue(forKey: key)
|
||||
return true
|
||||
}
|
||||
|
||||
func deleteAllKeychainData() -> Bool {
|
||||
storage.removeAll()
|
||||
serviceStorage.removeAll()
|
||||
return true
|
||||
}
|
||||
|
||||
func secureClear(_ data: inout Data) {
|
||||
data = Data()
|
||||
}
|
||||
|
||||
func secureClear(_ string: inout String) {
|
||||
string = ""
|
||||
}
|
||||
|
||||
func verifyIdentityKeyExists() -> Bool {
|
||||
storage["identity_noiseStaticKey"] != nil
|
||||
}
|
||||
|
||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
||||
if let data = storage[key] {
|
||||
return .success(data)
|
||||
}
|
||||
return .itemNotFound
|
||||
}
|
||||
|
||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
||||
if saveIdentityKey(keyData, forKey: key) {
|
||||
return .success
|
||||
}
|
||||
return .otherError(OSStatus(-1))
|
||||
}
|
||||
|
||||
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
||||
if serviceStorage[service] == nil {
|
||||
serviceStorage[service] = [:]
|
||||
}
|
||||
serviceStorage[service]?[key] = data
|
||||
}
|
||||
|
||||
func load(key: String, service: String) -> Data? {
|
||||
serviceStorage[service]?[key]
|
||||
}
|
||||
|
||||
func delete(key: String, service: String) {
|
||||
serviceStorage[service]?.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user