mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 00:25:22 +00:00
Retry unacknowledged DMs after Noise replacement (#1462)
DMs sent through an established Noise session are retained in the durable MessageRouter outbox until an authenticated delivery/read ack, and retried under the same message ID when the peer's replacement handshake authenticates — fixing DMs silently lost into stale local sessions after a remote app restart. Mesh ack handling is peer-scoped end-to-end (alias-scoped delivery status, peer-bound markDelivered, PeerMessageKey retry state), so a colliding message ID from another conversation can no longer clear or promote foreign state; bridge-drop dedup keys are recipient-scoped with hashed persistence. Includes review fix: acks arriving after a relaunch (durable outbox restored, conversation not) now clear the peer-scoped router entry unconditionally — only the UI status transition is gated on conversation presence — so a delivered message can no longer re-send to the attempt cap and be marked failed despite delivery. Regression test simulates the relaunch through real store persistence; the dead allowedPeerIDs parameter (unscoped-tombstone trap) is removed.
This commit is contained in:
@@ -133,11 +133,17 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
|
||||
// Delivery status
|
||||
var applyMessageDeliveryStatusResult = true
|
||||
var deliveryStatusesByMessageID: [String: DeliveryStatus] = [:]
|
||||
private(set) var appliedDeliveryStatuses: [(messageID: String, status: DeliveryStatus)] = []
|
||||
private(set) var appliedDeliveryStatuses: [
|
||||
(messageID: String, status: DeliveryStatus, peerIDAliases: Set<PeerID>)
|
||||
] = []
|
||||
|
||||
@discardableResult
|
||||
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
||||
appliedDeliveryStatuses.append((messageID, status))
|
||||
func applyAcknowledgedMessageDeliveryStatus(
|
||||
_ messageID: String,
|
||||
status: DeliveryStatus,
|
||||
from peerIDAliases: Set<PeerID>
|
||||
) -> Bool {
|
||||
appliedDeliveryStatuses.append((messageID, status, peerIDAliases))
|
||||
return applyMessageDeliveryStatusResult
|
||||
}
|
||||
|
||||
@@ -417,6 +423,10 @@ struct ChatTransportEventCoordinatorContextTests {
|
||||
let peerID = PeerID(str: "99aabbccddeeff00")
|
||||
let noiseKey = Data(repeating: 0x44, count: 32)
|
||||
context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")
|
||||
let stablePeerID = PeerID(hexData: noiseKey)
|
||||
let staleStablePeerID = PeerID(hexData: Data(repeating: 0x55, count: 32))
|
||||
context.cacheStablePeerID(staleStablePeerID, for: peerID)
|
||||
context.noiseSessionKeysByPeerID[peerID] = noiseKey
|
||||
|
||||
// Inbound private message: decoded, handled, and delivery-acked.
|
||||
let packet = PrivateMessagePacket(messageID: "pm-1", content: "hi there")
|
||||
@@ -438,6 +448,8 @@ struct ChatTransportEventCoordinatorContextTests {
|
||||
await drainMainActorTasks()
|
||||
#expect(context.appliedDeliveryStatuses.count == 2)
|
||||
#expect(context.appliedDeliveryStatuses[0].messageID == "m-1")
|
||||
#expect(context.appliedDeliveryStatuses[0].peerIDAliases == [peerID, stablePeerID])
|
||||
#expect(!context.appliedDeliveryStatuses[0].peerIDAliases.contains(staleStablePeerID))
|
||||
if case .delivered(let to, _) = context.appliedDeliveryStatuses[0].status {
|
||||
#expect(to == "alice")
|
||||
} else {
|
||||
|
||||
@@ -98,6 +98,7 @@ private final class MockChatVerificationContext: ChatVerificationContext {
|
||||
private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)?
|
||||
private(set) var triggeredHandshakes: [PeerID] = []
|
||||
private(set) var privateMediaAuthenticatedPeers: [PeerID] = []
|
||||
private(set) var securePrivateMessageRetryAliases: [[PeerID]] = []
|
||||
private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
|
||||
@@ -118,6 +119,10 @@ private final class MockChatVerificationContext: ChatVerificationContext {
|
||||
privateMediaAuthenticatedPeers.append(peerID)
|
||||
}
|
||||
|
||||
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) {
|
||||
securePrivateMessageRetryAliases.append(peerIDAliases)
|
||||
}
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||
sentChallenges.append((peerID, noiseKeyHex, nonceA))
|
||||
}
|
||||
@@ -269,6 +274,10 @@ struct ChatVerificationCoordinatorContextTests {
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let noiseKey = Data(repeating: 0x33, count: 32)
|
||||
context.noiseSessionKeysByPeerID[peerID] = noiseKey
|
||||
context.cacheStablePeerID(
|
||||
PeerID(hexData: Data(repeating: 0x44, count: 32)),
|
||||
for: peerID
|
||||
)
|
||||
context.verifiedFingerprints = ["fp-verified"]
|
||||
|
||||
coordinator.setupNoiseCallbacks()
|
||||
@@ -279,9 +288,11 @@ struct ChatVerificationCoordinatorContextTests {
|
||||
callbacks?.onPeerAuthenticated(peerID, "fp-verified")
|
||||
await waitForMainQueue()
|
||||
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
|
||||
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey))
|
||||
let stablePeerID = PeerID(hexData: noiseKey)
|
||||
#expect(context.stablePeerIDCache[peerID] == stablePeerID)
|
||||
#expect(context.invalidatedEncryptionCachePeers.contains(peerID))
|
||||
#expect(context.privateMediaAuthenticatedPeers == [peerID])
|
||||
#expect(context.securePrivateMessageRetryAliases == [[peerID, stablePeerID]])
|
||||
|
||||
// Handshake required -> handshaking status.
|
||||
callbacks?.onHandshakeRequired(peerID)
|
||||
|
||||
@@ -13,8 +13,11 @@ import BitFoundation
|
||||
// MARK: - Test Helpers
|
||||
|
||||
@MainActor
|
||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
let keychain = MockKeychain()
|
||||
private func makeTestableViewModel(
|
||||
keychain injectedKeychain: MockKeychain? = nil,
|
||||
outboxStore: MessageOutboxStore? = nil
|
||||
) -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
let keychain = injectedKeychain ?? MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
@@ -24,7 +27,8 @@ private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: Mo
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport
|
||||
transport: transport,
|
||||
outboxStore: outboxStore
|
||||
)
|
||||
|
||||
return (viewModel, transport)
|
||||
@@ -298,6 +302,137 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticatedNoiseAckCannotClearAnotherPeersRetryState() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let intendedPeer = PeerID(str: "0102030405060708")
|
||||
let otherPeer = PeerID(str: "1112131415161718")
|
||||
let messageID = "noise-peer-bound-ack"
|
||||
|
||||
viewModel.seedPrivateChat(
|
||||
[
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Keep retrying",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Intended",
|
||||
senderPeerID: viewModel.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
],
|
||||
for: intendedPeer
|
||||
)
|
||||
transport.reachablePeers.insert(intendedPeer)
|
||||
viewModel.messageRouter.sendPrivate(
|
||||
"Keep retrying",
|
||||
to: intendedPeer,
|
||||
recipientNickname: "Intended",
|
||||
messageID: messageID
|
||||
)
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
// This models a decrypted Noise receipt: the transport-authenticated
|
||||
// peer is authoritative, not the attacker-controlled message ID.
|
||||
viewModel.didReceiveNoisePayload(
|
||||
from: otherPeer,
|
||||
type: .delivered,
|
||||
payload: Data(messageID.utf8),
|
||||
timestamp: Date()
|
||||
)
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
|
||||
#expect(isSent(viewModel.conversations.deliveryStatus(forMessageID: messageID)))
|
||||
viewModel.messageRouter.flushOutbox(for: intendedPeer)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
viewModel.didReceiveNoisePayload(
|
||||
from: intendedPeer,
|
||||
type: .delivered,
|
||||
payload: Data(messageID.utf8),
|
||||
timestamp: Date()
|
||||
)
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
|
||||
#expect(isDelivered(viewModel.conversations.deliveryStatus(forMessageID: messageID)))
|
||||
viewModel.messageRouter.flushOutbox(for: intendedPeer)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticatedNoiseAckClearsOnlyIntendedPeersPrivateMediaRetry() async throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let intendedPeer = PeerID(str: "0102030405060708")
|
||||
let otherPeer = PeerID(str: "1112131415161718")
|
||||
let fileName = "voice_0011223344556677.m4a"
|
||||
let content = Data("voice".utf8)
|
||||
|
||||
transport.privateMediaPolicies[intendedPeer] = .encrypted
|
||||
transport.privateMediaReceiptSessionGenerations[intendedPeer] = UUID()
|
||||
viewModel.selectedPrivateChatPeer = intendedPeer
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: viewModel,
|
||||
prepareVoiceNotePacket: { _ in
|
||||
BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "audio/mp4",
|
||||
content: content
|
||||
)
|
||||
},
|
||||
transferIDFactory: { "\($0)-receipt-ack" }
|
||||
)
|
||||
viewModel.mediaTransferCoordinator = coordinator
|
||||
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"scoped-media-ack-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let sourceURL = directory.appendingPathComponent(fileName)
|
||||
try content.write(to: sourceURL)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
coordinator.sendVoiceNote(at: sourceURL)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{
|
||||
transport.sentPrivateFiles.count == 1
|
||||
&& coordinator.retainedReconnectRetryCount == 1
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let messageID = try #require(
|
||||
viewModel.privateChats[intendedPeer]?.first?.id
|
||||
)
|
||||
let transferID = try #require(
|
||||
transport.sentPrivateFiles.first?.transferID
|
||||
)
|
||||
|
||||
#expect(!viewModel.deliveryCoordinator
|
||||
.updateAcknowledgedMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .delivered(to: "Other", at: Date()),
|
||||
from: [otherPeer]
|
||||
))
|
||||
#expect(coordinator.retainedReconnectRetryCount == 1)
|
||||
#expect(transport.cancelledTransfers.isEmpty)
|
||||
|
||||
#expect(viewModel.deliveryCoordinator
|
||||
.updateAcknowledgedMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .delivered(to: "Intended", at: Date()),
|
||||
from: [intendedPeer]
|
||||
))
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(transport.cancelledTransfers == [transferID])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func cleanupOldReadReceipts_removesReceiptIDsWithoutMessages() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
@@ -323,6 +458,65 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
#expect(viewModel.sentReadReceipts == ["keep-receipt"])
|
||||
}
|
||||
|
||||
// MARK: - Relaunch-Restored Outbox Tests
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryAckAfterRelaunchClearsRestoredOutboxWithoutConversation() async {
|
||||
// Force-quit → relaunch: the durable outbox restores the retained DM,
|
||||
// but the in-memory conversation store starts empty. The delivery ack
|
||||
// must still clear the router's retained copy — gating it on the
|
||||
// conversation lookup would leave the entry re-sending on every
|
||||
// flush/auth event until the attempt cap marks it failed despite
|
||||
// delivery.
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("relaunch-ack-\(UUID().uuidString).sealed")
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let keychain = MockKeychain()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "relaunch-retained-dm"
|
||||
|
||||
// Pre-quit state: one retained private message on disk.
|
||||
MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([
|
||||
peerID: [MessageOutboxStore.QueuedMessage(
|
||||
content: "Sent before force-quit",
|
||||
nickname: "Peer",
|
||||
messageID: messageID,
|
||||
timestamp: Date()
|
||||
)]
|
||||
])
|
||||
|
||||
// Relaunch: fresh view model over the same durable store.
|
||||
let (viewModel, transport) = makeTestableViewModel(
|
||||
keychain: keychain,
|
||||
outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||
)
|
||||
#expect(viewModel.privateChats[peerID] == nil)
|
||||
|
||||
// The peer's delivery ack arrives with no conversation in the store.
|
||||
// No UI transition is possible (and none must crash), but the durable
|
||||
// retry state has to clear.
|
||||
let didUpdate = viewModel.deliveryCoordinator
|
||||
.updateAcknowledgedMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .delivered(to: "Peer", at: Date()),
|
||||
from: [peerID]
|
||||
)
|
||||
#expect(!didUpdate)
|
||||
#expect(viewModel.privateChats[peerID] == nil)
|
||||
|
||||
// Neither a flush nor a replacement-handshake auth event may re-send
|
||||
// the already-delivered message.
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
viewModel.messageRouter.flushOutbox(for: peerID)
|
||||
viewModel.messageRouter.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||
#expect(transport.sentPrivateMessages.isEmpty)
|
||||
|
||||
// The clear reached the durable snapshot: the next relaunch restores
|
||||
// nothing.
|
||||
#expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Public Timeline Status Tests
|
||||
|
||||
@Test @MainActor
|
||||
@@ -577,12 +771,27 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
|
||||
var isStartupPhase = false
|
||||
private(set) var notifyUIChangedCount = 0
|
||||
private(set) var markedDeliveredMessageIDs: [String] = []
|
||||
private(set) var peerBoundDeliveredMessages: [(messageID: String, peerIDs: Set<PeerID>)] = []
|
||||
private(set) var confirmedMediaMessageIDs: [String] = []
|
||||
|
||||
@discardableResult
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
store.setDeliveryStatus(status, forMessageID: messageID)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setDeliveryStatus(
|
||||
_ status: DeliveryStatus,
|
||||
forMessageID messageID: String,
|
||||
inDirectPeerAliases peerIDs: Set<PeerID>
|
||||
) -> Bool {
|
||||
store.setDeliveryStatus(
|
||||
status,
|
||||
forMessageID: messageID,
|
||||
inDirectPeerAliases: peerIDs
|
||||
)
|
||||
}
|
||||
|
||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||
store.deliveryStatus(forMessageID: messageID)
|
||||
}
|
||||
@@ -604,6 +813,28 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
|
||||
func markMessageDelivered(_ messageID: String) {
|
||||
markedDeliveredMessageIDs.append(messageID)
|
||||
}
|
||||
|
||||
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {
|
||||
peerBoundDeliveredMessages.append((messageID, peerIDs))
|
||||
}
|
||||
|
||||
func confirmPrivateMediaDelivery(_ messageID: String) {
|
||||
confirmedMediaMessageIDs.append(messageID)
|
||||
}
|
||||
|
||||
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
|
||||
peerIDs.contains { peerID in
|
||||
contextMessages(for: peerID).contains { message in
|
||||
message.id == messageID && message.senderPeerID == localPeerID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let localPeerID = PeerID(str: "aabbccddeeff0011")
|
||||
|
||||
private func contextMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||
store.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -685,7 +916,163 @@ struct ChatDeliveryCoordinatorContextTests {
|
||||
|
||||
#expect(isRead(coordinator.deliveryStatus(for: messageID)))
|
||||
#expect(context.notifyUIChangedCount == 1)
|
||||
#expect(context.markedDeliveredMessageIDs == [messageID])
|
||||
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
||||
#expect(context.peerBoundDeliveredMessages.count == 1)
|
||||
#expect(context.peerBoundDeliveredMessages[0].messageID == messageID)
|
||||
#expect(context.peerBoundDeliveredMessages[0].peerIDs == [peerID])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticatedReceiptWithCollidingIDUpdatesOnlyAuthenticatedAliases() async {
|
||||
let context = MockChatDeliveryContext()
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
let ephemeralPeerID = PeerID(str: "0102030405060708")
|
||||
let stablePeerID = PeerID(hexData: Data(repeating: 0x08, count: 32))
|
||||
let otherPeerID = PeerID(str: "1112131415161718")
|
||||
let messageID = "authenticated-receipt-collision"
|
||||
let mirroredMessage = makePrivateMessage(id: messageID, status: .sent)
|
||||
|
||||
context.store.append(mirroredMessage, to: .directPeer(ephemeralPeerID))
|
||||
context.store.append(mirroredMessage, to: .directPeer(stablePeerID))
|
||||
context.store.append(
|
||||
makePrivateMessage(id: messageID, status: .sent),
|
||||
to: .directPeer(otherPeerID)
|
||||
)
|
||||
context.store.append(makePublicMessage(id: messageID, status: .sent), to: .mesh)
|
||||
|
||||
let aliases: Set<PeerID> = [ephemeralPeerID, stablePeerID]
|
||||
let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .delivered(to: "Peer", at: Date()),
|
||||
from: aliases
|
||||
)
|
||||
|
||||
#expect(didUpdate)
|
||||
#expect(isDelivered(
|
||||
context.store.conversation(for: .directPeer(ephemeralPeerID))
|
||||
.message(withID: messageID)?.deliveryStatus
|
||||
))
|
||||
#expect(isDelivered(
|
||||
context.store.conversation(for: .directPeer(stablePeerID))
|
||||
.message(withID: messageID)?.deliveryStatus
|
||||
))
|
||||
#expect(isSent(
|
||||
context.store.conversation(for: .directPeer(otherPeerID))
|
||||
.message(withID: messageID)?.deliveryStatus
|
||||
))
|
||||
#expect(isSent(
|
||||
context.store.conversation(for: .mesh)
|
||||
.message(withID: messageID)?.deliveryStatus
|
||||
))
|
||||
#expect(context.peerBoundDeliveredMessages.count == 1)
|
||||
#expect(context.peerBoundDeliveredMessages[0].messageID == messageID)
|
||||
#expect(context.peerBoundDeliveredMessages[0].peerIDs == aliases)
|
||||
#expect(context.notifyUIChangedCount == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticatedReceiptRemainsScopedAfterPeerAliasMigration() async {
|
||||
let context = MockChatDeliveryContext()
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
let ephemeralPeerID = PeerID(str: "2122232425262728")
|
||||
let stablePeerID = PeerID(hexData: Data(repeating: 0x28, count: 32))
|
||||
let otherPeerID = PeerID(str: "3132333435363738")
|
||||
let messageID = "authenticated-receipt-after-migration"
|
||||
|
||||
context.store.append(
|
||||
makePrivateMessage(id: messageID, status: .sent),
|
||||
to: .directPeer(ephemeralPeerID)
|
||||
)
|
||||
context.store.append(
|
||||
makePrivateMessage(id: messageID, status: .sent),
|
||||
to: .directPeer(otherPeerID)
|
||||
)
|
||||
context.store.migrateConversation(
|
||||
from: .directPeer(ephemeralPeerID),
|
||||
to: .directPeer(stablePeerID)
|
||||
)
|
||||
|
||||
let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .read(by: "Peer", at: Date()),
|
||||
from: [ephemeralPeerID, stablePeerID]
|
||||
)
|
||||
|
||||
#expect(didUpdate)
|
||||
#expect(context.store.conversationsByID[.directPeer(ephemeralPeerID)] == nil)
|
||||
#expect(isRead(
|
||||
context.store.conversation(for: .directPeer(stablePeerID))
|
||||
.message(withID: messageID)?.deliveryStatus
|
||||
))
|
||||
#expect(isSent(
|
||||
context.store.conversation(for: .directPeer(otherPeerID))
|
||||
.message(withID: messageID)?.deliveryStatus
|
||||
))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func receiptFromWrongPeerDoesNotUpdateOrTerminalizeOutgoingMessage() async {
|
||||
let context = MockChatDeliveryContext()
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
let intendedPeer = PeerID(str: "0102030405060708")
|
||||
let otherPeer = PeerID(str: "1112131415161718")
|
||||
let messageID = "wrong-peer-receipt"
|
||||
context.store.append(
|
||||
makePrivateMessage(id: messageID, status: .sent),
|
||||
to: .directPeer(intendedPeer)
|
||||
)
|
||||
|
||||
coordinator.didReceiveReadReceipt(
|
||||
ReadReceipt(
|
||||
originalMessageID: messageID,
|
||||
readerID: otherPeer,
|
||||
readerNickname: "Other"
|
||||
)
|
||||
)
|
||||
|
||||
#expect(isSent(coordinator.deliveryStatus(for: messageID)))
|
||||
// The router-side clear runs, but bound only to the wrong peer's own
|
||||
// aliases — a scoped no-op that cannot touch the intended peer's
|
||||
// retained copy. Status, media retry, and UI stay untouched.
|
||||
#expect(context.peerBoundDeliveredMessages.count == 1)
|
||||
#expect(context.peerBoundDeliveredMessages[0].messageID == messageID)
|
||||
#expect(context.peerBoundDeliveredMessages[0].peerIDs == [otherPeer])
|
||||
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
||||
#expect(context.confirmedMediaMessageIDs.isEmpty)
|
||||
#expect(context.notifyUIChangedCount == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func rejectedStaleReceiptDoesNotDowngradeStatusOrNotify() async {
|
||||
let context = MockChatDeliveryContext()
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "stale-receipt"
|
||||
context.store.append(
|
||||
makePrivateMessage(
|
||||
id: messageID,
|
||||
status: .read(by: "Peer", at: Date())
|
||||
),
|
||||
to: .directPeer(peerID)
|
||||
)
|
||||
|
||||
let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .delivered(to: "Peer", at: Date()),
|
||||
from: [peerID]
|
||||
)
|
||||
|
||||
#expect(!didUpdate)
|
||||
#expect(isRead(coordinator.deliveryStatus(for: messageID)))
|
||||
// The peer-scoped router clear re-runs (idempotent: the earlier read
|
||||
// receipt already emptied this peer's retained copy), but the stale
|
||||
// delivered ack must not downgrade the read status, release media, or
|
||||
// notify the UI.
|
||||
#expect(context.peerBoundDeliveredMessages.count == 1)
|
||||
#expect(context.peerBoundDeliveredMessages[0].peerIDs == [peerID])
|
||||
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
||||
#expect(context.confirmedMediaMessageIDs.isEmpty)
|
||||
#expect(context.notifyUIChangedCount == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
|
||||
@@ -1179,6 +1179,71 @@ struct ConversationStoreTests {
|
||||
#expect(statusChangedIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("peer-scoped receipt updates only authenticated direct aliases")
|
||||
@MainActor
|
||||
func peerScopedReceiptUpdatesOnlyAuthenticatedDirectAliases() {
|
||||
let store = ConversationStore()
|
||||
let ephemeralPeer = PeerID(str: "0102030405060708")
|
||||
let stablePeer = PeerID(hexData: Data(repeating: 0x08, count: 32))
|
||||
let otherPeer = PeerID(str: "1112131415161718")
|
||||
let ephemeral = ConversationID.directPeer(ephemeralPeer)
|
||||
let stable = ConversationID.directPeer(stablePeer)
|
||||
let other = ConversationID.directPeer(otherPeer)
|
||||
let messageID = "scoped-receipt"
|
||||
let mirrored = makeMessage(
|
||||
id: messageID,
|
||||
timestamp: 1,
|
||||
isPrivate: true,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
store.upsertByID(mirrored, in: ephemeral)
|
||||
store.upsertByID(mirrored, in: stable)
|
||||
store.upsertByID(
|
||||
makeMessage(
|
||||
id: messageID,
|
||||
timestamp: 1,
|
||||
isPrivate: true,
|
||||
deliveryStatus: .sent
|
||||
),
|
||||
in: other
|
||||
)
|
||||
store.upsertByID(
|
||||
makeMessage(id: messageID, timestamp: 1, deliveryStatus: .sent),
|
||||
in: .mesh
|
||||
)
|
||||
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
var publishedIDs: [ConversationID] = []
|
||||
for id in [ephemeral, stable, other, .mesh] {
|
||||
store.conversation(for: id).objectWillChange
|
||||
.sink { publishedIDs.append(id) }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
var statusChangedIDs: [ConversationID] = []
|
||||
store.changes
|
||||
.sink { change in
|
||||
if case .statusChanged(let id, messageID, _) = change,
|
||||
messageID == "scoped-receipt" {
|
||||
statusChangedIDs.append(id)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
let delivered = DeliveryStatus.delivered(to: "bob", at: Date())
|
||||
#expect(store.setDeliveryStatus(
|
||||
delivered,
|
||||
forMessageID: messageID,
|
||||
inDirectPeerAliases: [ephemeralPeer, stablePeer]
|
||||
))
|
||||
|
||||
#expect(Set(publishedIDs) == Set([ephemeral, stable]))
|
||||
#expect(Set(statusChangedIDs) == Set([ephemeral, stable]))
|
||||
#expect(store.conversation(for: ephemeral).message(withID: messageID)?.deliveryStatus == delivered)
|
||||
#expect(store.conversation(for: stable).message(withID: messageID)?.deliveryStatus == delivered)
|
||||
#expect(store.conversation(for: other).message(withID: messageID)?.deliveryStatus == .sent)
|
||||
#expect(store.conversation(for: .mesh).message(withID: messageID)?.deliveryStatus == .sent)
|
||||
}
|
||||
|
||||
// MARK: - Invariant audit (field observability)
|
||||
|
||||
/// A store exercised through every intent family: public + geohash +
|
||||
|
||||
@@ -65,11 +65,15 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
|
||||
var peerFingerprints: [PeerID: String] = [:]
|
||||
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
||||
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
|
||||
var privateMediaReceiptSessionGenerations: [PeerID: UUID] = [:]
|
||||
var persistDeletedPrivateMediaResult = true
|
||||
var deferDeletedPrivateMediaPersistence = false
|
||||
private var pendingDeletedPrivateMediaCompletions: [
|
||||
@MainActor (Bool) -> Void
|
||||
] = []
|
||||
/// Optional synchronous hook for send-ordering tests (for example, an ack
|
||||
/// arriving before the router's send call returns).
|
||||
var onSendPrivateMessage: (@MainActor (_ messageID: String) -> Void)?
|
||||
private let mockKeychain = MockKeychain()
|
||||
|
||||
// MARK: - Transport Protocol Implementation
|
||||
@@ -174,6 +178,11 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
|
||||
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
sentPrivateMessages.append((content, peerID, recipientNickname, messageID))
|
||||
if let onSendPrivateMessage {
|
||||
MainActor.assumeIsolated {
|
||||
onSendPrivateMessage(messageID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
@@ -215,6 +224,12 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
|
||||
privateMediaPolicies[peerID] ?? .encrypted
|
||||
}
|
||||
|
||||
func authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to peerID: PeerID
|
||||
) -> UUID? {
|
||||
privateMediaReceiptSessionGenerations[peerID]
|
||||
}
|
||||
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
|
||||
@@ -749,12 +749,30 @@ private final class PerfDeliveryContext: ChatDeliveryContext {
|
||||
|
||||
func notifyUIChanged() {}
|
||||
func markMessageDelivered(_ messageID: String) {}
|
||||
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {}
|
||||
func confirmPrivateMediaDelivery(_ messageID: String) {}
|
||||
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
store.setDeliveryStatus(status, forMessageID: messageID)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setDeliveryStatus(
|
||||
_ status: DeliveryStatus,
|
||||
forMessageID messageID: String,
|
||||
inDirectPeerAliases peerIDs: Set<PeerID>
|
||||
) -> Bool {
|
||||
store.setDeliveryStatus(
|
||||
status,
|
||||
forMessageID: messageID,
|
||||
inDirectPeerAliases: peerIDs
|
||||
)
|
||||
}
|
||||
|
||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||
store.deliveryStatus(forMessageID: messageID)
|
||||
}
|
||||
|
||||
@@ -203,6 +203,134 @@ struct BridgeCourierServiceTests {
|
||||
#expect(confirmed.sealRequests.isEmpty)
|
||||
}
|
||||
|
||||
@Test func sameMessageIDIsScopedByRecipientAcrossRejectedActiveAndPersistedState() {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let rejectedKey = Fixture.randomKey()
|
||||
let firstKey = Fixture.randomKey()
|
||||
let secondKey = Fixture.randomKey()
|
||||
let thirdKey = Fixture.randomKey()
|
||||
let messageID = "recipient-scoped-collision"
|
||||
|
||||
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||
fixture.sealResult = makeEnvelope(
|
||||
recipientKey: rejectedKey,
|
||||
ciphertext: Data(
|
||||
repeating: 7,
|
||||
count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1
|
||||
)
|
||||
)
|
||||
var rejectedResults: [Bool] = []
|
||||
fixture.service.depositDrop(
|
||||
content: "rejected",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: rejectedKey
|
||||
) { rejectedResults.append($0) }
|
||||
#expect(rejectedResults == [false])
|
||||
|
||||
fixture.sealResult = makeEnvelope(recipientKey: firstKey)
|
||||
fixture.automaticPublishResult = nil
|
||||
var firstResults: [Bool] = []
|
||||
var secondResults: [Bool] = []
|
||||
var duplicateFirstResults: [Bool] = []
|
||||
fixture.service.depositDrop(
|
||||
content: "first",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: firstKey
|
||||
) { firstResults.append($0) }
|
||||
fixture.service.depositDrop(
|
||||
content: "second",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: secondKey
|
||||
) { secondResults.append($0) }
|
||||
fixture.service.depositDrop(
|
||||
content: "first duplicate",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: firstKey
|
||||
) { duplicateFirstResults.append($0) }
|
||||
|
||||
#expect(fixture.publishedEvents.count == 2)
|
||||
#expect(fixture.pendingPublishCompletions.count == 2)
|
||||
#expect(duplicateFirstResults == [false])
|
||||
#expect(firstResults.isEmpty)
|
||||
#expect(secondResults.isEmpty)
|
||||
|
||||
fixture.resolveNextPublish(true)
|
||||
fixture.resolveNextPublish(true)
|
||||
#expect(firstResults == [true])
|
||||
#expect(secondResults == [true])
|
||||
fixture.service.flushDedupSnapshot()
|
||||
|
||||
let relaunched = Fixture(
|
||||
dedupStore: BridgeDropDedupStore(fileURL: fileURL)
|
||||
)
|
||||
relaunched.sealResult = makeEnvelope(recipientKey: thirdKey)
|
||||
var relaunchResults: [Bool] = []
|
||||
relaunched.service.depositDrop(
|
||||
content: "first",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: firstKey
|
||||
) { relaunchResults.append($0) }
|
||||
relaunched.service.depositDrop(
|
||||
content: "second",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: secondKey
|
||||
) { relaunchResults.append($0) }
|
||||
relaunched.service.depositDrop(
|
||||
content: "third",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: thirdKey
|
||||
) { relaunchResults.append($0) }
|
||||
|
||||
#expect(relaunchResults == [false, false, true])
|
||||
#expect(relaunched.sealRequests.count == 1)
|
||||
#expect(relaunched.sealRequests.first?.key == thirdKey)
|
||||
#expect(relaunched.publishedEvents.count == 1)
|
||||
}
|
||||
|
||||
@Test func legacyPublishedMessageIDIsWildcardUntilItsOriginalExpiry() {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
var date = Date()
|
||||
let messageID = "legacy-wildcard"
|
||||
let recipientKey = Fixture.randomKey()
|
||||
let store = BridgeDropDedupStore(fileURL: fileURL)
|
||||
store.save(BridgeDropDedupStore.Snapshot(
|
||||
publishedDropKeys: [messageID: date],
|
||||
seenDropEventIDs: [:]
|
||||
))
|
||||
|
||||
let fixture = Fixture(
|
||||
now: { date },
|
||||
dedupStore: BridgeDropDedupStore(fileURL: fileURL)
|
||||
)
|
||||
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
|
||||
var results: [Bool] = []
|
||||
fixture.service.depositDrop(
|
||||
content: "legacy",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: recipientKey
|
||||
) { results.append($0) }
|
||||
#expect(results == [false])
|
||||
#expect(fixture.sealRequests.isEmpty)
|
||||
|
||||
date = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + 1)
|
||||
fixture.service.depositDrop(
|
||||
content: "after expiry",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: recipientKey
|
||||
) { results.append($0) }
|
||||
#expect(results == [false, true])
|
||||
#expect(fixture.publishedEvents.count == 1)
|
||||
fixture.service.flushDedupSnapshot()
|
||||
|
||||
let snapshot = BridgeDropDedupStore(fileURL: fileURL).load()
|
||||
#expect(snapshot.publishedDropKeys[messageID] == nil)
|
||||
#expect(snapshot.publishedDropKeys.count == 1)
|
||||
}
|
||||
|
||||
@Test func panicWipeInvalidatesInFlightPublishCompletion() throws {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||
@@ -297,8 +425,10 @@ struct BridgeCourierServiceTests {
|
||||
#expect(firstResults == [false])
|
||||
|
||||
// The evicted first drop is deposit-able again (slot released).
|
||||
let sealCountBeforeRetry = fixture.sealRequests.count
|
||||
fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key)
|
||||
#expect(fixture.service.pendingDrops.last?.dedupKey == firstID)
|
||||
#expect(fixture.sealRequests.count == sealCountBeforeRetry + 1)
|
||||
#expect(fixture.service.pendingDrops.count == BridgeCourierService.Limits.maxPendingDrops)
|
||||
}
|
||||
|
||||
@Test func oversizeDropConsumesSlotInsteadOfChurning() {
|
||||
|
||||
@@ -79,18 +79,231 @@ struct MessageRouterTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_connectedSendIsNotRetained() async {
|
||||
func peerBoundDeliveryAckCannotClearAnotherPeersRetainedMessage() async {
|
||||
let intendedPeer = PeerID(str: "0000000000000023")
|
||||
let otherPeer = PeerID(str: "0000000000000024")
|
||||
let transport = MockTransport()
|
||||
transport.reachablePeers = [intendedPeer, otherPeer]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate(
|
||||
"Secret",
|
||||
to: intendedPeer,
|
||||
recipientNickname: "Intended",
|
||||
messageID: "peer-bound-ack"
|
||||
)
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
// Even a receipt arriving over another authenticated conversation
|
||||
// must not terminalize the intended peer's retained retry.
|
||||
router.markDelivered("peer-bound-ack", from: [otherPeer])
|
||||
router.flushOutbox(for: intendedPeer)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
router.markDelivered("peer-bound-ack", from: [intendedPeer])
|
||||
router.flushOutbox(for: intendedPeer)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_connectedSecureSendRetainsUntilDeliveryAck() async {
|
||||
let peerID = PeerID(str: "0000000000000007")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.reachablePeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m7")
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
router.flushOutbox(for: peerID)
|
||||
// A newly authenticated/replacement session retries the retained
|
||||
// message instead of losing the first ciphertext to a stale session.
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
router.markDelivered("m7")
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticationRetry_matchesStableOutboxAliasWithoutDoubleSending() async {
|
||||
let shortPeerID = PeerID(str: "0000000000000019")
|
||||
let stablePeerID = PeerID(hexData: Data(repeating: 0x19, count: 32))
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(stablePeerID)
|
||||
transport.securePeers = [stablePeerID]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: stablePeerID, recipientNickname: "Peer", messageID: "alias-retry")
|
||||
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [shortPeerID, stablePeerID, stablePeerID])
|
||||
|
||||
#expect(transport.sentPrivateMessages.map(\.messageID) == ["alias-retry", "alias-retry"])
|
||||
#expect(transport.sentPrivateMessages.allSatisfy { $0.peerID == stablePeerID })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticationRetry_preservesFIFOAcrossSplitAliases() async {
|
||||
let shortPeerID = PeerID(str: "0000000000000022")
|
||||
let stablePeerID = PeerID(hexData: Data(repeating: 0x22, count: 32))
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers = [shortPeerID, stablePeerID]
|
||||
transport.securePeers = [shortPeerID, stablePeerID]
|
||||
let clock = MutableTestClock()
|
||||
let router = MessageRouter(transports: [transport], now: { clock.now })
|
||||
|
||||
// The older message lives under the stable key, even though the auth
|
||||
// callback supplies the ephemeral alias first.
|
||||
router.sendPrivate("Older", to: stablePeerID, recipientNickname: "Peer", messageID: "fifo-old")
|
||||
clock.now = clock.now.addingTimeInterval(1)
|
||||
router.sendPrivate("Newer", to: shortPeerID, recipientNickname: "Peer", messageID: "fifo-new")
|
||||
transport.resetRecordings()
|
||||
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [shortPeerID, stablePeerID])
|
||||
|
||||
#expect(transport.sentPrivateMessages.map(\.messageID) == ["fifo-old", "fifo-new"])
|
||||
#expect(transport.sentPrivateMessages.map(\.peerID) == [stablePeerID, shortPeerID])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticationRetry_doesNotDuplicateNormalPendingHandshakeSend() async {
|
||||
let peerID = PeerID(str: "0000000000000020")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = []
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "normal-handshake")
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
// BLE owns this pending send and drains it after authentication. Once
|
||||
// the session becomes secure, the router's targeted auth retry must
|
||||
// stay silent instead of producing a second copy.
|
||||
transport.securePeers = [peerID]
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
router.markDelivered("normal-handshake")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticationRetry_scopesCollidingMessageIDsByPeer() async {
|
||||
let securePeer = PeerID(str: "0000000000000025")
|
||||
let pendingPeer = PeerID(str: "0000000000000026")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers = [securePeer, pendingPeer]
|
||||
transport.securePeers = [securePeer]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
let promotedID = "collision-promoted"
|
||||
let clearedID = "collision-cleared"
|
||||
|
||||
// Pending B then secure A: an ID-global marker falsely promotes B.
|
||||
router.sendPrivate(
|
||||
"pending promoted",
|
||||
to: pendingPeer,
|
||||
recipientNickname: "Pending",
|
||||
messageID: promotedID
|
||||
)
|
||||
router.sendPrivate(
|
||||
"secure promoted",
|
||||
to: securePeer,
|
||||
recipientNickname: "Secure",
|
||||
messageID: promotedID
|
||||
)
|
||||
|
||||
// Secure A then pending B: an ID-global removal falsely clears A.
|
||||
router.sendPrivate(
|
||||
"secure cleared",
|
||||
to: securePeer,
|
||||
recipientNickname: "Secure",
|
||||
messageID: clearedID
|
||||
)
|
||||
router.sendPrivate(
|
||||
"pending cleared",
|
||||
to: pendingPeer,
|
||||
recipientNickname: "Pending",
|
||||
messageID: clearedID
|
||||
)
|
||||
|
||||
transport.resetRecordings()
|
||||
transport.securePeers = [securePeer, pendingPeer]
|
||||
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [pendingPeer])
|
||||
#expect(transport.sentPrivateMessages.isEmpty)
|
||||
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [securePeer])
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
#expect(Set(transport.sentPrivateMessages.map(\.messageID)) == [promotedID, clearedID])
|
||||
#expect(transport.sentPrivateMessages.allSatisfy { $0.peerID == securePeer })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticationRetry_doesNotDuplicateMessageRequeuedByBLEForHandshake() async {
|
||||
let peerID = PeerID(str: "0000000000000021")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "session-lost")
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
// The session disappears before a normal outbox flush. That send is
|
||||
// now owned by BLE's pending-handshake queue, so it clears the
|
||||
// router's secure-auth retry marker.
|
||||
transport.securePeers = []
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
transport.securePeers = [peerID]
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
router.markDelivered("session-lost")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_fastDeliveryAckCannotRaceAheadOfRetention() async {
|
||||
let peerID = PeerID(str: "0000000000000017")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
transport.onSendPrivateMessage = { messageID in
|
||||
router.markDelivered(messageID)
|
||||
}
|
||||
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "fast-ack")
|
||||
#expect(transport.sentPrivateMessages.map(\.messageID) == ["fast-ack"])
|
||||
|
||||
transport.onSendPrivateMessage = nil
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.map(\.messageID) == ["fast-ack"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func flushOutbox_synchronousAckDoesNotResurrectSnapshotEntry() async {
|
||||
let peerID = PeerID(str: "0000000000000018")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "flush-fast-ack")
|
||||
transport.onSendPrivateMessage = { messageID in
|
||||
router.markDelivered(messageID)
|
||||
}
|
||||
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
transport.onSendPrivateMessage = nil
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -392,10 +605,31 @@ struct MessageRouterTests {
|
||||
#expect(transport.sentPrivateMessages.count == 11)
|
||||
}
|
||||
|
||||
/// With an established secure session the connected fast-path stays
|
||||
/// exactly as before: trusted outright, no retained copy, no courier.
|
||||
@Test @MainActor
|
||||
func sendPrivate_connectedWithSecureSessionIsTrustedOutright() async {
|
||||
func authenticationRetry_capsActualSecureTransmissions() async {
|
||||
let peerID = PeerID(str: "00000000000000ad")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
var dropped: [String] = []
|
||||
router.onMessageDropped = { messageID, _ in dropped.append(messageID) }
|
||||
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "secure-retry")
|
||||
for _ in 0..<10 {
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||
}
|
||||
|
||||
#expect(dropped == ["secure-retry"])
|
||||
#expect(transport.sentPrivateMessages.count == 8)
|
||||
}
|
||||
|
||||
/// With an established secure session the connected fast-path sends
|
||||
/// immediately and never leaks to couriers, but retains a local encrypted
|
||||
/// outbox copy until the peer confirms receipt.
|
||||
@Test @MainActor
|
||||
func sendPrivate_connectedWithSecureSessionRetainsLocallyWithoutCourier() async {
|
||||
let peerID = PeerID(str: "00000000000000ab")
|
||||
let peerKey = Data(repeating: 0xAB, count: 32)
|
||||
let courier = PeerID(str: "00000000000000cc")
|
||||
@@ -415,7 +649,11 @@ struct MessageRouterTests {
|
||||
#expect(transport.sentPrivateMessages.map(\.messageID) == ["cs2"])
|
||||
#expect(transport.sentCourierMessages.isEmpty)
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
#expect(transport.sentCourierMessages.isEmpty)
|
||||
router.markDelivered("cs2")
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -527,6 +765,52 @@ struct MessageRouterTests {
|
||||
#expect(carried == ["bridge-ack"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func bridgeDepositsScopeCollidingMessageIDsByRecipient() async {
|
||||
let firstRecipient = PeerID(str: "00000000000000b1")
|
||||
let secondRecipient = PeerID(str: "00000000000000b2")
|
||||
let firstKey = Data(repeating: 0xB1, count: 32)
|
||||
let secondKey = Data(repeating: 0xB2, count: 32)
|
||||
let recipientKeys = [
|
||||
firstRecipient: firstKey,
|
||||
secondRecipient: secondKey
|
||||
]
|
||||
let router = MessageRouter(
|
||||
transports: [MockTransport()],
|
||||
courierDirectory: CourierDirectory(
|
||||
noiseKey: { recipientKeys[$0] },
|
||||
isTrustedCourier: { _ in false }
|
||||
)
|
||||
)
|
||||
var requestedKeys: [Data] = []
|
||||
var completions: [@MainActor (Bool) -> Void] = []
|
||||
router.bridgeCourierDeposit = { _, _, recipientKey, completion in
|
||||
requestedKeys.append(recipientKey)
|
||||
completions.append(completion)
|
||||
}
|
||||
var carriedPeers: [PeerID] = []
|
||||
router.onMessageCarried = { _, peerID in carriedPeers.append(peerID) }
|
||||
|
||||
router.sendPrivate(
|
||||
"First",
|
||||
to: firstRecipient,
|
||||
recipientNickname: "First",
|
||||
messageID: "bridge-collision"
|
||||
)
|
||||
router.sendPrivate(
|
||||
"Second",
|
||||
to: secondRecipient,
|
||||
recipientNickname: "Second",
|
||||
messageID: "bridge-collision"
|
||||
)
|
||||
|
||||
#expect(completions.count == 2)
|
||||
#expect(Set(requestedKeys) == [firstKey, secondKey])
|
||||
|
||||
completions.forEach { $0(true) }
|
||||
#expect(Set(carriedPeers) == [firstRecipient, secondRecipient])
|
||||
}
|
||||
|
||||
// MARK: - Outbox persistence
|
||||
|
||||
@Test @MainActor
|
||||
@@ -643,7 +927,10 @@ struct MessageRouterTests {
|
||||
transport.reachablePeers.formUnion([acknowledgedPeer, otherPeer])
|
||||
let router = MessageRouter(transports: [transport], outboxStore: restoredStore)
|
||||
|
||||
#expect(!router.markDelivered("shared-locked-id", for: [acknowledgedPeer]))
|
||||
router.markDelivered(
|
||||
"shared-locked-id",
|
||||
from: [acknowledgedPeer]
|
||||
)
|
||||
protectedDataUnavailable = false
|
||||
restoredStore.retryDeferredLoad()
|
||||
await Task.yield()
|
||||
@@ -853,12 +1140,14 @@ struct MessageRouterTests {
|
||||
protectedDataUnavailable = false
|
||||
restoredStore.retryDeferredLoad() // captures unseen durable + known wake
|
||||
|
||||
// Secure direct flush removes the wake message before recovery's
|
||||
// MainActor merge. It must remain removed, while the unseen durable
|
||||
// message still arrives through the pending recovery claim.
|
||||
// A secure direct retry followed by its delivery ack removes the wake
|
||||
// message before recovery's MainActor merge. It must remain removed,
|
||||
// while the unseen durable message still arrives through the pending
|
||||
// recovery claim.
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
router.flushOutbox(for: peerID)
|
||||
router.markDelivered("recovery-gap-known")
|
||||
await Task.yield()
|
||||
await Task.yield()
|
||||
|
||||
@@ -929,7 +1218,8 @@ struct MessageRouterTests {
|
||||
restoredStore.retryDeferredLoad() // persists D+W and queues recovery
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
router.flushOutbox(for: peerID) // removes W before queued callback
|
||||
router.flushOutbox(for: peerID)
|
||||
router.markDelivered("recovery-write-failure-known") // removes W before queued callback
|
||||
|
||||
// The gap save may remove W, but it must leave unseen D durable until
|
||||
// MessageRouter receives the pending recovery callback.
|
||||
|
||||
@@ -13,17 +13,16 @@ import Testing
|
||||
private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let activationGate = DispatchSemaphore(value: 0)
|
||||
private let activationBeganGate = DispatchSemaphore(value: 0)
|
||||
private let shouldGateFirstActivation: Bool
|
||||
private var gatedFirstActivation = false
|
||||
private var _activationCalls: [Bool] = []
|
||||
private var _activationBegan = false
|
||||
|
||||
init(gateFirstActivation: Bool = false) {
|
||||
self.shouldGateFirstActivation = gateFirstActivation
|
||||
}
|
||||
|
||||
var activationCalls: [Bool] { lock.withLock { _activationCalls } }
|
||||
var activationBegan: Bool { lock.withLock { _activationBegan } }
|
||||
|
||||
func setCategory(_ category: AudioSessionCoordinator.Category) throws {}
|
||||
|
||||
@@ -32,14 +31,28 @@ private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendab
|
||||
_activationCalls.append(active)
|
||||
guard active, shouldGateFirstActivation, !gatedFirstActivation else { return false }
|
||||
gatedFirstActivation = true
|
||||
_activationBegan = true
|
||||
return true
|
||||
}
|
||||
if shouldWait {
|
||||
activationBeganGate.signal()
|
||||
activationGate.wait()
|
||||
}
|
||||
}
|
||||
|
||||
func waitUntilActivationBegan(
|
||||
timeout: DispatchTimeInterval = .seconds(5)
|
||||
) async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
continuation.resume(
|
||||
returning: self.activationBeganGate.wait(
|
||||
timeout: DispatchTime.now() + timeout
|
||||
) == .success
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resumeActivation() {
|
||||
activationGate.signal()
|
||||
}
|
||||
@@ -142,26 +155,38 @@ private final class TestVoiceAudioRecorderFactory: VoiceAudioRecorderCreating {
|
||||
/// this remains deterministic when the full test suite saturates the executor.
|
||||
private final class VoiceRecorderPaddingGate: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var _entered = false
|
||||
private let enteredGate = DispatchSemaphore(value: 0)
|
||||
private var isOpen = false
|
||||
private var openWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
var entered: Bool { lock.withLock { _entered } }
|
||||
|
||||
func wait() async {
|
||||
await withCheckedContinuation { continuation in
|
||||
let resumeImmediately = lock.withLock { () -> Bool in
|
||||
_entered = true
|
||||
guard !isOpen else { return true }
|
||||
openWaiters.append(continuation)
|
||||
return false
|
||||
}
|
||||
enteredGate.signal()
|
||||
if resumeImmediately {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitUntilEntered(
|
||||
timeout: DispatchTimeInterval = .seconds(5)
|
||||
) async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
continuation.resume(
|
||||
returning: self.enteredGate.wait(
|
||||
timeout: DispatchTime.now() + timeout
|
||||
) == .success
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func open() {
|
||||
let waiters = lock.withLock { () -> [CheckedContinuation<Void, Never>] in
|
||||
isOpen = true
|
||||
@@ -181,18 +206,6 @@ struct VoiceRecorderTests {
|
||||
return url
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
_ condition: () -> Bool,
|
||||
sourceLocation: SourceLocation = #_sourceLocation
|
||||
) async {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
while !condition(), ContinuousClock.now < deadline {
|
||||
await Task.yield()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||
}
|
||||
#expect(condition(), sourceLocation: sourceLocation)
|
||||
}
|
||||
|
||||
@Test func cancelWhileSessionAcquireIsInFlightNeverCreatesARecorder() async throws {
|
||||
let directory = try makeTemporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
@@ -210,7 +223,7 @@ struct VoiceRecorderTests {
|
||||
let owner = VoiceRecorder.RecordingOwner()
|
||||
|
||||
let startTask = Task { try await voiceRecorder.startRecording(owner: owner) }
|
||||
await waitUntil { session.activationBegan }
|
||||
#expect(await session.waitUntilActivationBegan())
|
||||
|
||||
await voiceRecorder.cancelRecording(owner: owner)
|
||||
session.resumeActivation()
|
||||
@@ -308,7 +321,7 @@ struct VoiceRecorderTests {
|
||||
try await finishingHold.start()
|
||||
let firstURL = try #require(factory.urls.first)
|
||||
let finishTask = Task { await finishingHold.finish() }
|
||||
await waitUntil { paddingGate.entered }
|
||||
#expect(await paddingGate.waitUntilEntered())
|
||||
|
||||
await #expect(throws: VoiceRecorder.RecorderError.recordingInProgress) {
|
||||
try await rejectedHold.start()
|
||||
|
||||
Reference in New Issue
Block a user