mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
[codex] Extract BLE service policy helpers (#1321)
* Extract BLE service policy helpers * Stabilize image media transfer test --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BLE ingress packet guard tests")
|
||||
struct BLEIngressPacketGuardTests {
|
||||
@Test("valid packets return the received and validation peer context")
|
||||
func validPacketsReturnContext() throws {
|
||||
let local = PeerID(str: "0011223344556677")
|
||||
let bound = PeerID(str: "1122334455667788")
|
||||
let sender = PeerID(str: "8899aabbccddeeff")
|
||||
let packet = makePacket(sender: sender, timestamp: 1_000)
|
||||
|
||||
let context = try #require(success(BLEIngressPacketGuard.evaluate(
|
||||
packet: packet,
|
||||
claimedSenderID: sender,
|
||||
boundPeerID: bound,
|
||||
localPeerID: local,
|
||||
directAnnounceTTL: 7,
|
||||
nowMs: 1_000,
|
||||
isValidSyncResponse: { _ in false }
|
||||
)))
|
||||
|
||||
#expect(context.receivedFromPeerID == bound)
|
||||
#expect(context.validationPeerID == sender)
|
||||
}
|
||||
|
||||
@Test("self loopback and direct announce spoofing are rejected before timestamp checks")
|
||||
func linkBindingRejectionsWinBeforeTimestampChecks() {
|
||||
let local = PeerID(str: "0011223344556677")
|
||||
let bound = PeerID(str: "1122334455667788")
|
||||
let claimed = PeerID(str: "8899aabbccddeeff")
|
||||
let selfPacket = makePacket(sender: local, timestamp: 0)
|
||||
let spoofedAnnounce = makePacket(type: .announce, sender: claimed, timestamp: 0, ttl: 7)
|
||||
|
||||
let selfResult = BLEIngressPacketGuard.evaluate(
|
||||
packet: selfPacket,
|
||||
claimedSenderID: local,
|
||||
boundPeerID: bound,
|
||||
localPeerID: local,
|
||||
directAnnounceTTL: 7,
|
||||
nowMs: 1_000_000,
|
||||
isValidSyncResponse: { _ in false }
|
||||
)
|
||||
let spoofResult = BLEIngressPacketGuard.evaluate(
|
||||
packet: spoofedAnnounce,
|
||||
claimedSenderID: claimed,
|
||||
boundPeerID: bound,
|
||||
localPeerID: local,
|
||||
directAnnounceTTL: 7,
|
||||
nowMs: 1_000_000,
|
||||
isValidSyncResponse: { _ in false }
|
||||
)
|
||||
|
||||
#expect(selfResult == .failure(.selfLoopback(packetType: MessageType.message.rawValue)))
|
||||
#expect(spoofResult == .failure(.directSenderMismatch(boundPeerID: bound, claimedSenderID: claimed)))
|
||||
}
|
||||
|
||||
@Test("timestamp skew outside the window is rejected")
|
||||
func timestampSkewIsRejected() {
|
||||
let local = PeerID(str: "0011223344556677")
|
||||
let sender = PeerID(str: "1122334455667788")
|
||||
let packet = makePacket(sender: sender, timestamp: 1_000)
|
||||
|
||||
let result = BLEIngressPacketGuard.evaluate(
|
||||
packet: packet,
|
||||
claimedSenderID: sender,
|
||||
boundPeerID: nil,
|
||||
localPeerID: local,
|
||||
directAnnounceTTL: 7,
|
||||
nowMs: 200_000,
|
||||
maxTimestampSkewMs: 120_000,
|
||||
isValidSyncResponse: { _ in false }
|
||||
)
|
||||
|
||||
#expect(result == .failure(.timestampSkew(
|
||||
peerID: sender,
|
||||
skewMs: 199_000,
|
||||
maxSkewMs: 120_000
|
||||
)))
|
||||
}
|
||||
|
||||
@Test("valid RSR packets use bound peer validation and bypass timestamp skew")
|
||||
func validRSRUsesBoundPeerAndBypassesTimestamp() throws {
|
||||
let local = PeerID(str: "0011223344556677")
|
||||
let bound = PeerID(str: "1122334455667788")
|
||||
let sender = PeerID(str: "8899aabbccddeeff")
|
||||
var packet = makePacket(sender: sender, timestamp: 1)
|
||||
packet.isRSR = true
|
||||
|
||||
let context = try #require(success(BLEIngressPacketGuard.evaluate(
|
||||
packet: packet,
|
||||
claimedSenderID: sender,
|
||||
boundPeerID: bound,
|
||||
localPeerID: local,
|
||||
directAnnounceTTL: 7,
|
||||
nowMs: 1_000_000,
|
||||
isValidSyncResponse: { $0 == bound }
|
||||
)))
|
||||
|
||||
#expect(context.receivedFromPeerID == bound)
|
||||
#expect(context.validationPeerID == bound)
|
||||
}
|
||||
|
||||
@Test("invalid RSR packets are rejected")
|
||||
func invalidRSRIsRejected() {
|
||||
let local = PeerID(str: "0011223344556677")
|
||||
let bound = PeerID(str: "1122334455667788")
|
||||
let sender = PeerID(str: "8899aabbccddeeff")
|
||||
var packet = makePacket(sender: sender, timestamp: 1)
|
||||
packet.isRSR = true
|
||||
|
||||
let result = BLEIngressPacketGuard.evaluate(
|
||||
packet: packet,
|
||||
claimedSenderID: sender,
|
||||
boundPeerID: bound,
|
||||
localPeerID: local,
|
||||
directAnnounceTTL: 7,
|
||||
nowMs: 1_000_000,
|
||||
isValidSyncResponse: { _ in false }
|
||||
)
|
||||
|
||||
#expect(result == .failure(.invalidRSR(peerID: bound)))
|
||||
}
|
||||
|
||||
private func makePacket(
|
||||
type: MessageType = .message,
|
||||
sender: PeerID,
|
||||
timestamp: UInt64,
|
||||
ttl: UInt8 = 3
|
||||
) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: type.rawValue,
|
||||
senderID: Data(hexString: sender.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: Data([0x01, 0x02, 0x03]),
|
||||
signature: nil,
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
|
||||
private func success(_ result: Result<BLEIngressPacketContext, BLEIngressPacketGuard.Rejection>) -> BLEIngressPacketContext? {
|
||||
guard case .success(let context) = result else { return nil }
|
||||
return context
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BLE log rate limiter tests")
|
||||
struct BLELogRateLimiterTests {
|
||||
@Test("repeated log keys are suppressed until the default interval elapses")
|
||||
func suppressesRepeatedKeysUntilIntervalElapses() {
|
||||
let limiter = BLELogRateLimiter(defaultMinimumInterval: 5)
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
|
||||
#expect(limiter.shouldLog(key: "ready:peer-a", now: now))
|
||||
#expect(!limiter.shouldLog(key: "ready:peer-a", now: now.addingTimeInterval(4.9)))
|
||||
#expect(limiter.shouldLog(key: "ready:peer-a", now: now.addingTimeInterval(5)))
|
||||
}
|
||||
|
||||
@Test("different log keys are tracked independently")
|
||||
func tracksKeysIndependently() {
|
||||
let limiter = BLELogRateLimiter(defaultMinimumInterval: 5)
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
|
||||
#expect(limiter.shouldLog(key: "ready:peer-a", now: now))
|
||||
#expect(limiter.shouldLog(key: "ready:peer-b", now: now))
|
||||
#expect(!limiter.shouldLog(key: "ready:peer-a", now: now.addingTimeInterval(1)))
|
||||
}
|
||||
|
||||
@Test("call sites can override the default interval")
|
||||
func supportsPerCallIntervals() {
|
||||
let limiter = BLELogRateLimiter(defaultMinimumInterval: 5)
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
|
||||
#expect(limiter.shouldLog(key: "self-loopback", now: now, minimumInterval: 30))
|
||||
#expect(!limiter.shouldLog(key: "self-loopback", now: now.addingTimeInterval(29), minimumInterval: 30))
|
||||
#expect(limiter.shouldLog(key: "self-loopback", now: now.addingTimeInterval(30), minimumInterval: 30))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BLE outbound fragment planner tests")
|
||||
struct BLEOutboundFragmentPlannerTests {
|
||||
@Test("planner splits packets and preserves reassembled payload")
|
||||
func plannerSplitsAndReassemblesPacket() throws {
|
||||
let packet = makePacket(payload: makePayload(count: 384))
|
||||
let request = BLEOutboundFragmentTransferRequest(
|
||||
packet: packet,
|
||||
pad: false,
|
||||
maxChunk: 128,
|
||||
directedPeer: nil,
|
||||
transferId: nil
|
||||
)
|
||||
|
||||
let plan = try #require(BLEOutboundFragmentPlanner.makePlan(
|
||||
for: request,
|
||||
defaultChunkSize: 256,
|
||||
bleMaxMTU: 512,
|
||||
fragmentID: Data(repeating: 0xA1, count: 8)
|
||||
))
|
||||
let headers = try plan.fragmentPackets.map { try #require(BLEFragmentHeader(packet: $0)) }
|
||||
let reassembled = headers.reduce(into: Data()) { data, header in
|
||||
data.append(header.fragmentData)
|
||||
}
|
||||
let decoded = try #require(BinaryProtocol.decode(reassembled))
|
||||
|
||||
#expect(plan.fragmentVersion == 1)
|
||||
#expect(plan.chunkSize == 128)
|
||||
#expect(plan.spacingMs == TransportConfig.bleFragmentSpacingMs)
|
||||
#expect(headers.map(\.index) == Array(0..<headers.count))
|
||||
#expect(decoded.type == packet.type)
|
||||
#expect(decoded.payload == packet.payload)
|
||||
#expect(plan.fragmentPackets.allSatisfy { $0.recipientID == nil })
|
||||
}
|
||||
|
||||
@Test("directed fragments target the directed peer and use directed pacing")
|
||||
func directedFragmentsUseDirectedRecipientAndSpacing() throws {
|
||||
let directedPeer = PeerID(str: "8877665544332211")
|
||||
let packet = makePacket(payload: makePayload(count: 256))
|
||||
let request = BLEOutboundFragmentTransferRequest(
|
||||
packet: packet,
|
||||
pad: false,
|
||||
maxChunk: 96,
|
||||
directedPeer: directedPeer,
|
||||
transferId: nil
|
||||
)
|
||||
|
||||
let plan = try #require(BLEOutboundFragmentPlanner.makePlan(
|
||||
for: request,
|
||||
defaultChunkSize: 256,
|
||||
bleMaxMTU: 512,
|
||||
fragmentID: Data(repeating: 0xB2, count: 8)
|
||||
))
|
||||
|
||||
#expect(plan.spacingMs == TransportConfig.bleFragmentSpacingDirectedMs)
|
||||
#expect(plan.fragmentPackets.allSatisfy { $0.recipientID == Data(hexString: directedPeer.id) })
|
||||
}
|
||||
|
||||
@Test("route-aware fragments use version two and route-sized chunking")
|
||||
func routeAwareFragmentsUseVersionTwoAndRouteSizedChunking() throws {
|
||||
let route = [
|
||||
Data([0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17]),
|
||||
Data([0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27])
|
||||
]
|
||||
let packet = makePacket(payload: makePayload(count: 192), route: route, isRSR: true)
|
||||
let request = BLEOutboundFragmentTransferRequest(
|
||||
packet: packet,
|
||||
pad: false,
|
||||
maxChunk: nil,
|
||||
directedPeer: nil,
|
||||
transferId: nil
|
||||
)
|
||||
|
||||
let plan = try #require(BLEOutboundFragmentPlanner.makePlan(
|
||||
for: request,
|
||||
defaultChunkSize: 256,
|
||||
bleMaxMTU: 128,
|
||||
fragmentID: Data(repeating: 0xC3, count: 8)
|
||||
))
|
||||
let firstHeader = try #require(BLEFragmentHeader(packet: plan.fragmentPackets[0]))
|
||||
|
||||
#expect(plan.fragmentVersion == 2)
|
||||
#expect(plan.chunkSize == 64)
|
||||
#expect(firstHeader.fragmentData.count <= 64)
|
||||
#expect(plan.fragmentPackets.allSatisfy { $0.route == route && $0.isRSR })
|
||||
}
|
||||
|
||||
@Test("invalid fragment IDs do not produce a plan")
|
||||
func invalidFragmentIDReturnsNil() {
|
||||
let packet = makePacket(payload: makePayload(count: 128))
|
||||
let request = BLEOutboundFragmentTransferRequest(
|
||||
packet: packet,
|
||||
pad: false,
|
||||
maxChunk: nil,
|
||||
directedPeer: nil,
|
||||
transferId: nil
|
||||
)
|
||||
|
||||
#expect(BLEOutboundFragmentPlanner.makePlan(
|
||||
for: request,
|
||||
defaultChunkSize: 256,
|
||||
bleMaxMTU: 512,
|
||||
fragmentID: Data(repeating: 0x00, count: 7)
|
||||
) == nil)
|
||||
}
|
||||
|
||||
private func makePacket(
|
||||
payload: Data,
|
||||
route: [Data]? = nil,
|
||||
isRSR: Bool = false
|
||||
) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]),
|
||||
recipientID: nil,
|
||||
timestamp: 0x0102030405,
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 3,
|
||||
route: route,
|
||||
isRSR: isRSR
|
||||
)
|
||||
}
|
||||
|
||||
private func makePayload(count: Int, seed: UInt64 = 0xABCD_1234) -> Data {
|
||||
var state = seed
|
||||
return Data((0..<count).map { _ in
|
||||
state = state &* 6364136223846793005 &+ 1442695040888963407
|
||||
return UInt8(truncatingIfNeeded: state >> 32)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BLE peer registry tests")
|
||||
struct BLEPeerRegistryTests {
|
||||
@Test("upserted announces track new, reconnect, and rename transitions")
|
||||
func upsertVerifiedAnnounceTracksTransitions() {
|
||||
var registry = BLEPeerRegistry()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let firstSeen = Date(timeIntervalSince1970: 100)
|
||||
|
||||
let first = registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: "alice",
|
||||
noisePublicKey: Data([1, 2, 3]),
|
||||
signingPublicKey: Data([4, 5, 6]),
|
||||
isConnected: true,
|
||||
now: firstSeen
|
||||
)
|
||||
|
||||
#expect(first.isNewPeer)
|
||||
#expect(!first.wasDisconnected)
|
||||
#expect(first.previousNickname == nil)
|
||||
#expect(registry.connectedPeerIDs == [peerID])
|
||||
#expect(registry.nickname(for: peerID, connectedOnly: true) == "alice")
|
||||
|
||||
registry.markDisconnected(peerID)
|
||||
let reconnect = registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: "alice-renamed",
|
||||
noisePublicKey: Data([1, 2, 3]),
|
||||
signingPublicKey: Data([4, 5, 6]),
|
||||
isConnected: true,
|
||||
now: firstSeen.addingTimeInterval(1)
|
||||
)
|
||||
|
||||
#expect(!reconnect.isNewPeer)
|
||||
#expect(reconnect.wasDisconnected)
|
||||
#expect(reconnect.previousNickname == "alice")
|
||||
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
|
||||
}
|
||||
|
||||
@Test("reachability keeps recent verified offline peers only when mesh is attached")
|
||||
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
|
||||
let offlinePeer = PeerID(str: "1122334455667788")
|
||||
let connectedPeer = PeerID(str: "8877665544332211")
|
||||
let now = Date()
|
||||
|
||||
var isolatedRegistry = BLEPeerRegistry()
|
||||
isolatedRegistry.upsert(BLEPeerInfo(
|
||||
peerID: offlinePeer,
|
||||
nickname: "offline",
|
||||
isConnected: false,
|
||||
noisePublicKey: nil,
|
||||
signingPublicKey: nil,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: now
|
||||
))
|
||||
|
||||
#expect(!isolatedRegistry.isReachable(offlinePeer, now: now))
|
||||
|
||||
var attachedRegistry = isolatedRegistry
|
||||
attachedRegistry.upsert(BLEPeerInfo(
|
||||
peerID: connectedPeer,
|
||||
nickname: "connected",
|
||||
isConnected: true,
|
||||
noisePublicKey: nil,
|
||||
signingPublicKey: nil,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: now
|
||||
))
|
||||
|
||||
#expect(attachedRegistry.isReachable(offlinePeer, now: now))
|
||||
}
|
||||
|
||||
@Test("connectivity reconciliation disconnects inactive peers and prunes expired offline peers")
|
||||
func reconcileConnectivityUpdatesAndPrunesPeerState() {
|
||||
var registry = BLEPeerRegistry()
|
||||
let inactiveConnectedPeer = PeerID(str: "1122334455667788")
|
||||
let expiredOfflinePeer = PeerID(str: "8877665544332211")
|
||||
let now = Date()
|
||||
|
||||
registry.upsert(BLEPeerInfo(
|
||||
peerID: inactiveConnectedPeer,
|
||||
nickname: "inactive",
|
||||
isConnected: true,
|
||||
noisePublicKey: nil,
|
||||
signingPublicKey: nil,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: now.addingTimeInterval(-TransportConfig.blePeerInactivityTimeoutSeconds - 1)
|
||||
))
|
||||
registry.upsert(BLEPeerInfo(
|
||||
peerID: expiredOfflinePeer,
|
||||
nickname: "expired",
|
||||
isConnected: false,
|
||||
noisePublicKey: nil,
|
||||
signingPublicKey: nil,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: .distantPast
|
||||
))
|
||||
|
||||
let changes = registry.reconcileConnectivity(now: now, linkStates: [:])
|
||||
|
||||
#expect(changes.disconnectedPeerIDs == [inactiveConnectedPeer])
|
||||
#expect(changes.removedPeers.map(\.peerID) == [expiredOfflinePeer])
|
||||
#expect(registry.info(for: inactiveConnectedPeer)?.isConnected == false)
|
||||
#expect(registry.info(for: expiredOfflinePeer) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BLE peer sender display name tests")
|
||||
struct BLEPeerSenderDisplayNameTests {
|
||||
@Test("local peer resolves to local nickname")
|
||||
func localPeerUsesLocalNickname() {
|
||||
let local = PeerID(str: "1122334455667788")
|
||||
|
||||
#expect(BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: local,
|
||||
localPeerID: local,
|
||||
localNickname: "me",
|
||||
peers: [:],
|
||||
allowConnectedUnverified: false
|
||||
) == "me")
|
||||
}
|
||||
|
||||
@Test("verified nickname collisions add peer suffix")
|
||||
func verifiedCollisionAddsSuffix() {
|
||||
let local = PeerID(str: "1122334455667788")
|
||||
let peer = PeerID(str: "8877665544332211")
|
||||
let peers = [
|
||||
peer: makeInfo(peerID: peer, nickname: "sam", isConnected: false, isVerifiedNickname: true)
|
||||
]
|
||||
|
||||
#expect(BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peer,
|
||||
localPeerID: local,
|
||||
localNickname: "sam",
|
||||
peers: peers,
|
||||
allowConnectedUnverified: false
|
||||
) == "sam#8877")
|
||||
}
|
||||
|
||||
@Test("connected unverified fallback is opt in")
|
||||
func connectedUnverifiedFallbackIsOptIn() {
|
||||
let local = PeerID(str: "1122334455667788")
|
||||
let peer = PeerID(str: "8877665544332211")
|
||||
let peers = [
|
||||
peer: makeInfo(peerID: peer, nickname: "", isConnected: true, isVerifiedNickname: false)
|
||||
]
|
||||
|
||||
#expect(BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peer,
|
||||
localPeerID: local,
|
||||
localNickname: "me",
|
||||
peers: peers,
|
||||
allowConnectedUnverified: false
|
||||
) == nil)
|
||||
#expect(BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peer,
|
||||
localPeerID: local,
|
||||
localNickname: "me",
|
||||
peers: peers,
|
||||
allowConnectedUnverified: true
|
||||
) == "anon8877")
|
||||
}
|
||||
|
||||
private func makeInfo(
|
||||
peerID: PeerID,
|
||||
nickname: String,
|
||||
isConnected: Bool,
|
||||
isVerifiedNickname: Bool
|
||||
) -> BLEPeerInfo {
|
||||
BLEPeerInfo(
|
||||
peerID: peerID,
|
||||
nickname: nickname,
|
||||
isConnected: isConnected,
|
||||
noisePublicKey: nil,
|
||||
signingPublicKey: nil,
|
||||
isVerifiedNickname: isVerifiedNickname,
|
||||
lastSeen: Date(timeIntervalSince1970: 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BLE receive pipeline tests")
|
||||
struct BLEReceivePipelineTests {
|
||||
@Test("context includes sender, type-scoped message ID, and logging policy")
|
||||
func contextBuildsTypeScopedMessageID() {
|
||||
let sender = PeerID(str: "1122334455667788")
|
||||
let local = PeerID(str: "8877665544332211")
|
||||
let packet = makePacket(type: .message, sender: sender, timestamp: 1234)
|
||||
|
||||
let context = BLEReceivePipeline.context(for: packet, localPeerID: local)
|
||||
|
||||
#expect(context.senderID == sender)
|
||||
#expect(context.messageID == "\(sender)-1234-\(MessageType.message.rawValue)")
|
||||
#expect(context.messageType == .message)
|
||||
#expect(context.shouldDeduplicate)
|
||||
#expect(context.logsHandlingDetails)
|
||||
}
|
||||
|
||||
@Test("fragments and self sync replays bypass global deduplication")
|
||||
func contextBypassesDeduplicationForFragmentsAndSelfSyncReplay() {
|
||||
let local = PeerID(str: "1122334455667788")
|
||||
let remote = PeerID(str: "8877665544332211")
|
||||
|
||||
let fragment = BLEReceivePipeline.context(
|
||||
for: makePacket(type: .fragment, sender: remote),
|
||||
localPeerID: local
|
||||
)
|
||||
let selfReplay = BLEReceivePipeline.context(
|
||||
for: makePacket(type: .message, sender: local, ttl: 0),
|
||||
localPeerID: local
|
||||
)
|
||||
|
||||
#expect(!fragment.shouldDeduplicate)
|
||||
#expect(!selfReplay.shouldDeduplicate)
|
||||
}
|
||||
|
||||
@Test("dense duplicate traffic cancels pending relays but sparse traffic does not")
|
||||
func duplicateRelayCancellationUsesGraphDensity() {
|
||||
#expect(!BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: 2))
|
||||
#expect(BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: 3))
|
||||
}
|
||||
|
||||
@Test("relay decision maps packet context and suppresses local recipient traffic")
|
||||
func relayDecisionSuppressesLocalRecipientTraffic() {
|
||||
let sender = PeerID(str: "1122334455667788")
|
||||
let local = PeerID(str: "8877665544332211")
|
||||
let packet = makePacket(
|
||||
type: .noiseEncrypted,
|
||||
sender: sender,
|
||||
recipient: local,
|
||||
ttl: 7
|
||||
)
|
||||
|
||||
let decision = BLEReceivePipeline.relayDecision(
|
||||
for: packet,
|
||||
senderID: sender,
|
||||
localPeerID: local,
|
||||
degree: 3,
|
||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
||||
)
|
||||
|
||||
#expect(!decision.shouldRelay)
|
||||
}
|
||||
|
||||
@Test("recent traffic tracker prunes by count and time window")
|
||||
func recentTrafficTrackerPrunesByCountAndWindow() {
|
||||
var tracker = BLERecentTrafficTracker()
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
|
||||
tracker.recordPacket(at: now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds - 1))
|
||||
#expect(!tracker.hasTraffic(within: TransportConfig.bleRecentPacketWindowSeconds, now: now))
|
||||
|
||||
for index in 0...TransportConfig.bleRecentPacketWindowMaxCount {
|
||||
tracker.recordPacket(at: now.addingTimeInterval(Double(index) * 0.001))
|
||||
}
|
||||
|
||||
#expect(tracker.count == TransportConfig.bleRecentPacketWindowMaxCount)
|
||||
#expect(tracker.hasTraffic(within: 1.0, now: now.addingTimeInterval(0.1)))
|
||||
|
||||
tracker.removeAll()
|
||||
#expect(tracker.count == 0)
|
||||
}
|
||||
|
||||
private func makePacket(
|
||||
type: MessageType,
|
||||
sender: PeerID,
|
||||
recipient: PeerID? = nil,
|
||||
timestamp: UInt64 = 1,
|
||||
ttl: UInt8 = 7
|
||||
) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: type.rawValue,
|
||||
senderID: Data(hexString: sender.id) ?? Data(),
|
||||
recipientID: recipient.flatMap { Data(hexString: $0.id) },
|
||||
timestamp: timestamp,
|
||||
payload: Data([0x01, 0x02]),
|
||||
signature: nil,
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BLE subscription announce limiter tests")
|
||||
struct BLESubscriptionAnnounceLimiterTests {
|
||||
@Test("first subscription is allowed and repeated subscriptions are rate limited")
|
||||
func repeatedSubscriptionsAreRateLimited() {
|
||||
var limiter = BLESubscriptionAnnounceLimiter()
|
||||
let centralID = "central-a"
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
|
||||
#expect(limiter.decision(for: centralID, now: now) == .allowed)
|
||||
#expect(limiter.trackedCentralCount == 1)
|
||||
|
||||
let second = limiter.decision(for: centralID, now: now.addingTimeInterval(0.1))
|
||||
#expect(second == .rateLimited(
|
||||
backoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds,
|
||||
attemptCount: 1,
|
||||
suppressAnnounce: false
|
||||
))
|
||||
}
|
||||
|
||||
@Test("rapid subscription attempts eventually suppress announces")
|
||||
func rapidAttemptsSuppressAnnouncesAtThreshold() {
|
||||
var limiter = BLESubscriptionAnnounceLimiter()
|
||||
let centralID = "central-a"
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
|
||||
#expect(limiter.decision(for: centralID, now: now) == .allowed)
|
||||
|
||||
var decision = BLESubscriptionAnnounceDecision.allowed
|
||||
for attempt in 2...TransportConfig.bleSubscriptionRateLimitMaxAttempts {
|
||||
decision = limiter.decision(
|
||||
for: centralID,
|
||||
now: now.addingTimeInterval(Double(attempt) * 0.01)
|
||||
)
|
||||
}
|
||||
|
||||
if case let .rateLimited(_, _, suppressAnnounce) = decision {
|
||||
#expect(suppressAnnounce)
|
||||
} else {
|
||||
Issue.record("Expected rate-limited decision at suppression threshold")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("stale limiter entries are pruned on the next decision")
|
||||
func staleEntriesArePruned() {
|
||||
var limiter = BLESubscriptionAnnounceLimiter()
|
||||
let staleCentralID = "central-a"
|
||||
let freshCentralID = "central-b"
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
|
||||
#expect(limiter.decision(for: staleCentralID, now: now) == .allowed)
|
||||
#expect(limiter.trackedCentralCount == 1)
|
||||
|
||||
let afterWindow = now.addingTimeInterval(TransportConfig.bleSubscriptionRateLimitWindowSeconds + 1)
|
||||
#expect(limiter.decision(for: freshCentralID, now: afterWindow) == .allowed)
|
||||
#expect(limiter.trackedCentralCount == 1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user