bitchat/bitchatTests/Services/BLELinkAuthStateTests.swift
jack a0b7985cbe
Link layer slice 2: cohere link-auth state and split bindings from the physical store (#1540)
* Cohere per-link Noise auth and rebind containment into BLELinkAuthState

The authenticated-link owners, the reconnect revalidation policy, and
the two rebind-containment cooldowns were four loose bleQueue-owned
maps whose invariants lived in call-site discipline: every teardown
path had to remember to retire the proof AND close the revalidation
epoch (the pair appeared seven times), and both cooldowns hand-rolled
the same prune-check-record dance. BLELinkAuthState owns them as whole
transitions — retireLink, retireLinks(ownedBy:), permitRebind,
permitRedundantRetirement — with the ownership question (bleQueue
today, engine after the option-B flip) answered in one place.

No behavior change; the one call-site reordering (redundant retirement
computes the survivor before the cooldown check instead of after) is
outcome-equivalent since the cooldown only ever recorded when a
survivor existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split identity-link bindings out of the physical link store

BLELinkStateStore owned two different kinds of truth: what physical
links exist (CB handles, connect lifecycles, characteristics, stream
assemblers) and who each link belongs to (peer bindings in both roles
plus the preferred-peripheral reverse map for directed sends and fanout
collapse). The bindings now live on BLELinkBindings — same bleQueue
ownership, whole-transition methods, direct tests for the rotation
reverse-map cleanup and the preferred-link survivor repair that were
previously only exercised end to end. Composed operations that need
both truths (remove-with-repair, direct link state, the subscribed-
central snapshot, bind-only-live-links) live on the transport as
explicitly bleQueue-confined helpers.

This is the structural half of the option-B boundary flip
(docs/BLE-ARCHITECTURE-V3.md): ownership of the bindings can now move
to the engine without touching what-links-exist. An audit of every
physical clear/remove found three sites (emergency clear, both
unauthorized branches) that needed explicit binding-clear pairing under
the split — each now clears both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix iOS-gated constructors and preserve containment cooldowns on reset

CI caught what the macOS SwiftPM build cannot see: two #if os(iOS)
sites still passed the peerID field that slice B1 removed from
BLEPeripheralLinkState (willRestoreState in BLEService and
armPendingBackgroundConnects in BLERadioController). Both fixed and
verified with a local iOS simulator xcodebuild.

Codex also caught a real regression: BLELinkAuthState.removeAll()
cleared the rebind/retirement cooldown maps, which the original panic
and emergency reset paths deliberately left alive. A stable
CoreBluetooth UUID must not earn a fresh rebind allowance just because
the session state around it was wiped. removeAll() now clears only the
proofs and revalidation epochs, and BLELinkAuthStateTests pins the
survival invariant along with the other auth-state transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 19:19:13 +01:00

76 lines
3.2 KiB
Swift

import BitFoundation
import Foundation
import Testing
@testable import bitchat
struct BLELinkAuthStateTests {
private let peerID = PeerID(str: "1122334455667788")
private let link = BLEIngressLinkID.peripheral("periph-a")
@Test
func authenticationBindsToTheExactLinkAndOwner() {
var auth = BLELinkAuthState()
auth.markAuthenticated(link, owner: peerID)
#expect(auth.isAuthenticated(link, for: peerID))
#expect(!auth.isAuthenticated(link, for: PeerID(str: "8899aabbccddeeff")))
#expect(!auth.isAuthenticated(.peripheral("periph-b"), for: peerID))
auth.retireLink(link)
#expect(!auth.isAuthenticated(link, for: peerID))
}
@Test
func retireLinksOwnedByPeerReturnsAndRetiresThemAll() {
var auth = BLELinkAuthState()
auth.markAuthenticated(.peripheral("periph-a"), owner: peerID)
auth.markAuthenticated(.central("central-a"), owner: peerID)
auth.markAuthenticated(.central("central-b"), owner: PeerID(str: "8899aabbccddeeff"))
let departed = Set(auth.retireLinks(ownedBy: peerID))
#expect(departed == [.peripheral("periph-a"), .central("central-a")])
#expect(auth.links(ownedBy: peerID).isEmpty)
#expect(auth.isAuthenticated(.central("central-b"), for: PeerID(str: "8899aabbccddeeff")))
}
@Test
func rebindCooldownPermitsOncePerWindowAndAgesOut() {
var auth = BLELinkAuthState()
let start = Date(timeIntervalSince1970: 1_000)
let first = auth.permitRebind(linkUUID: "periph-a", now: start, cooldown: 30)
#expect(first)
let withinWindow = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(10), cooldown: 30)
#expect(!withinWindow)
// A different link has its own allowance.
let otherLink = auth.permitRebind(linkUUID: "periph-b", now: start.addingTimeInterval(10), cooldown: 30)
#expect(otherLink)
// The window ages out.
let afterWindow = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(31), cooldown: 30)
#expect(afterWindow)
}
@Test
func containmentCooldownsSurviveASessionReset() {
var auth = BLELinkAuthState()
let start = Date(timeIntervalSince1970: 2_000)
auth.markAuthenticated(link, owner: peerID)
let rebindBefore = auth.permitRebind(linkUUID: "periph-a", now: start, cooldown: 30)
let retirementBefore = auth.permitRedundantRetirement(peerID: peerID, now: start, cooldown: 30)
#expect(rebindBefore)
#expect(retirementBefore)
// Panic/emergency resets wipe proofs and epochs but a stable
// CoreBluetooth UUID must not earn a fresh rebind or retirement
// allowance just because the session state around it was wiped.
auth.removeAll()
#expect(!auth.isAuthenticated(link, for: peerID))
let rebindAfterReset = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(5), cooldown: 30)
let retirementAfterReset = auth.permitRedundantRetirement(peerID: peerID, now: start.addingTimeInterval(5), cooldown: 30)
#expect(!rebindAfterReset)
#expect(!retirementAfterReset)
}
}