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 init(result: Result) { 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 } }