From db7bc29d679845e397ddd0074bae6b447b440ff1 Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 25 Jul 2026 22:00:31 +0200 Subject: [PATCH] Surface terminal Nostr DM failures --- bitchat/Services/NostrTransport.swift | 34 ++++- .../ChatPrivateConversationCoordinator.swift | 11 +- .../ChatViewModelExtensionsTests.swift | 57 ++++++++ .../Services/NostrTransportTests.swift | 129 ++++++++++++++++++ 4 files changed, 224 insertions(+), 7 deletions(-) diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index 71f6befb..e8b69081 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -317,16 +317,40 @@ final class NostrTransport: Transport, @unchecked Sendable { func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { Task { @MainActor in - guard let recipientNpub = resolveRecipientNpub(for: peerID), - let recipientHex = npubToHex(recipientNpub), - let senderIdentity = try? dependencies.currentIdentity() else { return } + let failurePolicy = PrivateEnvelopeFailurePolicy.userMessage( + messageID: messageID + ) + guard let recipientNpub = resolveRecipientNpub(for: peerID) else { + handlePrivateEnvelopeFailure( + events: [], + registerPending: false, + policy: failurePolicy + ) + return + } + guard let recipientHex = npubToHex(recipientNpub) else { + handlePrivateEnvelopeFailure( + events: [], + registerPending: false, + policy: failurePolicy + ) + return + } + guard let senderIdentity = try? dependencies.currentIdentity() else { + handlePrivateEnvelopeFailure( + events: [], + registerPending: false, + policy: failurePolicy + ) + return + } SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session) guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session) handlePrivateEnvelopeFailure( events: [], registerPending: false, - policy: .userMessage(messageID: messageID) + policy: failurePolicy ) return } @@ -334,7 +358,7 @@ final class NostrTransport: Transport, @unchecked Sendable { content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity, - failurePolicy: .userMessage(messageID: messageID) + failurePolicy: failurePolicy ) } } diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index 3e0cb952..353fcd28 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -228,9 +228,16 @@ extension ChatViewModel: ChatPrivateConversationContext { NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID) } - private func makeGeohashNostrTransport() -> NostrTransport { - let transport = NostrTransport(keychain: keychain, idBridge: idBridge) + func makeGeohashNostrTransport( + dependencies: NostrTransport.Dependencies? = nil + ) -> NostrTransport { + let transport = NostrTransport( + keychain: keychain, + idBridge: idBridge, + dependencies: dependencies + ) transport.senderPeerID = meshService.myPeerID + transport.eventDelegate = self return transport } } diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index 67b018a1..be98a107 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -786,6 +786,63 @@ struct ChatViewModelGeoDMTests { #expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus)) } + @Test @MainActor + func geohashTerminalRelayFailure_transitionsSentMessageToFailed() async throws { + let (viewModel, _) = makeTestableViewModel() + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let messageID = "geo-terminal-failure" + let convKey = PeerID(nostr_: recipient.publicKeyHex) + let message = BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "queued geohash message", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "recipient", + senderPeerID: viewModel.myPeerID, + deliveryStatus: .sent + ) + viewModel.seedPrivateChat([message], for: convKey) + + var terminalFailure: (@MainActor () -> Void)? + let dependencies = NostrTransport.Dependencies( + notificationCenter: NotificationCenter(), + loadFavorites: { [:] }, + favoriteStatusForNoiseKey: { _ in nil }, + favoriteStatusForPeerID: { _ in nil }, + currentIdentity: { sender }, + registerPendingPrivateEnvelope: { _ in }, + sendPrivateEnvelopeBatch: { _, failure in + terminalFailure = failure + return true + }, + scheduleAfter: { _, _ in }, + relayConnectivity: { + Just(false).eraseToAnyPublisher() + } + ) + let transport = viewModel.makeGeohashNostrTransport( + dependencies: dependencies + ) + + let accepted = transport.sendPrivateMessageGeohash( + content: message.content, + toRecipientHex: recipient.publicKeyHex, + from: sender, + messageID: messageID + ) + + #expect(accepted) + #expect(viewModel.privateChats[convKey]?.first?.deliveryStatus == .sent) + let fail = try #require(terminalFailure) + fail() + #expect(isFailed( + status: viewModel.privateChats[convKey]?.first?.deliveryStatus + )) + } + /// The blocked notice belongs in the DM thread the person is typing in, /// not on the active location-channel timeline. @Test @MainActor diff --git a/bitchatTests/Services/NostrTransportTests.swift b/bitchatTests/Services/NostrTransportTests.swift index e823e3cf..8ae7907d 100644 --- a/bitchatTests/Services/NostrTransportTests.swift +++ b/bitchatTests/Services/NostrTransportTests.swift @@ -424,6 +424,135 @@ struct NostrTransportTests { #expect(publicationProbe.sentEvents.isEmpty) } + @Test("Direct message with an invalid npub emits a visible failure") + @MainActor + func invalidDirectRecipientNpubEmitsVisibleFailure() async throws { + let keychain = MockKeychain() + let idBridge = NostrIdentityBridge(keychain: keychain) + let sender = try NostrIdentity.generate() + let noiseKey = Data(repeating: 0xA5, count: 32) + let peerID = PeerID(hexData: noiseKey) + let relationship = makeRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: "not-a-valid-npub", + peerNickname: "Invalid npub peer" + ) + let eventProbe = NostrTransportEventProbe() + let publicationProbe = NostrTransportProbe() + let transport = NostrTransport( + keychain: keychain, + idBridge: idBridge, + dependencies: makeDependencies( + favoriteStatusForNoiseKey: { + $0 == noiseKey ? relationship : nil + }, + currentIdentity: { sender }, + sendPrivateEnvelopeBatch: { events, _ in + publicationProbe.record(batch: events) + } + ) + ) + transport.senderPeerID = PeerID(str: "0123456789abcdef") + transport.eventDelegate = eventProbe + + transport.sendPrivateMessage( + "must fail before publication", + to: peerID, + recipientNickname: "Invalid npub peer", + messageID: "invalid-npub" + ) + + let failed = await TestHelpers.waitUntil( + { eventProbe.failedMessageIDs == ["invalid-npub"] }, + timeout: 5.0 + ) + #expect(failed) + #expect(publicationProbe.sentEvents.isEmpty) + } + + @Test("Direct message without a resolvable recipient emits a visible failure") + @MainActor + func missingDirectRecipientEmitsVisibleFailure() async throws { + let keychain = MockKeychain() + let idBridge = NostrIdentityBridge(keychain: keychain) + let sender = try NostrIdentity.generate() + let peerID = PeerID(hexData: Data(repeating: 0xC3, count: 32)) + let eventProbe = NostrTransportEventProbe() + let publicationProbe = NostrTransportProbe() + let transport = NostrTransport( + keychain: keychain, + idBridge: idBridge, + dependencies: makeDependencies( + currentIdentity: { sender }, + sendPrivateEnvelopeBatch: { events, _ in + publicationProbe.record(batch: events) + } + ) + ) + transport.senderPeerID = PeerID(str: "0123456789abcdef") + transport.eventDelegate = eventProbe + + transport.sendPrivateMessage( + "must fail without recipient", + to: peerID, + recipientNickname: "Unknown peer", + messageID: "missing-recipient" + ) + + let failed = await TestHelpers.waitUntil( + { eventProbe.failedMessageIDs == ["missing-recipient"] }, + timeout: 5.0 + ) + #expect(failed) + #expect(publicationProbe.sentEvents.isEmpty) + } + + @Test("Direct message without a current identity emits a visible failure") + @MainActor + func missingDirectSenderIdentityEmitsVisibleFailure() async throws { + let keychain = MockKeychain() + let idBridge = NostrIdentityBridge(keychain: keychain) + let recipient = try NostrIdentity.generate() + let noiseKey = Data(repeating: 0x5A, count: 32) + let peerID = PeerID(hexData: noiseKey) + let relationship = makeRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: recipient.npub, + peerNickname: "Missing identity peer" + ) + let eventProbe = NostrTransportEventProbe() + let publicationProbe = NostrTransportProbe() + let transport = NostrTransport( + keychain: keychain, + idBridge: idBridge, + dependencies: makeDependencies( + favoriteStatusForNoiseKey: { + $0 == noiseKey ? relationship : nil + }, + currentIdentity: { nil }, + sendPrivateEnvelopeBatch: { events, _ in + publicationProbe.record(batch: events) + } + ) + ) + transport.senderPeerID = PeerID(str: "0123456789abcdef") + transport.eventDelegate = eventProbe + + transport.sendPrivateMessage( + "must fail without identity", + to: peerID, + recipientNickname: "Missing identity peer", + messageID: "missing-identity" + ) + + let failed = await TestHelpers.waitUntil( + { eventProbe.failedMessageIDs == ["missing-identity"] }, + timeout: 5.0 + ) + #expect(failed) + #expect(publicationProbe.sentEvents.isEmpty) + } + @Test("Rejected favorite notification retains and retries the exact pair") @MainActor func rejectedFavoriteNotificationRetriesExactPair() async throws {