mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +00:00
Extract BLE packet handlers and migrate coordinators to narrow contexts
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>
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEAnnounceHandlerTests {
|
||||
private final class Recorder {
|
||||
var existingNoisePublicKey: Data?
|
||||
var signatureValid = true
|
||||
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
||||
var upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||
var dedupSeenIDs: Set<String> = []
|
||||
var shouldEmitReconnectLogResult = true
|
||||
|
||||
var verifySignatureCalls: [(packet: BitchatPacket, signingPublicKey: Data)] = []
|
||||
var barrierCount = 0
|
||||
var upsertCalls: [(peerID: PeerID, announcement: AnnouncementPacket, isConnected: Bool, now: Date)] = []
|
||||
var reconnectLogQueries: [PeerID] = []
|
||||
var topologyUpdates: [(peerID: PeerID, neighbors: [Data])] = []
|
||||
var persistedIdentities: [AnnouncementPacket] = []
|
||||
var dedupContainsQueries: [String] = []
|
||||
var dedupMarkedIDs: [String] = []
|
||||
var uiEventDeliveries: [(peerID: PeerID, notifyPeerConnected: Bool, scheduleInitialSync: Bool)] = []
|
||||
var trackedPackets: [BitchatPacket] = []
|
||||
var announceBacks = 0
|
||||
var afterglowDelays: [TimeInterval] = []
|
||||
}
|
||||
|
||||
private func makeHandler(
|
||||
recorder: Recorder,
|
||||
localPeerID: PeerID = PeerID(str: "0102030405060708"),
|
||||
now: Date = Date(timeIntervalSince1970: 1_000)
|
||||
) -> BLEAnnounceHandler {
|
||||
let environment = BLEAnnounceHandlerEnvironment(
|
||||
localPeerID: { localPeerID },
|
||||
messageTTL: TransportConfig.messageTTLDefault,
|
||||
now: { now },
|
||||
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
|
||||
verifySignature: { packet, signingPublicKey in
|
||||
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
||||
return recorder.signatureValid
|
||||
},
|
||||
linkState: { _ in recorder.linkState },
|
||||
withRegistryBarrier: { body in
|
||||
recorder.barrierCount += 1
|
||||
body()
|
||||
},
|
||||
upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in
|
||||
recorder.upsertCalls.append((peerID, announcement, isConnected, now))
|
||||
return recorder.upsertResult
|
||||
},
|
||||
shouldEmitReconnectLog: { peerID, _ in
|
||||
recorder.reconnectLogQueries.append(peerID)
|
||||
return recorder.shouldEmitReconnectLogResult
|
||||
},
|
||||
updateTopology: { peerID, neighbors in
|
||||
recorder.topologyUpdates.append((peerID, neighbors))
|
||||
},
|
||||
persistIdentity: { announcement in
|
||||
recorder.persistedIdentities.append(announcement)
|
||||
},
|
||||
dedupContains: { id in
|
||||
recorder.dedupContainsQueries.append(id)
|
||||
return recorder.dedupSeenIDs.contains(id)
|
||||
},
|
||||
dedupMarkProcessed: { id in
|
||||
recorder.dedupMarkedIDs.append(id)
|
||||
},
|
||||
deliverAnnounceUIEvents: { peerID, notifyPeerConnected, scheduleInitialSync in
|
||||
recorder.uiEventDeliveries.append((peerID, notifyPeerConnected, scheduleInitialSync))
|
||||
},
|
||||
trackPacketSeen: { packet in
|
||||
recorder.trackedPackets.append(packet)
|
||||
},
|
||||
sendAnnounceBack: {
|
||||
recorder.announceBacks += 1
|
||||
},
|
||||
scheduleAfterglow: { delay in
|
||||
recorder.afterglowDelays.append(delay)
|
||||
}
|
||||
)
|
||||
return BLEAnnounceHandler(environment: environment)
|
||||
}
|
||||
|
||||
@Test
|
||||
func verifiedNewPeerAnnounceUpsertsNotifiesSyncsAndAnnouncesBack() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x11, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64)
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.verifySignatureCalls.count == 1)
|
||||
#expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32))
|
||||
#expect(recorder.barrierCount == 1)
|
||||
#expect(recorder.upsertCalls.count == 1)
|
||||
#expect(recorder.upsertCalls.first?.peerID == peerID)
|
||||
#expect(recorder.upsertCalls.first?.announcement.nickname == "Alice")
|
||||
#expect(recorder.upsertCalls.first?.isConnected == true)
|
||||
#expect(recorder.upsertCalls.first?.now == now)
|
||||
#expect(recorder.persistedIdentities.count == 1)
|
||||
#expect(recorder.persistedIdentities.first?.noisePublicKey == noiseKey)
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
#expect(recorder.uiEventDeliveries.first?.peerID == peerID)
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == true)
|
||||
#expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == true)
|
||||
#expect(recorder.trackedPackets.count == 1)
|
||||
#expect(recorder.dedupMarkedIDs == ["announce-back-\(peerID)"])
|
||||
#expect(recorder.announceBacks == 1)
|
||||
#expect(recorder.afterglowDelays.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func afterglowDelayStaysWithinConfiguredRange() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x12, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64)
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
for _ in 0..<8 {
|
||||
handler.handle(packet, from: peerID)
|
||||
}
|
||||
|
||||
#expect(recorder.afterglowDelays.count == 8)
|
||||
for delay in recorder.afterglowDelays {
|
||||
#expect(delay >= 0.3 && delay <= 0.6)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func unverifiedAnnounceWithoutSignatureSkipsUpsertAndConnectNotify() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x22, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: nil
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.verifySignatureCalls.isEmpty)
|
||||
#expect(recorder.barrierCount == 1)
|
||||
#expect(recorder.upsertCalls.isEmpty)
|
||||
#expect(recorder.topologyUpdates.isEmpty)
|
||||
#expect(recorder.afterglowDelays.isEmpty)
|
||||
// Original behavior: list refresh, identity persistence, sync tracking
|
||||
// and announce-back still occur for unverified announces.
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||
#expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == false)
|
||||
#expect(recorder.persistedIdentities.count == 1)
|
||||
#expect(recorder.trackedPackets.count == 1)
|
||||
#expect(recorder.announceBacks == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func invalidSignatureSkipsUpsertAndConnectNotify() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x23, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64)
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
recorder.signatureValid = false
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.verifySignatureCalls.count == 1)
|
||||
#expect(recorder.upsertCalls.isEmpty)
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func malformedAnnounceIsNoOp() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: Data(hexString: peerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp(now),
|
||||
payload: Data([0x01, 0x20]),
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
expectNoSideEffects(recorder)
|
||||
}
|
||||
|
||||
@Test
|
||||
func selfAnnounceIsNoOp() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x33, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64)
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder, localPeerID: peerID, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
expectNoSideEffects(recorder)
|
||||
}
|
||||
|
||||
@Test
|
||||
func staleAnnounceIsNoOp() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x44, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let staleTimestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: staleTimestamp,
|
||||
signature: Data(repeating: 0xEE, count: 64)
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
expectNoSideEffects(recorder)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reconnectedPeerNotifiesConnectionWithoutAfterglow() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x55, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64)
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: true, previousNickname: "Alice")
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
#expect(recorder.uiEventDeliveries.first?.peerID == peerID)
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == true)
|
||||
#expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == true)
|
||||
#expect(recorder.reconnectLogQueries == [peerID])
|
||||
#expect(recorder.afterglowDelays.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func relayedNewPeerSchedulesAfterglowWithoutConnectNotify() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x66, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64),
|
||||
ttl: TransportConfig.messageTTLDefault - 1
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.upsertCalls.count == 1)
|
||||
#expect(recorder.upsertCalls.first?.isConnected == false)
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||
#expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == false)
|
||||
#expect(recorder.afterglowDelays.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func announceBackIsSkippedWhenAlreadyMarked() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x77, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64)
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
recorder.dedupSeenIDs = ["announce-back-\(peerID)"]
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.dedupContainsQueries == ["announce-back-\(peerID)"])
|
||||
#expect(recorder.dedupMarkedIDs.isEmpty)
|
||||
#expect(recorder.announceBacks == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func verifiedAnnounceWithNeighborsUpdatesTopology() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x88, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let neighbors = [Data(repeating: 0xAB, count: 8), Data(repeating: 0xCD, count: 8)]
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64),
|
||||
directNeighbors: neighbors
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.topologyUpdates.count == 1)
|
||||
#expect(recorder.topologyUpdates.first?.peerID == peerID)
|
||||
#expect(recorder.topologyUpdates.first?.neighbors == neighbors)
|
||||
}
|
||||
|
||||
@Test
|
||||
func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x99, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64)
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32)
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.upsertCalls.isEmpty)
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||
}
|
||||
|
||||
private func expectNoSideEffects(_ recorder: Recorder) {
|
||||
#expect(recorder.barrierCount == 0)
|
||||
#expect(recorder.upsertCalls.isEmpty)
|
||||
#expect(recorder.topologyUpdates.isEmpty)
|
||||
#expect(recorder.persistedIdentities.isEmpty)
|
||||
#expect(recorder.dedupMarkedIDs.isEmpty)
|
||||
#expect(recorder.uiEventDeliveries.isEmpty)
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.announceBacks == 0)
|
||||
#expect(recorder.afterglowDelays.isEmpty)
|
||||
}
|
||||
|
||||
private func makeAnnouncePacket(
|
||||
noisePublicKey: Data,
|
||||
peerID: PeerID,
|
||||
timestamp: UInt64,
|
||||
signature: Data?,
|
||||
ttl: UInt8 = TransportConfig.messageTTLDefault,
|
||||
directNeighbors: [Data]? = nil
|
||||
) throws -> BitchatPacket {
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: "Alice",
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: Data(repeating: 0x99, count: 32),
|
||||
directNeighbors: directNeighbors
|
||||
)
|
||||
let payload = try #require(announcement.encode())
|
||||
|
||||
return BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: Data(hexString: peerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: payload,
|
||||
signature: signature,
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
|
||||
private func timestamp(_ date: Date) -> UInt64 {
|
||||
UInt64(date.timeIntervalSince1970 * 1000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
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 saveResult: URL? = URL(fileURLWithPath: "/tmp/files/incoming/sample.pdf")
|
||||
|
||||
var signedNameQueries: [PeerID] = []
|
||||
var trackedPackets: [BitchatPacket] = []
|
||||
var quotaReservations: [Int] = []
|
||||
var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = []
|
||||
var lastSeenUpdates: [PeerID] = []
|
||||
var deliveredMessages: [BitchatMessage] = []
|
||||
}
|
||||
|
||||
private let localPeerID = PeerID(str: "0102030405060708")
|
||||
private let remotePeerID = PeerID(str: "1122334455667788")
|
||||
|
||||
private func makeHandler(recorder: Recorder) -> BLEFileTransferHandler {
|
||||
let environment = BLEFileTransferHandlerEnvironment(
|
||||
localPeerID: { [localPeerID] in localPeerID },
|
||||
localNickname: { recorder.localNickname },
|
||||
peersSnapshot: { recorder.peers },
|
||||
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))
|
||||
return recorder.saveResult
|
||||
},
|
||||
updatePeerLastSeen: { peerID in
|
||||
recorder.lastSeenUpdates.append(peerID)
|
||||
},
|
||||
deliverMessage: { message in
|
||||
recorder.deliveredMessages.append(message)
|
||||
}
|
||||
)
|
||||
return BLEFileTransferHandler(environment: environment)
|
||||
}
|
||||
|
||||
@Test
|
||||
func broadcastFileFromVerifiedPeerIsSavedAndDelivered() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data("%PDF-1.7".utf8)
|
||||
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: content)
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
#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))
|
||||
}
|
||||
|
||||
@Test
|
||||
func selfEchoIsDropped() throws {
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3)
|
||||
|
||||
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))
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.signedNameQueries == [remotePeerID])
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func connectedUnverifiedPeerIsAccepted() 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))
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
// Unlike public messages, file transfers accept connected-but-unverified peers.
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.sender == "Bob")
|
||||
}
|
||||
|
||||
@Test
|
||||
func fileDirectedToAnotherPeerIsIgnored() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: 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")
|
||||
)
|
||||
|
||||
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)]
|
||||
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)
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
func malformedPayloadIsTrackedForSyncButDropped() {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: 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: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
|
||||
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)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: nil, content: Data([0x4D, 0x5A, 0x00, 0x00]))
|
||||
|
||||
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)]
|
||||
recorder.saveResult = nil
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.quotaReservations.count == 1)
|
||||
#expect(recorder.saveCalls.count == 1)
|
||||
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
}
|
||||
|
||||
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
|
||||
) -> BLEPeerInfo {
|
||||
BLEPeerInfo(
|
||||
peerID: peerID,
|
||||
nickname: nickname,
|
||||
isConnected: isConnected,
|
||||
noisePublicKey: nil,
|
||||
signingPublicKey: nil,
|
||||
isVerifiedNickname: isVerified,
|
||||
lastSeen: Date(timeIntervalSince1970: 999)
|
||||
)
|
||||
}
|
||||
|
||||
private func makeFileTransferPacket(
|
||||
sender: PeerID,
|
||||
mimeType: String?,
|
||||
content: Data,
|
||||
ttl: UInt8 = TransportConfig.messageTTLDefault,
|
||||
recipientID: Data? = nil
|
||||
) throws -> BitchatPacket {
|
||||
let filePacket = BitchatFilePacket(
|
||||
fileName: "sample",
|
||||
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: nil,
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLENoisePacketHandlerTests {
|
||||
private struct TestError: Error {}
|
||||
|
||||
private final class Recorder {
|
||||
var handshakeResult: Result<Data?, Error> = .success(nil)
|
||||
var hasSession = false
|
||||
var decryptResult: Result<Data, Error> = .success(Data())
|
||||
|
||||
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
|
||||
var hasSessionQueries: [PeerID] = []
|
||||
var initiatedHandshakes: [PeerID] = []
|
||||
var broadcastPackets: [BitchatPacket] = []
|
||||
var lastSeenUpdates: [PeerID] = []
|
||||
var decryptCalls: [(payload: Data, peerID: PeerID)] = []
|
||||
var clearedSessions: [PeerID] = []
|
||||
var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = []
|
||||
/// Ordered side-effect log to assert recovery sequencing.
|
||||
var events: [String] = []
|
||||
}
|
||||
|
||||
private let localPeerID = PeerID(str: "0102030405060708")
|
||||
private let remotePeerID = PeerID(str: "1122334455667788")
|
||||
private let localPeerIDData = Data(hexString: "0102030405060708") ?? Data()
|
||||
|
||||
private func makeHandler(
|
||||
recorder: Recorder,
|
||||
now: Date = Date(timeIntervalSince1970: 1_000)
|
||||
) -> BLENoisePacketHandler {
|
||||
let environment = BLENoisePacketHandlerEnvironment(
|
||||
localPeerID: { [localPeerID] in localPeerID },
|
||||
localPeerIDData: { [localPeerIDData] in localPeerIDData },
|
||||
messageTTL: TransportConfig.messageTTLDefault,
|
||||
now: { now },
|
||||
processHandshakeMessage: { peerID, message in
|
||||
recorder.processedHandshakes.append((peerID, message))
|
||||
return try recorder.handshakeResult.get()
|
||||
},
|
||||
hasNoiseSession: { peerID in
|
||||
recorder.hasSessionQueries.append(peerID)
|
||||
return recorder.hasSession
|
||||
},
|
||||
initiateHandshake: { peerID in
|
||||
recorder.initiatedHandshakes.append(peerID)
|
||||
recorder.events.append("initiateHandshake")
|
||||
},
|
||||
broadcastPacket: { packet in
|
||||
recorder.broadcastPackets.append(packet)
|
||||
},
|
||||
updatePeerLastSeen: { peerID in
|
||||
recorder.lastSeenUpdates.append(peerID)
|
||||
},
|
||||
decrypt: { payload, peerID in
|
||||
recorder.decryptCalls.append((payload, peerID))
|
||||
return try recorder.decryptResult.get()
|
||||
},
|
||||
clearSession: { peerID in
|
||||
recorder.clearedSessions.append(peerID)
|
||||
recorder.events.append("clearSession")
|
||||
},
|
||||
deliverNoisePayload: { peerID, type, payload, timestamp in
|
||||
recorder.deliveries.append((peerID, type, payload, timestamp))
|
||||
}
|
||||
)
|
||||
return BLENoisePacketHandler(environment: environment)
|
||||
}
|
||||
|
||||
// MARK: Handshake
|
||||
|
||||
@Test
|
||||
func handshakeForUsBroadcastsResponsePacket() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.handshakeResult = .success(Data([0xAA, 0xBB]))
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id))
|
||||
|
||||
handler.handleHandshake(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.processedHandshakes.count == 1)
|
||||
#expect(recorder.processedHandshakes.first?.peerID == remotePeerID)
|
||||
#expect(recorder.processedHandshakes.first?.message == packet.payload)
|
||||
#expect(recorder.broadcastPackets.count == 1)
|
||||
let response = recorder.broadcastPackets.first
|
||||
#expect(response?.type == MessageType.noiseHandshake.rawValue)
|
||||
#expect(response?.senderID == localPeerIDData)
|
||||
#expect(response?.recipientID == Data(hexString: remotePeerID.id))
|
||||
#expect(response?.payload == Data([0xAA, 0xBB]))
|
||||
#expect(response?.signature == nil)
|
||||
#expect(response?.ttl == TransportConfig.messageTTLDefault)
|
||||
#expect(response?.timestamp == UInt64(now.timeIntervalSince1970 * 1000))
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func handshakeWithoutResponseDoesNotBroadcast() {
|
||||
let recorder = Recorder()
|
||||
recorder.handshakeResult = .success(nil)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id))
|
||||
|
||||
handler.handleHandshake(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.processedHandshakes.count == 1)
|
||||
#expect(recorder.broadcastPackets.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func handshakeForAnotherPeerIsIgnored() {
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeHandshakePacket(recipientID: Data(hexString: remotePeerID.id))
|
||||
|
||||
handler.handleHandshake(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.processedHandshakes.isEmpty)
|
||||
#expect(recorder.broadcastPackets.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func handshakeFailureInitiatesNewHandshakeWhenNoSession() {
|
||||
let recorder = Recorder()
|
||||
recorder.handshakeResult = .failure(TestError())
|
||||
recorder.hasSession = false
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id))
|
||||
|
||||
handler.handleHandshake(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.hasSessionQueries == [remotePeerID])
|
||||
#expect(recorder.initiatedHandshakes == [remotePeerID])
|
||||
#expect(recorder.broadcastPackets.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func handshakeFailureSkipsInitiateWhenSessionExists() {
|
||||
let recorder = Recorder()
|
||||
recorder.handshakeResult = .failure(TestError())
|
||||
recorder.hasSession = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id))
|
||||
|
||||
handler.handleHandshake(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.hasSessionQueries == [remotePeerID])
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: Encrypted
|
||||
|
||||
@Test
|
||||
func encryptedWithoutRecipientIsDropped() {
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeEncryptedPacket(recipientID: nil)
|
||||
|
||||
handler.handleEncrypted(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||
#expect(recorder.decryptCalls.isEmpty)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func encryptedForAnotherPeerIsDropped() {
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeEncryptedPacket(recipientID: Data(hexString: remotePeerID.id))
|
||||
|
||||
handler.handleEncrypted(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||
#expect(recorder.decryptCalls.isEmpty)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func decryptedPayloadIsDeliveredWithTypeAndTimestamp() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.decryptResult = .success(Data([NoisePayloadType.privateMessage.rawValue, 0x01, 0x02, 0x03]))
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let sentAt = Date(timeIntervalSince1970: 900)
|
||||
let packet = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
timestamp: UInt64(sentAt.timeIntervalSince1970 * 1000)
|
||||
)
|
||||
|
||||
handler.handleEncrypted(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
||||
#expect(recorder.decryptCalls.count == 1)
|
||||
#expect(recorder.decryptCalls.first?.payload == packet.payload)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.peerID == remotePeerID)
|
||||
#expect(recorder.deliveries.first?.type == .privateMessage)
|
||||
#expect(recorder.deliveries.first?.payload == Data([0x01, 0x02, 0x03]))
|
||||
#expect(recorder.deliveries.first?.timestamp == sentAt)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func emptyDecryptedPayloadIsIgnored() {
|
||||
let recorder = Recorder()
|
||||
recorder.decryptResult = .success(Data())
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id))
|
||||
|
||||
handler.handleEncrypted(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 1)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func unknownNoisePayloadTypeIsIgnored() {
|
||||
let recorder = Recorder()
|
||||
recorder.decryptResult = .success(Data([0xEE, 0x01]))
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id))
|
||||
|
||||
handler.handleEncrypted(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 1)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func missingSessionInitiatesHandshakeWithoutClearing() {
|
||||
let recorder = Recorder()
|
||||
recorder.decryptResult = .failure(NoiseEncryptionError.sessionNotEstablished)
|
||||
recorder.hasSession = false
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id))
|
||||
|
||||
handler.handleEncrypted(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.hasSessionQueries == [remotePeerID])
|
||||
#expect(recorder.initiatedHandshakes == [remotePeerID])
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func missingSessionSkipsInitiateWhenSessionAppeared() {
|
||||
let recorder = Recorder()
|
||||
recorder.decryptResult = .failure(NoiseEncryptionError.sessionNotEstablished)
|
||||
recorder.hasSession = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id))
|
||||
|
||||
handler.handleEncrypted(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func decryptFailureClearsSessionThenReinitiatesHandshake() {
|
||||
let recorder = Recorder()
|
||||
recorder.decryptResult = .failure(TestError())
|
||||
// Even with a live session, recovery clears it and re-initiates unconditionally.
|
||||
recorder.hasSession = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id))
|
||||
|
||||
handler.handleEncrypted(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.clearedSessions == [remotePeerID])
|
||||
#expect(recorder.initiatedHandshakes == [remotePeerID])
|
||||
// Session-recovery order must stay clear → re-initiate.
|
||||
#expect(recorder.events == ["clearSession", "initiateHandshake"])
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
private func makeHandshakePacket(recipientID: Data?) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.noiseHandshake.rawValue,
|
||||
senderID: Data(hexString: remotePeerID.id) ?? Data(),
|
||||
recipientID: recipientID,
|
||||
timestamp: 900_000,
|
||||
payload: Data([0x01, 0x02, 0x03]),
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
}
|
||||
|
||||
private func makeEncryptedPacket(
|
||||
recipientID: Data?,
|
||||
timestamp: UInt64 = 900_000
|
||||
) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: remotePeerID.id) ?? Data(),
|
||||
recipientID: recipientID,
|
||||
timestamp: timestamp,
|
||||
payload: Data([0xC0, 0xFF, 0xEE]),
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEPublicMessageHandlerTests {
|
||||
private final class Recorder {
|
||||
var localNickname = "Me"
|
||||
var peers: [PeerID: BLEPeerInfo] = [:]
|
||||
var signedName: String?
|
||||
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
||||
var selfBroadcastMessageID: String?
|
||||
|
||||
var peersSnapshotReads = 0
|
||||
var signedNameQueries: [PeerID] = []
|
||||
var trackedPackets: [BitchatPacket] = []
|
||||
var selfBroadcastTakes: [BitchatPacket] = []
|
||||
var deliveries: [(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)] = []
|
||||
}
|
||||
|
||||
private let localPeerID = PeerID(str: "0102030405060708")
|
||||
private let remotePeerID = PeerID(str: "1122334455667788")
|
||||
|
||||
private func makeHandler(
|
||||
recorder: Recorder,
|
||||
localPeerID: PeerID? = nil,
|
||||
now: Date = Date(timeIntervalSince1970: 1_000)
|
||||
) -> BLEPublicMessageHandler {
|
||||
let resolvedLocalPeerID = localPeerID ?? self.localPeerID
|
||||
let environment = BLEPublicMessageHandlerEnvironment(
|
||||
localPeerID: { resolvedLocalPeerID },
|
||||
localNickname: { recorder.localNickname },
|
||||
now: { now },
|
||||
peersSnapshot: {
|
||||
recorder.peersSnapshotReads += 1
|
||||
return recorder.peers
|
||||
},
|
||||
signedSenderDisplayName: { _, peerID in
|
||||
recorder.signedNameQueries.append(peerID)
|
||||
return recorder.signedName
|
||||
},
|
||||
trackPacketSeen: { packet in
|
||||
recorder.trackedPackets.append(packet)
|
||||
},
|
||||
linkState: { _ in recorder.linkState },
|
||||
takeSelfBroadcastMessageID: { packet in
|
||||
recorder.selfBroadcastTakes.append(packet)
|
||||
return recorder.selfBroadcastMessageID
|
||||
},
|
||||
deliverPublicMessage: { peerID, nickname, content, timestamp, messageID in
|
||||
recorder.deliveries.append((peerID, nickname, content, timestamp, messageID))
|
||||
}
|
||||
)
|
||||
return BLEPublicMessageHandler(environment: environment)
|
||||
}
|
||||
|
||||
@Test
|
||||
func verifiedPeerBroadcastIsTrackedAndDelivered() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, content: "hello mesh", timestamp: timestamp(now))
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.peersSnapshotReads == 1)
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.trackedPackets.count == 1)
|
||||
#expect(recorder.selfBroadcastTakes.isEmpty)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.peerID == remotePeerID)
|
||||
#expect(recorder.deliveries.first?.nickname == "Alice")
|
||||
#expect(recorder.deliveries.first?.content == "hello mesh")
|
||||
#expect(recorder.deliveries.first?.timestamp == now)
|
||||
#expect(recorder.deliveries.first?.messageID == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func selfEchoIsDropped() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(sender: localPeerID, content: "echo", timestamp: timestamp(now), ttl: 3)
|
||||
|
||||
handler.handle(packet, from: localPeerID)
|
||||
|
||||
expectNoSideEffects(recorder)
|
||||
}
|
||||
|
||||
@Test
|
||||
func staleBroadcastIsDropped() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let staleTimestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, content: "old", timestamp: staleTimestamp)
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
expectNoSideEffects(recorder)
|
||||
}
|
||||
|
||||
@Test
|
||||
func unknownPeerWithoutValidSignatureIsDroppedBeforeSyncTracking() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, content: "hi", timestamp: timestamp(now))
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.signedNameQueries == [remotePeerID])
|
||||
// The sender must resolve before the packet is tracked for sync.
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func connectedButUnverifiedPeerIsDropped() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Mallory", isVerified: false, isConnected: true)]
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, content: "hi", timestamp: timestamp(now))
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
// Public messages never accept connected-but-unverified registry entries.
|
||||
#expect(recorder.signedNameQueries == [remotePeerID])
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func signedSenderFallbackDeliversWithSignedName() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.signedName = "SignedAlice"
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, content: "signed hello", timestamp: timestamp(now))
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.signedNameQueries == [remotePeerID])
|
||||
#expect(recorder.trackedPackets.count == 1)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.nickname == "SignedAlice")
|
||||
}
|
||||
|
||||
@Test
|
||||
func invalidUTF8PayloadIsTrackedForSyncButNotDelivered() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, payload: Data([0xFF, 0xFE, 0xFD]), timestamp: timestamp(now))
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
// Sync tracking happens before payload decoding, matching the original order.
|
||||
#expect(recorder.trackedPackets.count == 1)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func selfSyncReplayResolvesOriginalMessageID() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.selfBroadcastMessageID = "original-id"
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
// TTL 0 marks a sync replay of our own broadcast, not a self echo.
|
||||
let packet = makeMessagePacket(sender: localPeerID, content: "mine", timestamp: timestamp(now), ttl: 0)
|
||||
|
||||
handler.handle(packet, from: localPeerID)
|
||||
|
||||
#expect(recorder.selfBroadcastTakes.count == 1)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.peerID == localPeerID)
|
||||
#expect(recorder.deliveries.first?.nickname == recorder.localNickname)
|
||||
#expect(recorder.deliveries.first?.messageID == "original-id")
|
||||
}
|
||||
|
||||
@Test
|
||||
func directedMessageIsDeliveredWithoutSyncTracking() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(
|
||||
sender: remotePeerID,
|
||||
content: "direct",
|
||||
timestamp: timestamp(now),
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
)
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.content == "direct")
|
||||
}
|
||||
|
||||
private func expectNoSideEffects(_ recorder: Recorder) {
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.selfBroadcastTakes.isEmpty)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
private func makePeerInfo(
|
||||
_ peerID: PeerID,
|
||||
nickname: String,
|
||||
isVerified: Bool,
|
||||
isConnected: Bool = true
|
||||
) -> BLEPeerInfo {
|
||||
BLEPeerInfo(
|
||||
peerID: peerID,
|
||||
nickname: nickname,
|
||||
isConnected: isConnected,
|
||||
noisePublicKey: nil,
|
||||
signingPublicKey: nil,
|
||||
isVerifiedNickname: isVerified,
|
||||
lastSeen: Date(timeIntervalSince1970: 999)
|
||||
)
|
||||
}
|
||||
|
||||
private func makeMessagePacket(
|
||||
sender: PeerID,
|
||||
content: String? = nil,
|
||||
payload: Data? = nil,
|
||||
timestamp: UInt64,
|
||||
ttl: UInt8 = TransportConfig.messageTTLDefault,
|
||||
recipientID: Data? = nil
|
||||
) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data(hexString: sender.id) ?? Data(),
|
||||
recipientID: recipientID,
|
||||
timestamp: timestamp,
|
||||
payload: payload ?? Data((content ?? "").utf8),
|
||||
signature: nil,
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
|
||||
private func timestamp(_ date: Date) -> UInt64 {
|
||||
UInt64(date.timeIntervalSince1970 * 1000)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user