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,275 @@
import ClientTLS
import Foundation
import Observation
import SwiftUI
import UIKit
import WireProtocol
/// B3 · Zero-`.p12` device-enrollment screen the phone half of zero-touch.
///
/// Flow: one operator login a short-lived `device:enroll` bearer a
/// NON-EXPORTABLE Secure-Enclave key + PKCS#10 CSR `POST /device/enroll`
/// the returned leaf is stored against the SE key and presents AUTOMATICALLY on
/// the existing mTLS path (no manual certificate import, ever). After this one
/// login the cert renews itself silently (`CertificateRotationScheduler`).
///
/// Pure presentation over `EnrollmentViewModel`; validation, the flow, and error
/// mapping live in the VM. Presented from the host-menu ("").
struct EnrollmentScreen: View {
@State private var model: EnrollmentViewModel
init(model: EnrollmentViewModel) {
_model = State(initialValue: model)
}
var body: some View {
Form {
installedSection
enrollSection
}
.navigationTitle(EnrollmentCopy.title)
.task { model.refresh() }
}
// MARK: - Sections
@ViewBuilder private var installedSection: some View {
Section(EnrollmentCopy.installedHeader) {
if let summary = model.summary {
LabeledContent(EnrollmentCopy.subject, value: summary.subjectCommonName ?? "")
LabeledContent(EnrollmentCopy.issuer, value: summary.issuerCommonName ?? "")
LabeledContent(EnrollmentCopy.expiry, value: model.expiryText(summary))
if summary.isExpired() {
Label(EnrollmentCopy.expiredWarning, systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
} else {
Text(EnrollmentCopy.noneInstalled).foregroundStyle(.secondary)
}
}
}
@ViewBuilder private var enrollSection: some View {
Section {
TextField(EnrollmentCopy.controlPlaneURL, text: $model.controlPlaneURLText)
.textContentType(.URL)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField(EnrollmentCopy.subdomain, text: $model.subdomain)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField(EnrollmentCopy.deviceName, text: $model.deviceName)
SecureField(EnrollmentCopy.password, text: $model.password)
.textContentType(.password)
.autocorrectionDisabled()
Button {
Task { await model.enroll() }
} label: {
if model.isEnrolling {
ProgressView()
} else {
Label(
model.summary == nil
? EnrollmentCopy.enrollAction
: EnrollmentCopy.rotateAction,
systemImage: "checkmark.shield"
)
}
}
.disabled(!model.canEnroll)
.accessibilityIdentifier("enroll.submit")
if let error = model.errorMessage {
Text(error).foregroundStyle(.red).font(.footnote)
}
if model.didSucceed {
Label(EnrollmentCopy.success, systemImage: "checkmark.circle.fill")
.foregroundStyle(.green).font(.footnote)
}
} header: {
Text(model.summary == nil ? EnrollmentCopy.enrollHeader : EnrollmentCopy.rotateHeader)
} footer: {
Text(EnrollmentCopy.footer)
}
}
}
/// B3 · Enrollment state machine. Validates inputs at the boundary, drives
/// `DeviceEnrollmentFlow`, and maps every failure to actionable copy never
/// swallowed, never leaking the password or bearer.
@MainActor
@Observable
final class EnrollmentViewModel {
/// The enroll operation: login SE keygen+CSR enroll store. Injected so
/// the VM is unit-testable without a keychain / network (production wires it
/// to `DeviceEnrollmentFlow`).
typealias EnrollOperation = @Sendable (
_ password: String, _ subdomain: String, _ deviceName: String, _ controlPlaneURL: URL
) async throws -> ClientCertificateSummary?
var controlPlaneURLText: String
var subdomain: String
var deviceName: String
var password = ""
private(set) var summary: ClientCertificateSummary?
private(set) var isEnrolling = false
private(set) var didSucceed = false
private(set) var errorMessage: String?
@ObservationIgnored private let enrollOperation: EnrollOperation
@ObservationIgnored private let loadSummary: @Sendable () -> ClientCertificateSummary?
@ObservationIgnored private let persistSettings: @Sendable (_ url: String, _ subdomain: String) -> Void
init(
controlPlaneURLText: String = EnrollmentSettings.controlPlaneURL ?? EnrollmentCopy.defaultControlPlaneURL,
subdomain: String = EnrollmentSettings.subdomain ?? "",
deviceName: String = UIDevice.current.name,
enrollOperation: @escaping EnrollOperation,
loadSummary: @escaping @Sendable () -> ClientCertificateSummary?,
persistSettings: @escaping @Sendable (_ url: String, _ subdomain: String) -> Void = { url, sub in
EnrollmentSettings.controlPlaneURL = url
EnrollmentSettings.subdomain = sub
}
) {
self.controlPlaneURLText = controlPlaneURLText
self.subdomain = subdomain
self.deviceName = deviceName
self.enrollOperation = enrollOperation
self.loadSummary = loadSummary
self.persistSettings = persistSettings
}
/// Fields all present the button is enabled (deeper URL validation on tap).
var canEnroll: Bool {
!isEnrolling
&& !controlPlaneURLText.trimmed.isEmpty
&& !subdomain.trimmed.isEmpty
&& !deviceName.trimmed.isEmpty
&& !password.isEmpty
}
func refresh() {
summary = loadSummary()
}
func expiryText(_ summary: ClientCertificateSummary) -> String {
guard let notAfter = summary.notAfter else { return "" }
return notAfter.formatted(date: .abbreviated, time: .omitted)
}
/// Run enrollment. Validates the control-plane URL (must be https with a
/// host) before any network, then drives the flow. The password is cleared
/// after every attempt (success OR failure) so it never lingers in memory.
func enroll() async {
errorMessage = nil
didSucceed = false
let trimmedURL = controlPlaneURLText.trimmed
let trimmedSubdomain = subdomain.trimmed
let trimmedName = deviceName.trimmed
guard let url = URL(string: trimmedURL),
url.scheme?.lowercased() == "https",
let host = url.host, !host.isEmpty else {
errorMessage = EnrollmentCopy.errInvalidURL
return
}
guard !trimmedSubdomain.isEmpty, !trimmedName.isEmpty, !password.isEmpty else {
errorMessage = EnrollmentCopy.errMissingFields
return
}
isEnrolling = true
defer { isEnrolling = false }
let secret = password
do {
summary = try await enrollOperation(secret, trimmedSubdomain, trimmedName, url)
persistSettings(trimmedURL, trimmedSubdomain)
password = "" // never linger after success
didSucceed = true
refresh()
} catch {
password = "" // never linger after failure either
errorMessage = Self.copy(for: error)
}
}
/// Map a login/enroll/keychain error to enrollment-UX copy (never swallowed,
/// never containing the password/token).
private static func copy(for error: any Error) -> String {
switch error {
case ControlPlaneLoginError.emptyPassword:
return EnrollmentCopy.errEmptyPassword
case ControlPlaneLoginError.http(let status, _) where status == 401:
return EnrollmentCopy.errBadCredential
case ControlPlaneLoginError.http:
return EnrollmentCopy.errLoginFailed
case ControlPlaneLoginError.malformedResponse:
return EnrollmentCopy.errServer
case DeviceEnrollmentError.http(let status, _) where status == 403:
return EnrollmentCopy.errSubdomainNotOwned
case DeviceEnrollmentError.http(let status, _) where status == 429:
return EnrollmentCopy.errRateLimited
case DeviceEnrollmentError.http(let status, _) where status == 400:
return EnrollmentCopy.errRejected
case DeviceEnrollmentError.http:
return EnrollmentCopy.errEnrollFailed
case DeviceEnrollmentError.malformedResponse:
return EnrollmentCopy.errServer
case SecureEnclaveKeyError.secureEnclaveUnavailable:
return EnrollmentCopy.errNoSecureEnclave
case is SecureEnclaveKeyError:
return EnrollmentCopy.errKeygen
case ClientIdentityStoreError.keychain, ClientIdentityStoreError.corruptStoredBlob:
return EnrollmentCopy.errKeychain
case DeviceEnrollmentWiringError.missingControlPlaneURL,
DeviceEnrollmentWiringError.invalidControlPlaneURL:
return EnrollmentCopy.errInvalidURL
default:
return EnrollmentCopy.errUnknown
}
}
}
private extension String {
var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }
}
/// User-facing copy (Chinese, matching the rest of the app).
enum EnrollmentCopy {
static let defaultControlPlaneURL = "https://cp.terminal.yaojia.wang"
static let title = "自动获取证书"
static let installedHeader = "已安装证书"
static let subject = "设备 (CN)"
static let issuer = "签发 CA"
static let expiry = "有效期至"
static let expiredWarning = "证书已过期,请重新注册。"
static let noneInstalled = "尚未安装设备证书。"
static let enrollHeader = "注册本设备"
static let rotateHeader = "重新注册"
static let controlPlaneURL = "控制面地址"
static let subdomain = "子域名 (你拥有的隧道名)"
static let deviceName = "设备名称"
static let password = "操作口令"
static let enrollAction = "注册本设备"
static let rotateAction = "重新注册"
static let success = "已注册,证书已保存到本设备安全区。"
static let footer =
"首次登录一次即可:本设备在安全区 (Secure Enclave) 生成不可导出的私钥,向控制面申请证书并自动保存;之后连接隧道主机时自动出示,证书到期前会自动续期,无需再手动导入 .p12。"
static let errInvalidURL = "控制面地址无效,请输入 https:// 开头的完整地址。"
static let errMissingFields = "请填写子域名、设备名称和操作口令。"
static let errEmptyPassword = "请输入操作口令。"
static let errBadCredential = "操作口令错误,请重试。"
static let errLoginFailed = "登录失败,请稍后重试。"
static let errSubdomainNotOwned = "该账号未拥有此子域名,无法签发证书。"
static let errRateLimited = "请求过于频繁,请稍后再试。"
static let errRejected = "请求被拒绝:子域名或证书请求无效。"
static let errEnrollFailed = "注册失败,请稍后重试。"
static let errNoSecureEnclave = "本设备不支持安全区 (Secure Enclave)(模拟器?),无法生成硬件密钥。"
static let errKeygen = "生成硬件密钥失败,请重试。"
static let errKeychain = "保存到钥匙串失败,请重试。"
static let errServer = "服务器返回异常,请稍后重试。"
static let errUnknown = "注册失败,请重试。"
}

View File

@@ -29,6 +29,11 @@ struct SessionListScreen: View {
/// settings-like surface and is present in every empty state, so this is the
/// nav idiom that makes the orphaned screen reachable.
var onDeviceCert: () -> Void = {}
/// B3 · "" entry presents `EnrollmentScreen` (the zero-`.p12`
/// enrollment flow: login Secure-Enclave key + CSR device cert). Same
/// host-menu surface as `onDeviceCert`; that screen imports a `.p12`, this
/// one obtains a hardware-bound cert with no file at all.
var onEnroll: () -> Void = {}
/// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one
/// render-concurrency gate across ALL rows (scrolling must never spawn
/// unbounded offscreen terminals). `@State` keeps it stable across body
@@ -219,6 +224,12 @@ struct SessionListScreen: View {
} label: {
Label(ScreenCopy.addHost, systemImage: "plus")
}
Button {
onEnroll()
} label: {
Label(ScreenCopy.enroll, systemImage: "checkmark.shield")
}
.accessibilityIdentifier("sessions.enroll")
Button {
onDeviceCert()
} label: {
@@ -355,6 +366,7 @@ private enum ScreenCopy {
static let kill = "结束"
static let addHost = "配对新主机"
static let deviceCert = "设备证书"
static let enroll = "自动获取证书"
static let hostMenuFallback = "主机"
static let notPairedTitle = "还没有配对的主机"
static let notPairedHint = "先配对你电脑上的 web-terminal扫码或手输地址会话会出现在这里。"

View File

@@ -54,6 +54,12 @@ struct AdaptiveRootView: View {
.sheet(isPresented: $coordinator.isDeviceCertPresented) {
deviceCertSheet
}
.sheet(
isPresented: $coordinator.isEnrollmentPresented,
onDismiss: { coordinator.enrollmentDismissed() }
) {
enrollmentSheet
}
}
// MARK: - Layout branch (the SOLE size-class consumer)
@@ -126,4 +132,21 @@ struct AdaptiveRootView: View {
}
}
}
// MARK: - Device enrollment sheet (B3, zero-.p12 auto-cert)
/// NavigationStack + VM coordinator
/// `presentEnrollment` `enrollmentDismissed` VM
@ViewBuilder private var enrollmentSheet: some View {
if let viewModel = coordinator.enrollmentViewModel {
NavigationStack {
EnrollmentScreen(model: viewModel)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(RootCopy.done) { coordinator.isEnrollmentPresented = false }
}
}
}
}
}
}

