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:
142
ios/App/WebTermTests/EnrollmentViewModelTests.swift
Normal file
142
ios/App/WebTermTests/EnrollmentViewModelTests.swift
Normal 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 error→copy 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user