Wire the SecureEnclave enroll library into a real flow (login->bearer->CSR->
/device/enroll->keychain identity), presented on the existing mTLS path; add a
rotation scheduler. Atomic keychain replace (add-before-delete); renew body is
{csr}-only; renewal-failing surfaced in the UI. ClientTLS 48 tests pass.
108 lines
3.7 KiB
Swift
108 lines
3.7 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import ClientTLS
|
|
|
|
// B3 · The silent rotation scheduler decides — from the installed identity's
|
|
// rotation timing — whether to renew before expiry, then drives an injected
|
|
// mTLS renew. Pure and transport-free: fully unit-testable with fakes.
|
|
|
|
private let t0 = ISO8601DateFormatter().date(from: "2026-09-05T00:00:00Z")!
|
|
private let renewAfter = ISO8601DateFormatter().date(from: "2026-09-05T00:00:00Z")!
|
|
private let notAfter = ISO8601DateFormatter().date(from: "2026-10-06T00:00:00Z")!
|
|
private let before = ISO8601DateFormatter().date(from: "2026-09-04T00:00:00Z")!
|
|
private let after = ISO8601DateFormatter().date(from: "2026-09-06T00:00:00Z")!
|
|
|
|
private func state(renewAfter: Date? = renewAfter) -> DeviceRenewalState {
|
|
DeviceRenewalState(deviceId: "dev-1", notAfter: notAfter, renewAfter: renewAfter)
|
|
}
|
|
|
|
/// Records how many times the renew operation ran. `@unchecked Sendable`: guarded
|
|
/// by a lock.
|
|
private final class RenewSpy: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var _count = 0
|
|
var count: Int { lock.withLock { _count } }
|
|
func bump() { lock.withLock { _count += 1 } }
|
|
}
|
|
|
|
@Test("no enrolled identity → notEnrolled, and renew never runs")
|
|
func schedulerNotEnrolled() async {
|
|
let spy = RenewSpy()
|
|
let scheduler = CertificateRotationScheduler(
|
|
renewalState: { nil },
|
|
performRenew: { spy.bump(); return nil },
|
|
now: { after }
|
|
)
|
|
let outcome = await scheduler.runIfDue()
|
|
#expect(outcome == .notEnrolled)
|
|
#expect(spy.count == 0)
|
|
}
|
|
|
|
@Test("cert not yet due → notDue, and renew never runs")
|
|
func schedulerNotDue() async {
|
|
let spy = RenewSpy()
|
|
let scheduler = CertificateRotationScheduler(
|
|
renewalState: { state() },
|
|
performRenew: { spy.bump(); return nil },
|
|
now: { before }
|
|
)
|
|
let outcome = await scheduler.runIfDue()
|
|
#expect(outcome == .notDue(renewAfter: renewAfter))
|
|
#expect(spy.count == 0)
|
|
}
|
|
|
|
@Test("past renewAfter → renews exactly once")
|
|
func schedulerRenews() async {
|
|
let spy = RenewSpy()
|
|
let scheduler = CertificateRotationScheduler(
|
|
renewalState: { state() },
|
|
performRenew: { spy.bump(); return nil },
|
|
now: { after }
|
|
)
|
|
let outcome = await scheduler.runIfDue()
|
|
#expect(outcome == .renewed)
|
|
#expect(spy.count == 1)
|
|
}
|
|
|
|
@Test("due exactly at renewAfter (>= boundary) renews")
|
|
func schedulerRenewsAtBoundary() async {
|
|
let scheduler = CertificateRotationScheduler(
|
|
renewalState: { state() },
|
|
performRenew: { nil },
|
|
now: { renewAfter } // now == renewAfter
|
|
)
|
|
#expect(await scheduler.runIfDue() == .renewed)
|
|
}
|
|
|
|
@Test("a missing renewAfter never triggers (fail-safe — TLS stack is the gate)")
|
|
func schedulerMissingRenewAfterNeverDue() async {
|
|
let scheduler = CertificateRotationScheduler(
|
|
renewalState: { state(renewAfter: nil) },
|
|
performRenew: { nil },
|
|
now: { after }
|
|
)
|
|
#expect(await scheduler.runIfDue() == .notDue(renewAfter: nil))
|
|
}
|
|
|
|
@Test("a renew that throws is surfaced as .failed (never crashes, cert still valid)")
|
|
func schedulerRenewFailure() async {
|
|
struct Boom: Error {}
|
|
let scheduler = CertificateRotationScheduler(
|
|
renewalState: { state() },
|
|
performRenew: { throw Boom() },
|
|
now: { after }
|
|
)
|
|
#expect(await scheduler.runIfDue() == .failed)
|
|
}
|
|
|
|
@Test("a keychain read fault is surfaced as .failed, not a crash")
|
|
func schedulerReadFailure() async {
|
|
struct Boom: Error {}
|
|
let scheduler = CertificateRotationScheduler(
|
|
renewalState: { throw Boom() },
|
|
performRenew: { nil },
|
|
now: { after }
|
|
)
|
|
#expect(await scheduler.runIfDue() == .failed)
|
|
}
|