mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 21:25:22 +00:00
Reject unsendable Nostr DMs visibly
This commit is contained in:
@@ -176,6 +176,7 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
||||
private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = []
|
||||
private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = []
|
||||
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
|
||||
var geohashPrivateMessageAccepted = true
|
||||
|
||||
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
routedPrivateMessages.append((content, peerID, messageID))
|
||||
@@ -191,8 +192,14 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
||||
meshReadReceipts.append((receipt.originalMessageID, peerID))
|
||||
}
|
||||
|
||||
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||
func sendGeohashPrivateMessage(
|
||||
_ content: String,
|
||||
toRecipientHex recipientHex: String,
|
||||
from identity: NostrIdentity,
|
||||
messageID: String
|
||||
) -> Bool {
|
||||
geoPrivateMessages.append((content, recipientHex, messageID))
|
||||
return geohashPrivateMessageAccepted
|
||||
}
|
||||
|
||||
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
@@ -279,6 +286,11 @@ private func isRead(_ status: DeliveryStatus?, by expected: String) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
private func isFailed(_ status: DeliveryStatus?) -> Bool {
|
||||
if case .failed = status { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func makeFavoriteRelationship(
|
||||
noiseKey: Data,
|
||||
nostrPublicKey: String? = nil,
|
||||
@@ -412,6 +424,25 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
#expect(context.privateChats[convKey]?.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendGeohashDM_rejectedAdmissionNeverBecomesSent() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let recipientHex = String(repeating: "33", count: 32)
|
||||
let peerID = PeerID(nostr_: recipientHex)
|
||||
context.activeChannel = .location(
|
||||
GeohashChannel(level: .city, geohash: "u4pruy")
|
||||
)
|
||||
context.nostrKeyMapping[peerID] = recipientHex
|
||||
context.geohashPrivateMessageAccepted = false
|
||||
|
||||
coordinator.sendGeohashDM("rejected", to: peerID)
|
||||
|
||||
#expect(context.geoPrivateMessages.map(\.content) == ["rejected"])
|
||||
#expect(context.privateChats[peerID]?.count == 1)
|
||||
#expect(isFailed(context.privateChats[peerID]?.first?.deliveryStatus))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleViewingThisChat_clearsUnreadAndSendsRoutedReadReceiptOnce() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
|
||||
@@ -399,6 +399,27 @@ struct NostrProtocolTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func privateEnvelopePublicationBatchRejectsMaximumComposerPayload() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let composerMaximum = String(
|
||||
repeating: "x",
|
||||
count: InputValidator.Limits.maxMessageLength
|
||||
)
|
||||
|
||||
#expect(
|
||||
composerMaximum.utf8.count
|
||||
> NostrProtocol.maximumPrivateEnvelopePlaintextBytes
|
||||
)
|
||||
expectInvalidCiphertext {
|
||||
_ = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: composerMaximum,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func privateEnvelopeRejectsOversizedNestedJSONBeforeParsing() {
|
||||
let oversizedJSON = String(
|
||||
repeating: "{",
|
||||
|
||||
@@ -302,6 +302,128 @@ struct NostrTransportTests {
|
||||
#expect(reported)
|
||||
}
|
||||
|
||||
@Test("Envelope construction failure rejects visibly without publishing")
|
||||
@MainActor
|
||||
func envelopeConstructionFailureRejectsVisibly() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
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
|
||||
|
||||
// Non-empty but invalid hex reaches envelope construction after the
|
||||
// embedded BitChat packet succeeds, reproducing the throwing batch
|
||||
// builder path without allocating an oversized test fixture.
|
||||
let accepted = transport.sendPrivateMessageGeohash(
|
||||
content: "must fail before sent",
|
||||
toRecipientHex: "not-a-hex-public-key",
|
||||
from: sender,
|
||||
messageID: "build-reject"
|
||||
)
|
||||
|
||||
#expect(!accepted)
|
||||
#expect(publicationProbe.sentEvents.isEmpty)
|
||||
#expect(eventProbe.failedMessageIDs == ["build-reject"])
|
||||
}
|
||||
|
||||
@Test("Unencodable user message rejects visibly without publishing")
|
||||
@MainActor
|
||||
func unencodableUserMessageRejectsVisibly() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
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
|
||||
|
||||
// PrivateMessagePacket uses the deployed UInt8 content length. Do not
|
||||
// create a larger wire shape that released clients cannot decode.
|
||||
let accepted = transport.sendPrivateMessageGeohash(
|
||||
content: String(repeating: "x", count: 256),
|
||||
toRecipientHex: recipient.publicKeyHex,
|
||||
from: sender,
|
||||
messageID: "packet-reject"
|
||||
)
|
||||
|
||||
#expect(!accepted)
|
||||
#expect(publicationProbe.sentEvents.isEmpty)
|
||||
#expect(eventProbe.failedMessageIDs == ["packet-reject"])
|
||||
}
|
||||
|
||||
@Test("Unencodable direct message emits a visible failure")
|
||||
@MainActor
|
||||
func unencodableDirectMessageEmitsVisibleFailure() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((224..<256).map(UInt8.init))
|
||||
let peerID = PeerID(hexData: noiseKey)
|
||||
let relationship = makeRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: recipient.npub,
|
||||
peerNickname: "Oversized 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(
|
||||
String(repeating: "x", count: 256),
|
||||
to: peerID,
|
||||
recipientNickname: "Oversized peer",
|
||||
messageID: "direct-packet-reject"
|
||||
)
|
||||
|
||||
let failed = await TestHelpers.waitUntil(
|
||||
{
|
||||
eventProbe.failedMessageIDs
|
||||
== ["direct-packet-reject"]
|
||||
},
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(failed)
|
||||
#expect(publicationProbe.sentEvents.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Rejected favorite notification retains and retries the exact pair")
|
||||
@MainActor
|
||||
func rejectedFavoriteNotificationRetriesExactPair() async throws {
|
||||
|
||||
Reference in New Issue
Block a user