View File

@@ -1,3 +1,4 @@
import ClientTLS
import Foundation
import HostRegistry
import Observation
@@ -36,6 +37,20 @@ final class AppCoordinator {
/// its own `ClientCertViewModel` over the keychain store; dismissal needs no
/// refresh because the transports resolve the identity lazily per connection.
var isDeviceCertPresented = false
/// B3 · "" sheet the zero-`.p12` enrollment flow
/// (`EnrollmentScreen`): one login Secure-Enclave key + CSR device cert,
/// presented automatically on the existing mTLS path. VM built per entry.
var isEnrollmentPresented = false
private(set) var enrollmentViewModel: EnrollmentViewModel?
/// B3 · Re-entrancy guard so overlapping foregrounds never launch two silent
/// renews at once (a shared PTY-style single-flight for the rotation pass).
@ObservationIgnored private var isRotating = false
/// B3 (HIGH observability fix) · Persistent, OBSERVABLE flag: the last silent
/// rotation pass ended in `.failed` (keychain read fault or renew error). Set
/// alongside the os.Logger line so a silently-failing renewal surfaces to the
/// UI (a warning banner) instead of vanishing into the log. Cleared by the
/// next non-failed pass a recovered renewal drops the warning automatically.
private(set) var isCertificateRenewalFailing = false
let sessionList: SessionListViewModel
@ObservationIgnored let environment: AppEnvironment
@@ -69,6 +84,7 @@ final class AppCoordinator {
if route == .pairing {
rootPairingViewModel = makePairingViewModel()
}
runCertificateRotationIfDue() // B3 · renew a due device cert on cold launch
await deepLink.markReady() // flush a cold-launch deep link (T-iOS-22)
}
@@ -120,6 +136,50 @@ final class AppCoordinator {
isDeviceCertPresented = true
}
// MARK: - Device enrollment (B3, zero-.p12 auto-cert)
/// Toolbar host-menu sheetVM
func presentEnrollment() {
enrollmentViewModel = makeEnrollmentViewModel()
isEnrollmentPresented = true
}
/// Sheet VM
func enrollmentDismissed() {
enrollmentViewModel = nil
runCertificateRotationIfDue()
}
/// VM +CSR `/device/enroll`
/// `DeviceEnrollmentFlow` ClientTLS /
private func makeEnrollmentViewModel() -> EnrollmentViewModel {
let http = environment.http
return EnrollmentViewModel(
enrollOperation: { password, subdomain, deviceName, url in
try await makeDeviceEnrollmentFlow(controlPlaneURL: url, http: http)
.run(password: password, subdomain: subdomain, deviceName: deviceName)
},
loadSummary: { (try? KeychainClientIdentityStore().loadSummary()) ?? nil }
)
}
/// B3 · Silent rotation: check the installed leaf's timing and, if the renew
/// window has opened, renew over mTLS against the SAME Secure-Enclave key.
/// Fire-and-forget on launch + every foreground; single-flight via
/// `isRotating`. A `.notEnrolled` / `.notDue` pass is cheap and common.
func runCertificateRotationIfDue() {
guard !isRotating else { return }
isRotating = true
let scheduler = makeCertificateRotationScheduler(http: environment.http)
Task { [weak self] in
let outcome = await scheduler.runIfDue()
self?.isRotating = false
// Surface a silently-failing renewal as observable state (not only a
// log line); a later successful/not-due pass clears it.
self?.isCertificateRenewalFailing = (outcome == .failed)
}
}
/// "" sheet fresh spawn`attach(null, cwd)`+
/// attach `claude\r` engine attach-first
func openProject(_ request: ProjectOpenRequest) {
@@ -290,6 +350,7 @@ final class AppCoordinator {
terminalController?.suspend()
case .active:
terminalController?.resumeIfNeeded()
runCertificateRotationIfDue() // B3 · check for a due renewal each foreground
case .inactive:
break // transient; shade covers it at the view layer
@unknown default:

View File

@@ -0,0 +1,90 @@
import ClientTLS
import Foundation
import WireProtocol
/// B3 · Production wiring that connects the ClientTLS enrollment library to the
/// app's real transports. Kept in the assembly layer (not the leaf package) so
/// `ClientTLS` stays dependency-free and unit-testable; here we only adapt types
/// and read persisted config no crypto, no keychain of our own.
/// Adapts the app's `HTTPTransport` (WireProtocol) to `ClientTLS`'s
/// `EnrollmentTransport`. The two protocols have an identical `send` shape; this
/// one-line bridge lets the SAME `URLSessionHTTPTransport` which already
/// presents the device identity on a client-cert challenge (mTLS) serve login,
/// enroll AND the silent renew, so renew authenticates with the current cert.
struct EnrollmentTransportAdapter: EnrollmentTransport {
private let http: any HTTPTransport
init(http: any HTTPTransport) {
self.http = http
}
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
try await http.send(request)
}
}
/// B3 · Non-secret enrollment config persisted for the silent rotation scheduler
/// (the control-plane URL to renew against) and to prefill the re-enroll form.
/// Namespaced statics over `UserDefaults.standard` nothing secret is stored
/// here (the bearer is never persisted; the private key stays in the Secure
/// Enclave; the leaf/chain live in the keychain via `KeychainClientIdentityStore`).
enum EnrollmentSettings {
private static let controlPlaneURLKey = "webterm.enroll.controlPlaneURL"
private static let subdomainKey = "webterm.enroll.subdomain"
static var controlPlaneURL: String? {
get { UserDefaults.standard.string(forKey: controlPlaneURLKey) }
set { UserDefaults.standard.set(newValue, forKey: controlPlaneURLKey) }
}
static var subdomain: String? {
get { UserDefaults.standard.string(forKey: subdomainKey) }
set { UserDefaults.standard.set(newValue, forKey: subdomainKey) }
}
}
enum DeviceEnrollmentWiringError: Error, Equatable, Sendable {
/// The control-plane URL is missing/invalid cannot build a renew client.
case missingControlPlaneURL
/// The typed control-plane URL is not a valid absolute URL.
case invalidControlPlaneURL
}
/// Build the enrollment flow (login SE keygen+CSR enroll store) against the
/// given control-plane URL, reusing the app's mTLS-capable transport.
func makeDeviceEnrollmentFlow(
controlPlaneURL: URL,
http: any HTTPTransport,
store: KeychainClientIdentityStore = KeychainClientIdentityStore()
) -> DeviceEnrollmentFlow {
DeviceEnrollmentFlow(
baseURL: controlPlaneURL,
transport: EnrollmentTransportAdapter(http: http),
store: store
)
}
/// Build the silent rotation scheduler. `renewalState` reads the installed leaf's
/// timing; `performRenew` re-CSRs from the SAME Secure-Enclave key and POSTs
/// `/device/:id/renew` over mTLS (nil bearer the current cert authenticates).
/// The control-plane URL is read fresh from `EnrollmentSettings` at renew time.
func makeCertificateRotationScheduler(
http: any HTTPTransport,
store: KeychainClientIdentityStore = KeychainClientIdentityStore()
) -> CertificateRotationScheduler {
let transport = EnrollmentTransportAdapter(http: http)
return CertificateRotationScheduler(
renewalState: { try store.renewalState() },
performRenew: {
guard let urlString = EnrollmentSettings.controlPlaneURL,
let url = URL(string: urlString) else {
throw DeviceEnrollmentWiringError.missingControlPlaneURL
}
// nil bearer: /device/:id/renew authenticates by the presented
// client cert (mTLS), which the adapter's transport supplies.
let client = DeviceEnrollmentClient(baseURL: url, bearerToken: nil, transport: transport)
return try await store.renew(using: client)
}
)
}

View File

@@ -75,9 +75,13 @@ struct StackRootView: View {
viewModel: coordinator.sessionList,
onOpen: { coordinator.open($0) },
onAddHost: { coordinator.presentAddHost() },
onDeviceCert: { coordinator.presentDeviceCert() }
onDeviceCert: { coordinator.presentDeviceCert() },
onEnroll: { coordinator.presentEnrollment() }
)
.safeAreaInset(edge: .bottom) { continueLastBanner }
// B3 (HIGH) · A silently-failing device-cert renewal is surfaced here
// (top inset), so it is observable instead of buried in os.Logger.
.safeAreaInset(edge: .top) { certRenewalWarningBanner }
// / DS reduceMotion
.animation(
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
@@ -98,6 +102,15 @@ struct StackRootView: View {
}
}
// MARK: - Device-cert renewal warning (B3 HIGH observability fix)
@ViewBuilder private var certRenewalWarningBanner: some View {
if coordinator.isCertificateRenewalFailing {
CertRenewalWarningBanner()
.transition(.move(edge: .top).combined(with: .opacity))
}
}
// MARK: - Terminal push
private var terminalBinding: Binding<Bool> {
@@ -155,6 +168,32 @@ struct ContinueLastBanner: View {
}
}
// MARK: - Device-cert renewal warning (B3 HIGH observability fix)
/// A quiet, non-blocking warning that the silent device-certificate renewal is
/// failing surfaced from `AppCoordinator.isCertificateRenewalFailing` so a
/// failing renewal is observable in the UI instead of only in os.Logger. Amber
/// (the `waiting`/needs-me status color) + an SF Symbol so meaning is never
/// carried by color alone. Non-interactive: the existing cert stays valid until
/// expiry, and the next foreground retries automatically.
struct CertRenewalWarningBanner: View {
var body: some View {
Label(RootCopy.certRenewalFailing, systemImage: "exclamationmark.shield")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusWaiting)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, DS.Space.lg16)
.padding(.vertical, DS.Space.sm8)
.background(.regularMaterial)
.overlay(alignment: .bottom) {
Rectangle()
.fill(DS.Palette.hairline)
.frame(height: DS.Stroke.hairline)
}
.accessibilityIdentifier("root.certRenewalWarning")
}
}
// MARK: - Projects toolbar item (shared stack + split, DRY)
/// leading stack split disabled
@@ -180,4 +219,6 @@ enum RootCopy {
static let continueLast = "继续上次会话"
static let projects = "项目"
static let done = "完成"
/// B3 (HIGH) · Shown when the silent device-cert renewal keeps failing.
static let certRenewalFailing = "设备证书自动续期失败,将在下次前台重试"
}

View File

@@ -40,7 +40,8 @@ struct SplitRootView: View {
coordinator.selectSidebarItem(sidebarItem(for: request))
},
onAddHost: { coordinator.presentAddHost() },
onDeviceCert: { coordinator.presentDeviceCert() }
onDeviceCert: { coordinator.presentDeviceCert() },
onEnroll: { coordinator.presentEnrollment() }
)
.safeAreaInset(edge: .bottom) { continueLastBanner }
.animation(

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