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.
143 lines
5.3 KiB
Swift
143 lines
5.3 KiB
Swift
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
|
|
}
|
|
}
|