// // FragmentationTests.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // import Testing import Foundation import CoreBluetooth @testable import bitchat struct FragmentationTests { private let mockKeychain: MockKeychain private let mockIdentityManager: MockIdentityManager private let idBridge: NostrIdentityBridge init() { mockKeychain = MockKeychain() mockIdentityManager = MockIdentityManager(mockKeychain) idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) } @Test("Reassembly from fragments delivers a public message") func reassemblyFromFragmentsDeliversPublicMessage() async throws { let ble = BLEService( keychain: mockKeychain, idBridge: idBridge, identityManager: mockIdentityManager ) let capture = CaptureDelegate() ble.delegate = capture // Construct a big packet (3KB) from a remote sender (not our own ID) let remoteShortID = PeerID(str: "1122334455667788") let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000) // Use a small fragment size to ensure multiple pieces let fragments = fragmentPacket(original, fragmentSize: 400) // Shuffle fragments to simulate out-of-order arrival let shuffled = fragments.shuffled() // Send fragments sequentially with small delays (no fire-and-forget Tasks) for (i, fragment) in shuffled.enumerated() { if i > 0 { try await Task.sleep(for: .milliseconds(5)) } ble._test_handlePacket(fragment, fromPeerID: remoteShortID) } // Wait for delegate callback with proper timeout try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2)) #expect(capture.publicMessages.count == 1) #expect(capture.publicMessages.first?.content.count == 3_000) } @Test("Duplicate fragment does not break reassembly") func duplicateFragmentDoesNotBreakReassembly() async throws { let ble = BLEService( keychain: mockKeychain, idBridge: idBridge, identityManager: mockIdentityManager ) let capture = CaptureDelegate() ble.delegate = capture let remoteShortID = PeerID(str: "A1B2C3D4E5F60708") let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048) var frags = fragmentPacket(original, fragmentSize: 300) // Duplicate one fragment if let dup = frags.first { frags.insert(dup, at: 1) } // Send fragments sequentially with small delays (no fire-and-forget Tasks) for (i, fragment) in frags.enumerated() { if i > 0 { try await Task.sleep(for: .milliseconds(5)) } ble._test_handlePacket(fragment, fromPeerID: remoteShortID) } // Wait for delegate callback with proper timeout try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2)) #expect(capture.publicMessages.count == 1) #expect(capture.publicMessages.first?.content.count == 2048) } @Test("Max-sized file transfer survives reassembly") func maxSizedFileTransferSurvivesReassembly() async throws { let ble = BLEService( keychain: mockKeychain, idBridge: idBridge, identityManager: mockIdentityManager ) let capture = CaptureDelegate() ble.delegate = capture let remoteID = PeerID(str: "CAFEBABECAFEBABE") let fileContent = Data(repeating: 0x42, count: FileTransferLimits.maxPayloadBytes) let filePacket = BitchatFilePacket( fileName: "limit.bin", fileSize: UInt64(fileContent.count), mimeType: "application/octet-stream", content: fileContent ) let encoded = try #require(filePacket.encode(), "File packet encoding failed") let packet = BitchatPacket( type: MessageType.fileTransfer.rawValue, senderID: Data(hexString: remoteID.id) ?? Data(), recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: encoded, signature: nil, ttl: 7, version: 2 ) let fragments = fragmentPacket(packet, fragmentSize: 4096, pad: false) #expect(!fragments.isEmpty) for (i, fragment) in fragments.enumerated() { let delay = 5 * Double(i) * 0.001 Task { try await sleep(delay) ble._test_handlePacket(fragment, fromPeerID: remoteID) } } // BCH-01-002: Files are now held as pending until user accepts (no auto-save to disk) try await capture.waitForPendingFiles(count: 1, timeout: .seconds(2)) let pending = try #require(capture.pendingFiles.first, "Expected pending file transfer") #expect(pending.mimeType == "application/octet-stream") #expect(pending.fileSize == FileTransferLimits.maxPayloadBytes) #expect(pending.fileName == "limit.bin") // Verify file was NOT written to disk (the whole point of BCH-01-002 fix) let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let filesRoot = base.appendingPathComponent("files", isDirectory: true) let incoming = filesRoot.appendingPathComponent("files/incoming", isDirectory: true) if FileManager.default.fileExists(atPath: incoming.path) { let files = try FileManager.default.contentsOfDirectory(atPath: incoming.path) #expect(files.isEmpty, "Files should NOT be auto-saved to disk before user acceptance") } // Clean up pending files PendingFileManager.shared.clearAll() } @Test("Invalid fragment header is ignored") func invalidFragmentHeaderIsIgnored() async throws { let ble = BLEService( keychain: mockKeychain, idBridge: idBridge, identityManager: mockIdentityManager ) let capture = CaptureDelegate() ble.delegate = capture let remoteShortID = PeerID(str: "0011223344556677") let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 1000) let fragments = fragmentPacket(original, fragmentSize: 250) // Corrupt one fragment: make payload too short (header incomplete) var corrupted = fragments if !corrupted.isEmpty { var p = corrupted[0] p = BitchatPacket( type: p.type, senderID: p.senderID, recipientID: p.recipientID, timestamp: p.timestamp, payload: Data([0x00, 0x01, 0x02]), // invalid header signature: nil, ttl: p.ttl ) corrupted[0] = p } for (i, fragment) in corrupted.enumerated() { let delay = 5 * Double(i) * 0.001 Task { try await sleep(delay) ble._test_handlePacket(fragment, fromPeerID: remoteShortID) } } // Allow async processing try await sleep(0.5) // Should not deliver since one fragment is invalid and reassembly can't complete #expect(capture.publicMessages.isEmpty) } } extension FragmentationTests { /// Thread-safe delegate that supports awaiting message delivery private final class CaptureDelegate: BitchatDelegate, @unchecked Sendable { private let lock = NSLock() private var _publicMessages: [(peerID: PeerID, nickname: String, content: String)] = [] private var _receivedMessages: [BitchatMessage] = [] private var _pendingFiles: [PendingFileTransfer] = [] private var publicMessageContinuation: CheckedContinuation? private var receivedMessageContinuation: CheckedContinuation? private var pendingFileContinuation: CheckedContinuation? private var expectedPublicMessageCount: Int = 0 private var expectedReceivedMessageCount: Int = 0 private var expectedPendingFileCount: Int = 0 var publicMessages: [(peerID: PeerID, nickname: String, content: String)] { lock.lock() defer { lock.unlock() } return _publicMessages } var receivedMessages: [BitchatMessage] { lock.lock() defer { lock.unlock() } return _receivedMessages } var pendingFiles: [PendingFileTransfer] { lock.lock() defer { lock.unlock() } return _pendingFiles } func didReceiveMessage(_ message: BitchatMessage) { lock.lock() _receivedMessages.append(message) let count = _receivedMessages.count let expected = expectedReceivedMessageCount let continuation = receivedMessageContinuation lock.unlock() if count >= expected, let cont = continuation { lock.lock() receivedMessageContinuation = nil lock.unlock() cont.resume() } } func didReceivePendingFileTransfer(_ pending: PendingFileTransfer) { lock.lock() _pendingFiles.append(pending) let count = _pendingFiles.count let expected = expectedPendingFileCount let continuation = pendingFileContinuation lock.unlock() if count >= expected, let cont = continuation { lock.lock() pendingFileContinuation = nil lock.unlock() cont.resume() } } func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { lock.lock() _publicMessages.append((peerID, nickname, content)) let count = _publicMessages.count let expected = expectedPublicMessageCount let continuation = publicMessageContinuation lock.unlock() if count >= expected, let cont = continuation { lock.lock() publicMessageContinuation = nil lock.unlock() cont.resume() } } /// Waits for the specified number of public messages to be received func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws { lock.lock() if _publicMessages.count >= count { lock.unlock() return } expectedPublicMessageCount = count lock.unlock() try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { await withCheckedContinuation { continuation in self.lock.lock() // Recheck count after acquiring lock to avoid race condition // where message arrives between initial check and continuation install if self._publicMessages.count >= count { self.lock.unlock() continuation.resume() return } self.publicMessageContinuation = continuation self.lock.unlock() } } group.addTask { try await Task.sleep(for: timeout) throw CancellationError() } try await group.next() group.cancelAll() } } /// Waits for the specified number of received messages func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws { lock.lock() if _receivedMessages.count >= count { lock.unlock() return } expectedReceivedMessageCount = count lock.unlock() try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { await withCheckedContinuation { continuation in self.lock.lock() // Recheck count after acquiring lock to avoid race condition // where message arrives between initial check and continuation install if self._receivedMessages.count >= count { self.lock.unlock() continuation.resume() return } self.receivedMessageContinuation = continuation self.lock.unlock() } } group.addTask { try await Task.sleep(for: timeout) throw CancellationError() } try await group.next() group.cancelAll() } } /// Waits for the specified number of pending files to be received func waitForPendingFiles(count: Int, timeout: Duration = .seconds(2)) async throws { lock.lock() if _pendingFiles.count >= count { lock.unlock() return } expectedPendingFileCount = count lock.unlock() try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { await withCheckedContinuation { continuation in self.lock.lock() if self._pendingFiles.count >= count { self.lock.unlock() continuation.resume() return } self.pendingFileContinuation = continuation self.lock.unlock() } } group.addTask { try await Task.sleep(for: timeout) throw CancellationError() } try await group.next() group.cancelAll() } } func didConnectToPeer(_ peerID: PeerID) {} func didDisconnectFromPeer(_ peerID: PeerID) {} func didUpdatePeerList(_ peers: [PeerID]) {} func isFavorite(fingerprint: String) -> Bool { false } func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {} func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {} func didUpdateBluetoothState(_ state: CBManagerState) {} func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {} } // Helper: build a large message packet (unencrypted public message) private func makeLargePublicPacket(senderShortHex: PeerID, size: Int) -> BitchatPacket { let content = String(repeating: "A", count: size) let payload = Data(content.utf8) let pkt = BitchatPacket( type: MessageType.message.rawValue, senderID: Data(hexString: senderShortHex.id) ?? Data(), recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, ttl: 7 ) return pkt } // Helper: fragment a packet using the same header format BLEService expects private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil, pad: Bool = true) -> [BitchatPacket] { guard let fullData = packet.toBinaryData(padding: pad) else { return [] } let fid = fragmentID ?? Data((0..<8).map { _ in UInt8.random(in: 0...255) }) let chunks: [Data] = stride(from: 0, to: fullData.count, by: fragmentSize).map { off in Data(fullData[off..