Files
web-terminal/ios/Packages/ClientTLS/Tests/ClientTLSTests/CertificateRotationSchedulerTests.swift
Yaojia Wang 07bcbf0c08 feat(ios): device enrollment flow + silent cert rotation (B3)
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.
2026-07-18 13:32:05 +02:00

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)
}