Files
bitchat/bitchatTests/Services/BLEFileTransferHandlerTests.swift
T
jackandClaude Opus 4.8 9df056d6a8 Reset BLEService's own receipt cache on panic completion
The panic media wipe runs on the file store PanicRecoveryOperations
.live() constructs, but receipt lookups are served by BLEService's own
incomingFileStore, whose in-memory index survived the wipe. Drop that
instance's cache in completePanicReset — after media deletion, before
radio admission reopens — so the next lookup rebuilds from the wiped
directory. The panic test now drives the production suspend/wipe/
complete sequence against the instance BLEService actually uses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 13:15:41 +02:00

1063 lines
44 KiB
Swift

import BitFoundation
import Foundation
import Testing
@testable import bitchat
struct BLEFileTransferHandlerTests {
private final class Recorder {
var localNickname = "Me"
var peers: [PeerID: BLEPeerInfo] = [:]
var signedName: String?
var signatureVerifies = false
var saveResult: URL? = URL(fileURLWithPath: "/tmp/files/incoming/sample.pdf")
var signatureVerifyCount = 0
var signedNameQueries: [PeerID] = []
var blockedPeers: Set<PeerID> = []
var trackedPackets: [BitchatPacket] = []
var quotaReservations: [Int] = []
var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = []
var receiptStates: [String: BLEPrivateMediaReceiptState] = [:]
var receiptCommits: [(messageID: String, storedURL: URL)] = []
var receiptCommitSucceeds = true
var removedIncomingFiles: [URL] = []
var lastSeenUpdates: [PeerID] = []
var deliveryAcks: [(messageID: String, peerID: PeerID)] = []
var deliveredMessages: [BitchatMessage] = []
var saveOverride: ((
_ data: Data,
_ preferredName: String?,
_ subdirectory: String,
_ fallbackExtension: String?,
_ defaultPrefix: String
) -> URL?)?
var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)?
var receiptCommitOverride: ((String, URL) -> Bool)?
var removeIncomingFileOverride: ((URL) -> Void)?
}
private let localPeerID = PeerID(str: "0102030405060708")
private let remotePeerID = PeerID(str: "1122334455667788")
private let sampleSigningKey = Data(repeating: 0xAB, count: 32)
private func makeHandler(recorder: Recorder) -> BLEFileTransferHandler {
let environment = BLEFileTransferHandlerEnvironment(
localPeerID: { [localPeerID] in localPeerID },
localNickname: { recorder.localNickname },
peersSnapshot: { recorder.peers },
verifyPacketSignature: { _, _ in
recorder.signatureVerifyCount += 1
return recorder.signatureVerifies
},
localSigningPublicKey: { [sampleSigningKey] in sampleSigningKey },
signedSenderDisplayName: { _, peerID in
recorder.signedNameQueries.append(peerID)
return recorder.signedName
},
trackPacketSeen: { packet in
recorder.trackedPackets.append(packet)
},
enforceStorageQuota: { reservingBytes in
recorder.quotaReservations.append(reservingBytes)
},
saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix))
if let saveOverride = recorder.saveOverride {
return saveOverride(data, preferredName, subdirectory, fallbackExtension, defaultPrefix)
}
return recorder.saveResult
},
privateMediaReceiptState: { messageID in
if let receiptStateOverride = recorder.receiptStateOverride {
return receiptStateOverride(messageID)
}
return recorder.receiptStates[messageID] ?? .absent
},
commitPrivateMediaFile: { messageID, storedURL in
recorder.receiptCommits.append((messageID, storedURL))
if let receiptCommitOverride = recorder.receiptCommitOverride {
return receiptCommitOverride(messageID, storedURL)
}
guard recorder.receiptCommitSucceeds else { return false }
recorder.receiptStates[messageID] = .accepted(storedURL)
return true
},
removeIncomingFile: { storedURL in
recorder.removedIncomingFiles.append(storedURL)
recorder.removeIncomingFileOverride?(storedURL)
},
isPrivateMediaSenderBlocked: { peerID in
recorder.blockedPeers.contains(peerID)
},
updatePeerLastSeen: { peerID in
recorder.lastSeenUpdates.append(peerID)
},
acknowledgePrivateMedia: { messageID, peerID in
recorder.deliveryAcks.append((messageID, peerID))
},
deliverMessage: { message, shouldDeliver, completion in
guard shouldDeliver() else { return }
recorder.deliveredMessages.append(message)
guard shouldDeliver() else { return }
completion()
}
)
return BLEFileTransferHandler(environment: environment)
}
@Test
func broadcastFileFromVerifiedPeerIsSavedAndDelivered() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
recorder.signatureVerifies = true
let handler = makeHandler(recorder: recorder)
let content = Data("%PDF-1.7".utf8)
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: content)
#expect(handler.handle(packet, from: remotePeerID))
#expect(recorder.signatureVerifyCount == 1)
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.trackedPackets.count == 1)
#expect(recorder.quotaReservations == [content.count])
#expect(recorder.saveCalls.count == 1)
#expect(recorder.saveCalls.first?.data == content)
#expect(recorder.saveCalls.first?.preferredName == "sample")
#expect(recorder.saveCalls.first?.subdirectory == "files/incoming")
#expect(recorder.saveCalls.first?.fallbackExtension == "pdf")
#expect(recorder.saveCalls.first?.defaultPrefix == "file")
#expect(recorder.lastSeenUpdates.isEmpty)
#expect(recorder.deliveredMessages.count == 1)
let message = recorder.deliveredMessages.first
#expect(message?.sender == "Alice")
#expect(message?.content == "[file] sample.pdf")
#expect(message?.isPrivate == false)
#expect(message?.senderPeerID == remotePeerID)
#expect(message?.timestamp == Date(timeIntervalSince1970: 900))
#expect(message?.deliveryStatus == nil)
}
@Test
func selfEchoIsDropped() throws {
let recorder = Recorder()
recorder.signatureVerifies = true
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3)
#expect(!handler.handle(packet, from: localPeerID))
expectNoSideEffects(recorder)
}
@Test
func unknownPeerWithoutValidSignatureIsDropped() throws {
let recorder = Recorder()
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
#expect(!handler.handle(packet, from: remotePeerID))
#expect(recorder.signedNameQueries == [remotePeerID])
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
}
@Test
func broadcastFromConnectedUnverifiedPeerWithoutSignatureIsDropped() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(
sender: remotePeerID,
mimeType: "application/pdf",
content: Data("%PDF-1.7".utf8),
hasSignature: false
)
// Failed sender authentication must also stop the packet from being
// relayed to downstream nodes.
#expect(!handler.handle(packet, from: remotePeerID))
// Broadcast files carry an attacker-controllable senderID, so — like
// public messages — a connected-but-unverified peer must present a valid
// packet signature. No signing key + no signed identity means dropped.
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
}
@Test
func broadcastFromConnectedUnverifiedPeerWithSignedIdentityIsAccepted() throws {
let recorder = Recorder()
// Connected but nickname not yet verified and no registry signing key —
// the persisted-identity signature lookup still authenticates the
// sender, so the transfer is accepted under that verified name.
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
recorder.signedName = "Bob"
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
#expect(handler.handle(packet, from: remotePeerID))
#expect(recorder.signedNameQueries == [remotePeerID])
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.sender == "Bob")
}
@Test
func signedSelfBroadcastReplayIsDelivered() throws {
// Our own broadcast file replayed via gossip sync arrives with ttl==0;
// it is verified against our local signing key before delivery.
let recorder = Recorder()
recorder.signatureVerifies = true
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(
sender: localPeerID,
mimeType: "application/pdf",
content: Data("%PDF-1.7".utf8),
ttl: 0
)
#expect(handler.handle(packet, from: localPeerID))
#expect(recorder.signatureVerifyCount == 1)
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.sender == "Me")
}
@Test
func broadcastFromPeerNotInRegistryAcceptedViaSignedIdentity() throws {
let recorder = Recorder()
recorder.signedName = "Carol"
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
#expect(handler.handle(packet, from: remotePeerID))
// Peer absent from the registry: fall back to the persisted-identity
// signature lookup (mirrors BLEPublicMessageHandler).
#expect(recorder.signedNameQueries == [remotePeerID])
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.sender == "Carol")
}
@Test
func spoofedBroadcastVoiceNoteWithoutSignatureIsDropped() throws {
// Regression for the PR #1406 finding: an in-range peer that observed a
// public voice burst tries to overwrite the live bubble by broadcasting
// a `voice_<burstID>.m4a` note under the talker's senderID. Without a
// valid signature the note never reaches the coordinator's absorption.
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Mallory", isVerified: false, isConnected: true)]
let handler = makeHandler(recorder: recorder)
let m4a = Data([0x00, 0x00, 0x00, 0x18]) + Data("ftypM4A ".utf8)
let packet = try makeFileTransferPacket(
sender: remotePeerID,
mimeType: "audio/mp4",
content: m4a,
fileName: "voice_1122334455667788",
hasSignature: false
)
// The spoofed note must be dropped locally AND not relayed onward.
#expect(!handler.handle(packet, from: remotePeerID))
#expect(recorder.deliveredMessages.isEmpty)
}
@Test
func rawDirectedFileWithoutVerifiableSignatureIsDroppedWithoutWriteOrRelay() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(
sender: remotePeerID,
mimeType: "application/pdf",
content: Data("%PDF-1.7".utf8),
recipientID: Data(hexString: localPeerID.id),
hasSignature: false
)
#expect(!handler.handle(packet, from: remotePeerID))
#expect(recorder.signatureVerifyCount == 0)
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
}
@Test
func fileDirectedToAnotherPeerIsIgnored() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
recorder.signatureVerifies = true
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(
sender: remotePeerID,
mimeType: "application/pdf",
content: Data("%PDF-1.7".utf8),
recipientID: Data(hexString: "AABBCCDDEEFF0011")
)
// Not for us, but it must keep relaying toward the real recipient.
#expect(handler.handle(packet, from: remotePeerID))
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
}
@Test
func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
recorder.signatureVerifies = true
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(
sender: remotePeerID,
mimeType: "application/pdf",
content: Data("%PDF-1.7".utf8),
recipientID: Data(hexString: localPeerID.id)
)
#expect(handler.handle(packet, from: remotePeerID))
// Directed transfers are not tracked for gossip sync.
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.lastSeenUpdates == [remotePeerID])
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.isPrivate == true)
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
// Must be explicit: BitchatMessage defaults private messages to
// .sending, which the media views render as an in-flight send
// (empty reveal mask, disabled reveal tap).
#expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900)))
}
@Test
func bit8EncryptedPrivateFileKeepsStableIDAndAckWithoutBit9Proof() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
let file = BitchatFilePacket(
fileName: fileName,
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
let timestamp = Date(timeIntervalSince1970: 1_234)
#expect(handler.handlePrivatePayload(payload, from: remotePeerID, timestamp: timestamp))
#expect(recorder.signatureVerifyCount == 0)
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.quotaReservations == [content.count])
#expect(recorder.saveCalls.first?.data == content)
#expect(recorder.lastSeenUpdates == [remotePeerID])
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.isPrivate == true)
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
#expect(recorder.deliveredMessages.first?.id == PrivateMediaMessageIdentity.stableID(
senderPeerID: remotePeerID,
recipientPeerID: localPeerID,
fileName: fileName
))
#expect(recorder.receiptCommits.count == 1)
#expect(recorder.deliveryAcks.count == 1)
#expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id)
}
@Test
func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(
remotePeerID,
nickname: "Alice",
isVerified: true,
signingPublicKey: sampleSigningKey
)]
recorder.signatureVerifies = true
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
let packet = try makeFileTransferPacket(
sender: remotePeerID,
mimeType: "image/jpeg",
content: content,
recipientID: Data(hexString: localPeerID.id),
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
)
#expect(handler.handle(packet, from: remotePeerID))
#expect(recorder.receiptCommits.isEmpty)
#expect(recorder.deliveryAcks.isEmpty)
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
}
@Test
func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let file = BitchatFilePacket(
fileName: "photo.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
))
#expect(recorder.deliveredMessages.count == 2)
#expect(recorder.deliveredMessages[0].id != recorder.deliveredMessages[1].id)
#expect(recorder.deliveredMessages.allSatisfy { !$0.id.hasPrefix("media-") })
}
@Test
func lostCapabilityProofThenStableRetryReusesDurableIDWithoutSecondDiskWrite() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
let file = BitchatFilePacket(
fileName: fileName,
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
let expectedID = try #require(PrivateMediaMessageIdentity.stableID(
senderPeerID: remotePeerID,
recipientPeerID: localPeerID,
fileName: fileName
))
// First encrypted arrival may precede the sender's authenticated bit-9
// proof. It still uses the bit-8 stable ID/ACK contract.
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
// A later automatic retry after proof must resolve the same durable ID
// rather than create a legacy random-ID bubble.
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
))
#expect(recorder.quotaReservations == [content.count])
#expect(recorder.saveCalls.count == 1)
// The handler re-offers a durable duplicate so a relaunched UI can
// restore its bubble; the synchronous conversation sink deduplicates.
#expect(recorder.deliveredMessages.count == 2)
#expect(recorder.lastSeenUpdates == [remotePeerID, remotePeerID])
#expect(recorder.deliveryAcks.count == 2)
#expect(recorder.deliveryAcks.allSatisfy {
$0.messageID == expectedID && $0.peerID == remotePeerID
})
}
@Test
func acceptedPrivateMediaAfterRelaunchRedeliversDurableURLBeforeAck() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
"private-media-handler-relaunch-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: root) }
let store = BLEIncomingFileStore(baseDirectory: root)
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
let file = BitchatFilePacket(
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
func configure(_ recorder: Recorder) {
recorder.peers = [remotePeerID: makePeerInfo(
remotePeerID,
nickname: "Alice",
isVerified: true
)]
recorder.saveOverride = {
data,
preferredName,
subdirectory,
fallbackExtension,
defaultPrefix in
store.save(
data: data,
preferredName: preferredName,
subdirectory: subdirectory,
fallbackExtension: fallbackExtension,
defaultPrefix: defaultPrefix
)
}
recorder.receiptStateOverride = {
store.privateMediaReceiptState(messageID: $0)
}
recorder.receiptCommitOverride = {
store.commitPrivateMediaFile(messageID: $0, storedURL: $1)
}
recorder.removeIncomingFileOverride = {
store.removeIncomingFile(at: $0)
}
}
let first = Recorder()
configure(first)
#expect(makeHandler(recorder: first).handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
let originalMessage = try #require(first.deliveredMessages.first)
#expect(first.deliveryAcks.count == 1)
// A fresh handler models process relaunch: its in-memory reservation
// cache is empty, so only the durable receipt can suppress disk work.
let relaunched = Recorder()
configure(relaunched)
#expect(makeHandler(recorder: relaunched).handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
))
#expect(relaunched.quotaReservations.isEmpty)
#expect(relaunched.saveCalls.isEmpty)
#expect(relaunched.receiptCommits.isEmpty)
#expect(relaunched.deliveredMessages.count == 1)
#expect(relaunched.deliveredMessages.first?.id == originalMessage.id)
#expect(relaunched.deliveredMessages.first?.content == originalMessage.content)
#expect(relaunched.deliveryAcks.count == 1)
#expect(relaunched.deliveryAcks.first?.messageID == originalMessage.id)
}
@Test
func inFlightStableDuplicateIsNotAcknowledgedAndFailedSaveRemainsRetryable() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let file = BitchatFilePacket(
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
var handler: BLEFileTransferHandler!
var nestedResult: Bool?
var failFirstSave = true
recorder.saveOverride = { _, _, _, _, _ in
if failFirstSave {
failFirstSave = false
nestedResult = handler.handlePrivatePayload(
payload,
from: self.remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
)
return nil
}
return recorder.saveResult
}
handler = makeHandler(recorder: recorder)
// The nested arrival sees the first reservation as pending. It is
// coalesced without an ACK; then the first durable save fails.
#expect(!handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(nestedResult == true)
#expect(recorder.saveCalls.count == 1)
#expect(recorder.deliveryAcks.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
// Failure released the reservation, so the sender's later retry can
// persist and deliver normally.
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_236)
))
#expect(recorder.saveCalls.count == 2)
#expect(recorder.deliveryAcks.count == 1)
#expect(recorder.deliveredMessages.count == 1)
}
@Test
func unavailableDurableReceiptStateWithholdsDiskDeliveryAndAck() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(
remotePeerID,
nickname: "Alice",
isVerified: true
)]
let fileName =
"img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
let messageID = try #require(PrivateMediaMessageIdentity.stableID(
senderPeerID: remotePeerID,
recipientPeerID: localPeerID,
fileName: fileName
))
recorder.receiptStates[messageID] = .unavailable
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
let payload = try #require(BitchatFilePacket(
fileName: fileName,
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
).encode())
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.receiptCommits.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
#expect(recorder.deliveryAcks.isEmpty)
}
@Test
func durableReceiptCommitFailureRollsBackAndWithholdsDeliveryAck() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(
remotePeerID,
nickname: "Alice",
isVerified: true
)]
recorder.receiptCommitSucceeds = false
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
let payload = try #require(BitchatFilePacket(
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
).encode())
#expect(!handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(recorder.saveCalls.count == 1)
#expect(recorder.receiptCommits.count == 1)
#expect(recorder.removedIncomingFiles.count == 1)
#expect(recorder.removedIncomingFiles.first == recorder.saveResult)
#expect(recorder.deliveredMessages.isEmpty)
#expect(recorder.deliveryAcks.isEmpty)
}
@Test
func blockedPrivateMediaIsDroppedBeforeQuotaDiskAndDedupState() throws {
let recorder = Recorder()
recorder.blockedPeers = [remotePeerID]
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let file = BitchatFilePacket(
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.lastSeenUpdates.isEmpty)
#expect(recorder.deliveryAcks.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
// Unblocking must allow a retry through; the blocked attempt cannot
// poison the stable-ID dedup reservation.
recorder.blockedPeers = []
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
))
#expect(recorder.saveCalls.count == 1)
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveryAcks.count == 1)
}
@Test
func decryptedPrivateFileOverPayloadCapIsRejectedBeforeQuotaOrDiskWrite() {
let recorder = Recorder()
let handler = makeHandler(recorder: recorder)
let oversizedCount = FileTransferLimits.maxPayloadBytes + 1
var length = UInt32(oversizedCount).bigEndian
var payload = Data([0x04]) // BitchatFilePacket CONTENT TLV
withUnsafeBytes(of: &length) { payload.append(contentsOf: $0) }
payload.append(Data(repeating: 0x41, count: oversizedCount))
#expect(!handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.lastSeenUpdates.isEmpty)
#expect(recorder.deliveryAcks.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
}
@Test
func malformedPayloadIsTrackedForSyncButDropped() {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
recorder.signatureVerifies = true
let handler = makeHandler(recorder: recorder)
let packet = BitchatPacket(
type: MessageType.fileTransfer.rawValue,
senderID: Data(hexString: remotePeerID.id) ?? Data(),
recipientID: nil,
timestamp: 900_000,
payload: Data([0x01, 0x02, 0x03]),
signature: Data(repeating: 0x5A, count: 64),
ttl: TransportConfig.messageTTLDefault
)
// Local decode failures are not proof of forgery; the packet stays relayable.
#expect(handler.handle(packet, from: remotePeerID))
// Sync tracking happens before payload validation, matching the original order.
#expect(recorder.trackedPackets.count == 1)
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
}
@Test
func unsupportedMimeIsDroppedBeforeQuotaAndSave() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
recorder.signatureVerifies = true
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: nil, content: Data([0x4D, 0x5A, 0x00, 0x00]))
#expect(handler.handle(packet, from: remotePeerID))
#expect(recorder.trackedPackets.count == 1)
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
}
@Test
func saveFailureSkipsDelivery() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
recorder.signatureVerifies = true
recorder.saveResult = nil
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
// A local save failure must not stop the mesh relay.
#expect(handler.handle(packet, from: remotePeerID))
#expect(recorder.quotaReservations.count == 1)
#expect(recorder.saveCalls.count == 1)
#expect(recorder.lastSeenUpdates.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
}
@Test
func quotaEvictionForFinalizedArrivalSkipsInFlightLiveCaptures() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent("quota-live-capture-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: base) }
let store = BLEIncomingFileStore(baseDirectory: base)
let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming")
// The in-flight partial is the LRU-oldest eviction candidate; without
// the voice_live_ pattern guard it would be deleted first, unlinking
// the inode under the coordinator's open FileHandle.
let inFlight = incoming.appendingPathComponent("voice_live_00112233445566ff_1122334455667788_dm.aac")
let evictable = incoming.appendingPathComponent("voice_old.m4a")
try Data(count: 51 * 1024 * 1024).write(to: inFlight)
try Data(count: 51 * 1024 * 1024).write(to: evictable)
try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -7200)], ofItemAtPath: inFlight.path)
try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -60)], ofItemAtPath: evictable.path)
// 102 MB used against the 100 MB quota forces one eviction. This is
// the finalized-file arrival path (BLEFileTransferHandler via
// BLEService), which knows nothing about in-flight captures — the
// store itself must protect them.
store.enforceQuota(reservingBytes: 0)
#expect(FileManager.default.fileExists(atPath: inFlight.path))
#expect(!FileManager.default.fileExists(atPath: evictable.path))
}
@Test
func panicWipeDeletesEveryManagedMediaFileAndRecreatesEmptyDirectories() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent("panic-media-wipe-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: base) }
let store = BLEIncomingFileStore(baseDirectory: base)
let subdirectories = [
"voicenotes/incoming",
"voicenotes/outgoing",
"images/incoming",
"images/outgoing",
"files/incoming",
"files/outgoing"
]
for subdirectory in subdirectories {
let directory = base
.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent(subdirectory, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try Data("secret".utf8).write(to: directory.appendingPathComponent("artifact.bin"))
}
let unmanaged = base.appendingPathComponent("files/legacy/secret.bin")
try FileManager.default.createDirectory(at: unmanaged.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data("legacy".utf8).write(to: unmanaged)
try store.panicWipe()
#expect(!FileManager.default.fileExists(atPath: unmanaged.path))
for subdirectory in subdirectories {
let directory = base
.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent(subdirectory, isDirectory: true)
var isDirectory: ObjCBool = false
#expect(FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory))
#expect(isDirectory.boolValue)
#expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty)
}
}
@Test
func panicWipeClearsCachedPrivateMediaReceiptDecisions() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"panic-receipt-cache-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let messageID = "media-00112233445566778899aabbccddeeff"
let seed = BLEPrivateMediaReceiptStore(baseDirectory: base)
#expect(seed.recordDeleted(messageID: messageID))
// Production wiring: receipt lookups run against the service's OWN
// incoming-file store while the panic wipe runs on the separate store
// `PanicRecoveryOperations.live()` constructs. The test must reset
// the instance BLEService uses, not a same-instance shortcut.
let keychain = MockKeychain()
let identityManager = MockIdentityManager(keychain)
let service = BLEService(
keychain: keychain,
idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()),
identityManager: identityManager,
initializeBluetoothManagers: false,
incomingFileStore: BLEIncomingFileStore(baseDirectory: base)
)
#expect(
service._test_privateMediaReceiptState(messageID: messageID)
== .tombstoned
)
service.suspendForPanicReset()
// A receive callback drained during suspension can still consult the
// ledger and re-cache the pre-wipe decision before media deletion.
#expect(
service._test_privateMediaReceiptState(messageID: messageID)
== .tombstoned
)
// The wipe itself runs on the recovery operations' distinct store,
// exactly like ChatViewModel's panic transaction.
let recoveryStore = BLEIncomingFileStore(baseDirectory: base)
try recoveryStore.panicWipe()
service.completePanicReset(restartServices: false)
#expect(
service._test_privateMediaReceiptState(messageID: messageID)
== .absent
)
}
@Test
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
enum MarkerFailure: Error { case unavailable }
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"panic-marker-failure-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let secret = base
.appendingPathComponent("files/images/outgoing", isDirectory: true)
.appendingPathComponent("secret.jpg")
try FileManager.default.createDirectory(
at: secret.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Data("secret".utf8).write(to: secret)
let store = BLEIncomingFileStore(
baseDirectory: base,
panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable }
)
do {
try store.panicWipe(hasDurablePendingMarker: false)
Issue.record("Expected the missing durable marker to fail closed")
} catch {
// The marker error is reported only after the deletion attempt.
}
#expect(!FileManager.default.fileExists(atPath: secret.path))
#expect(
FileManager.default.fileExists(
atPath: secret.deletingLastPathComponent().path
)
)
}
@Test
func externalMarkerAllowsDeletionToCommitWhenFileMarkerFails() throws {
enum MarkerFailure: Error { case unavailable }
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"panic-external-marker-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let secret = base
.appendingPathComponent("files/voicenotes/incoming", isDirectory: true)
.appendingPathComponent("secret.m4a")
try FileManager.default.createDirectory(
at: secret.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Data("secret".utf8).write(to: secret)
let store = BLEIncomingFileStore(
baseDirectory: base,
panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable }
)
try store.panicWipe(hasDurablePendingMarker: true)
#expect(!FileManager.default.fileExists(atPath: secret.path))
}
@Test
func panicRecoveryMarkerPersistsUntilExplicitCommit() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"panic-recovery-marker-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let store = BLEIncomingFileStore(baseDirectory: base)
try store.markPanicRecoveryPending()
#expect(try store.isPanicRecoveryPending())
try store.panicWipe(hasDurablePendingMarker: true)
#expect(try store.isPanicRecoveryPending())
try store.completePanicRecovery()
#expect(try !store.isPanicRecoveryPending())
}
private func expectNoSideEffects(_ recorder: Recorder) {
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.lastSeenUpdates.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
}
private func makePeerInfo(
_ peerID: PeerID,
nickname: String,
isVerified: Bool,
isConnected: Bool = true,
signingPublicKey: Data? = nil
) -> BLEPeerInfo {
BLEPeerInfo(
peerID: peerID,
nickname: nickname,
isConnected: isConnected,
noisePublicKey: nil,
signingPublicKey: signingPublicKey,
isVerifiedNickname: isVerified,
lastSeen: Date(timeIntervalSince1970: 999)
)
}
private func makeFileTransferPacket(
sender: PeerID,
mimeType: String?,
content: Data,
ttl: UInt8 = TransportConfig.messageTTLDefault,
recipientID: Data? = nil,
fileName: String = "sample",
hasSignature: Bool = true
) throws -> BitchatPacket {
let filePacket = BitchatFilePacket(
fileName: fileName,
fileSize: UInt64(content.count),
mimeType: mimeType,
content: content
)
let payload = try #require(filePacket.encode())
return BitchatPacket(
type: MessageType.fileTransfer.rawValue,
senderID: Data(hexString: sender.id) ?? Data(),
recipientID: recipientID,
timestamp: 900_000,
payload: payload,
signature: hasSignature ? Data(repeating: 0x5A, count: 64) : nil,
ttl: ttl
)
}
}