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.
This commit is contained in:
Yaojia Wang
2026-07-18 13:32:05 +02:00
parent 9a5909f672
commit 07bcbf0c08
19 changed files with 1549 additions and 32 deletions

View File

@@ -0,0 +1,142 @@
import ClientTLS
import Foundation
import Testing
@testable import WebTerm
/// B3 · EnrollmentViewModel the zero-`.p12` enrollment flow's state machine.
/// The enroll operation is injected as a closure (the flow logic itself is
/// covered headlessly in ClientTLS's DeviceEnrollmentFlowTests); these cover the
/// VM's boundary validation, success bookkeeping, and errorcopy mapping
/// including that the password never lingers after an attempt.
@MainActor
@Suite("EnrollmentViewModel")
struct EnrollmentViewModelTests {
/// Records enroll invocations + replays a scripted result/error.
private actor EnrollScript {
private(set) var calls: [(password: String, subdomain: String, deviceName: String, url: URL)] = []
private let result: Result<ClientCertificateSummary?, any Error>
init(result: Result<ClientCertificateSummary?, any Error>) {
self.result = result
}
func run(_ p: String, _ s: String, _ d: String, _ u: URL) throws -> ClientCertificateSummary? {
calls.append((p, s, d, u))
return try result.get()
}
var callCount: Int { calls.count }
var lastPassword: String? { calls.last?.password }
}
/// Records persisted settings so a test can assert them without real defaults.
private final class PersistSpy: @unchecked Sendable {
private let lock = NSLock()
private(set) var url: String?
private(set) var subdomain: String?
func persist(_ url: String, _ subdomain: String) {
lock.withLock { self.url = url; self.subdomain = subdomain }
}
}
private func makeVM(
script: EnrollScript,
persist: PersistSpy = PersistSpy(),
url: String = "https://cp.terminal.yaojia.wang",
subdomain: String = "alice",
summary: ClientCertificateSummary? = nil
) -> EnrollmentViewModel {
EnrollmentViewModel(
controlPlaneURLText: url,
subdomain: subdomain,
deviceName: "Test iPhone",
enrollOperation: { p, s, d, u in try await script.run(p, s, d, u) },
loadSummary: { summary },
persistSettings: { persist.persist($0, $1) }
)
}
@Test("a successful enroll sets the summary, flags success, clears the password, persists settings")
func successPath() async {
let sum = ClientCertificateSummary(
subjectCommonName: "Test iPhone", issuerCommonName: "webterm-device-ca", notAfter: nil
)
let script = EnrollScript(result: .success(sum))
let persist = PersistSpy()
let vm = makeVM(script: script, persist: persist, summary: sum)
vm.password = "operator-secret"
await vm.enroll()
#expect(await script.callCount == 1)
#expect(vm.didSucceed == true)
#expect(vm.errorMessage == nil)
#expect(vm.password == "") // never lingers
#expect(vm.summary?.subjectCommonName == "Test iPhone")
#expect(persist.url == "https://cp.terminal.yaojia.wang")
#expect(persist.subdomain == "alice")
}
@Test("a non-https control-plane URL is rejected before any network")
func rejectsInsecureURL() async {
let script = EnrollScript(result: .success(nil))
let vm = makeVM(script: script, url: "http://cp.terminal.yaojia.wang")
vm.password = "pw"
await vm.enroll()
#expect(await script.callCount == 0) // zero network
#expect(vm.errorMessage == EnrollmentCopy.errInvalidURL)
#expect(vm.didSucceed == false)
}
@Test("a 403 subdomain-not-owned maps to actionable copy; password still cleared")
func mapsSubdomainNotOwned() async {
let script = EnrollScript(
result: .failure(DeviceEnrollmentError.http(status: 403, code: "rejected"))
)
let vm = makeVM(script: script)
vm.password = "operator-secret"
await vm.enroll()
#expect(vm.errorMessage == EnrollmentCopy.errSubdomainNotOwned)
#expect(vm.didSucceed == false)
#expect(vm.password == "") // cleared even on failure
}
@Test("a 401 login maps to the bad-credential copy")
func mapsBadCredential() async {
let script = EnrollScript(
result: .failure(ControlPlaneLoginError.http(status: 401, code: "login rejected"))
)
let vm = makeVM(script: script)
vm.password = "wrong"
await vm.enroll()
#expect(vm.errorMessage == EnrollmentCopy.errBadCredential)
}
@Test("Secure Enclave unavailable (simulator) maps to a clear message")
func mapsNoSecureEnclave() async {
let script = EnrollScript(
result: .failure(SecureEnclaveKeyError.secureEnclaveUnavailable("no entitlement"))
)
let vm = makeVM(script: script)
vm.password = "operator-secret"
await vm.enroll()
#expect(vm.errorMessage == EnrollmentCopy.errNoSecureEnclave)
}
@Test("canEnroll requires every field including a non-empty password")
func canEnrollGating() {
let script = EnrollScript(result: .success(nil))
let vm = makeVM(script: script)
#expect(vm.canEnroll == false) // password empty
vm.password = "pw"
#expect(vm.canEnroll == true)
vm.subdomain = " "
#expect(vm.canEnroll == false) // whitespace-only subdomain
}
}