mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
BLEService's per-packet-type orchestration moves into owned, tested components (BLEAnnounceHandler, BLEPublicMessageHandler, BLENoisePacketHandler, BLEFileTransferHandler, BLEFragmentHandler), each taking an environment struct of closures so every queue hop stays in BLEService and the handlers are synchronously testable. Behavior is preserved verbatim, including Noise session recovery on decrypt failure and single-block UI event ordering. handleLeave/handleRequestSync stay in place as already-thin delegations. BLEService drops to 3393 lines. Four coordinators (delivery, private conversation, Nostr, public conversation) drop their unowned/weak ChatViewModel back-references for narrow @MainActor context protocols, with ChatViewModel conformances as single shared witnesses for overlapping members. Their true coupling is now an explicit, reviewable surface, and each gains a mock-context test suite covering flows previously testable only through the full view model. Delivery/read acks now also clear the router's retained-send outbox via the delivery context. New LargeTopologyTests exercise production-shaped meshes with the in-memory harness: an 8-peer relay chain with per-hop TTL decay, a 14-peer cyclic mesh with exactly-once delivery, partition/heal, and topology churn. App-layer runtime/model files updated alongside. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
228 lines
8.2 KiB
Swift
228 lines
8.2 KiB
Swift
import BitFoundation
|
|
import Foundation
|
|
import Testing
|
|
@testable import bitchat
|
|
|
|
struct BLEFragmentHandlerTests {
|
|
private final class Recorder {
|
|
/// Optional override for the assembly result; defaults to `.stored`.
|
|
var appendResult: ((BLEFragmentHeader) -> BLEFragmentAssemblyBuffer.AppendResult)?
|
|
var accepted = true
|
|
|
|
var trackedPackets: [BitchatPacket] = []
|
|
var appendedHeaders: [BLEFragmentHeader] = []
|
|
var ingressChecks: [(packet: BitchatPacket, innerSender: PeerID)] = []
|
|
var reinjectedPackets: [(packet: BitchatPacket, from: PeerID)] = []
|
|
}
|
|
|
|
private let localPeerID = PeerID(str: "0102030405060708")
|
|
private let remotePeerID = PeerID(str: "1122334455667788")
|
|
|
|
private func makeHandler(recorder: Recorder) -> BLEFragmentHandler {
|
|
let environment = BLEFragmentHandlerEnvironment(
|
|
localPeerID: { [localPeerID] in localPeerID },
|
|
trackPacketSeen: { packet in
|
|
recorder.trackedPackets.append(packet)
|
|
},
|
|
appendFragment: { header in
|
|
recorder.appendedHeaders.append(header)
|
|
return recorder.appendResult?(header) ?? .stored(header: header, started: true)
|
|
},
|
|
isAcceptedIngressPayload: { packet, innerSender in
|
|
recorder.ingressChecks.append((packet, innerSender))
|
|
return recorder.accepted
|
|
},
|
|
processReassembledPacket: { packet, from in
|
|
recorder.reinjectedPackets.append((packet, from))
|
|
}
|
|
)
|
|
return BLEFragmentHandler(environment: environment)
|
|
}
|
|
|
|
@Test
|
|
func ownFragmentIsIgnored() {
|
|
let recorder = Recorder()
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = makeFragmentPacket(sender: localPeerID, index: 0, total: 2)
|
|
|
|
handler.handle(packet, from: localPeerID)
|
|
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.appendedHeaders.isEmpty)
|
|
#expect(recorder.reinjectedPackets.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func malformedFragmentPayloadIsIgnored() {
|
|
let recorder = Recorder()
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = BitchatPacket(
|
|
type: MessageType.fragment.rawValue,
|
|
senderID: Data(hexString: remotePeerID.id) ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: 900_000,
|
|
payload: Data([0x01, 0x02, 0x03]),
|
|
signature: nil,
|
|
ttl: TransportConfig.messageTTLDefault
|
|
)
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.appendedHeaders.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func broadcastFragmentIsTrackedAndAppended() {
|
|
let recorder = Recorder()
|
|
let handler = makeHandler(recorder: recorder)
|
|
let chunk = Data([0xDE, 0xAD, 0xBE, 0xEF])
|
|
let packet = makeFragmentPacket(sender: remotePeerID, index: 0, total: 3, chunk: chunk)
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
#expect(recorder.trackedPackets.count == 1)
|
|
#expect(recorder.appendedHeaders.count == 1)
|
|
#expect(recorder.appendedHeaders.first?.index == 0)
|
|
#expect(recorder.appendedHeaders.first?.total == 3)
|
|
#expect(recorder.appendedHeaders.first?.originalType == MessageType.message.rawValue)
|
|
#expect(recorder.appendedHeaders.first?.fragmentData == chunk)
|
|
#expect(recorder.ingressChecks.isEmpty)
|
|
#expect(recorder.reinjectedPackets.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func directedFragmentIsNotTrackedForSync() {
|
|
let recorder = Recorder()
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = makeFragmentPacket(
|
|
sender: remotePeerID,
|
|
index: 1,
|
|
total: 3,
|
|
recipientID: Data(hexString: localPeerID.id)
|
|
)
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.appendedHeaders.count == 1)
|
|
}
|
|
|
|
@Test
|
|
func completedReassemblyReinjectsAcceptedPacketWithZeroTTL() throws {
|
|
let innerSender = PeerID(str: "99AABBCCDDEEFF00")
|
|
let innerPacket = BitchatPacket(
|
|
type: MessageType.message.rawValue,
|
|
senderID: Data(hexString: innerSender.id) ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: 900_000,
|
|
payload: Data("hello".utf8),
|
|
signature: nil,
|
|
ttl: 5
|
|
)
|
|
let reassembled = try #require(innerPacket.toBinaryData())
|
|
|
|
let recorder = Recorder()
|
|
recorder.appendResult = { header in
|
|
.complete(header: header, reassembledData: reassembled, started: false)
|
|
}
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = makeFragmentPacket(sender: remotePeerID, index: 1, total: 2)
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
#expect(recorder.ingressChecks.count == 1)
|
|
#expect(recorder.ingressChecks.first?.innerSender == innerSender)
|
|
#expect(recorder.reinjectedPackets.count == 1)
|
|
let reinjected = recorder.reinjectedPackets.first
|
|
#expect(reinjected?.from == remotePeerID)
|
|
#expect(reinjected?.packet.type == MessageType.message.rawValue)
|
|
#expect(reinjected?.packet.payload == Data("hello".utf8))
|
|
// Reassembled packets must re-enter the pipeline with TTL zeroed.
|
|
#expect(reinjected?.packet.ttl == 0)
|
|
}
|
|
|
|
@Test
|
|
func rejectedReassembledPacketIsNotReinjected() throws {
|
|
let innerPacket = BitchatPacket(
|
|
type: MessageType.message.rawValue,
|
|
senderID: Data(hexString: "99AABBCCDDEEFF00") ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: 900_000,
|
|
payload: Data("hello".utf8),
|
|
signature: nil,
|
|
ttl: 5
|
|
)
|
|
let reassembled = try #require(innerPacket.toBinaryData())
|
|
|
|
let recorder = Recorder()
|
|
recorder.accepted = false
|
|
recorder.appendResult = { header in
|
|
.complete(header: header, reassembledData: reassembled, started: false)
|
|
}
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = makeFragmentPacket(sender: remotePeerID, index: 1, total: 2)
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
#expect(recorder.ingressChecks.count == 1)
|
|
#expect(recorder.reinjectedPackets.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func undecodableReassembledDataIsDropped() {
|
|
let recorder = Recorder()
|
|
recorder.appendResult = { header in
|
|
.complete(header: header, reassembledData: Data([0x00, 0x01]), started: false)
|
|
}
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = makeFragmentPacket(sender: remotePeerID, index: 1, total: 2)
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
#expect(recorder.ingressChecks.isEmpty)
|
|
#expect(recorder.reinjectedPackets.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func incompleteAssemblyDoesNotReinject() {
|
|
let recorder = Recorder()
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = makeFragmentPacket(sender: remotePeerID, index: 0, total: 2)
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
#expect(recorder.appendedHeaders.count == 1)
|
|
#expect(recorder.ingressChecks.isEmpty)
|
|
#expect(recorder.reinjectedPackets.isEmpty)
|
|
}
|
|
|
|
private func makeFragmentPacket(
|
|
sender: PeerID,
|
|
index: UInt16,
|
|
total: UInt16,
|
|
originalType: UInt8 = MessageType.message.rawValue,
|
|
chunk: Data = Data([0xAA, 0xBB]),
|
|
recipientID: Data? = nil
|
|
) -> BitchatPacket {
|
|
var payload = Data()
|
|
payload.append(contentsOf: [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]) // fragment ID
|
|
payload.append(UInt8(index >> 8))
|
|
payload.append(UInt8(index & 0xFF))
|
|
payload.append(UInt8(total >> 8))
|
|
payload.append(UInt8(total & 0xFF))
|
|
payload.append(originalType)
|
|
payload.append(chunk)
|
|
|
|
return BitchatPacket(
|
|
type: MessageType.fragment.rawValue,
|
|
senderID: Data(hexString: sender.id) ?? Data(),
|
|
recipientID: recipientID,
|
|
timestamp: 900_000,
|
|
payload: payload,
|
|
signature: nil,
|
|
ttl: TransportConfig.messageTTLDefault
|
|
)
|
|
}
|
|
}
|