Files
web-terminal/ios/Packages/ClientTLS/Tests/ClientTLSTests/DeviceEnrollmentFlowTests.swift
Yaojia Wang 07bcbf0c08 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.
2026-07-18 13:32:05 +02:00

137 lines
5.6 KiB
Swift

import Foundation
import Testing
@testable import ClientTLS
// B3 · DeviceEnrollmentFlow composes the pinned bootstrap end-to-end:
// login(password) bearer enroll(CSR, subdomain) with that bearer leaf.
// Driven by a path-routing stub transport (no network, no keychain): asserts the
// bearer minted at login is exactly what authenticates the enroll call.
private let flowBaseURL = URL(string: "https://cp.terminal.yaojia.wang")!
/// Routes by path: `/auth/login` login body, `/device/enroll` enroll body.
/// Records every request so the test can assert the enroll bearer. `@unchecked
/// Sendable`: mutable captures guarded by a lock.
private final class RoutingStubTransport: EnrollmentTransport, @unchecked Sendable {
private let lock = NSLock()
private var _requests: [URLRequest] = []
private let loginStatus: Int
private let loginBody: Data
init(loginStatus: Int = 201, loginBody: Data) {
self.loginStatus = loginStatus
self.loginBody = loginBody
}
var requests: [URLRequest] { lock.withLock { _requests } }
func request(forPathSuffix suffix: String) -> URLRequest? {
lock.withLock { _requests.first { $0.url?.path.hasSuffix(suffix) == true } }
}
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
lock.withLock { _requests.append(request) }
let path = request.url?.path ?? ""
let (status, body): (Int, Data)
if path.hasSuffix("/auth/login") {
(status, body) = (loginStatus, loginBody)
} else {
// /device/enroll a minimal 201 leaf.
(status, body) = (201, enrollLeafBody())
}
let response = HTTPURLResponse(
url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil
)!
return (body, response)
}
}
private func loginResponseBody(enrollToken: String) -> Data {
try! JSONSerialization.data(withJSONObject: [
"enrollToken": enrollToken,
"accountId": "acct-123",
"expiresIn": 600,
])
}
private func enrollLeafBody() -> Data {
try! JSONSerialization.data(withJSONObject: [
"deviceId": "dev-1",
"cert": Data([0x30, 0x01]).base64EncodedString(),
"caChain": [Data([0x30, 0x02]).base64EncodedString()],
"notBefore": "2026-07-18T00:00:00Z",
"notAfter": "2026-10-16T00:00:00Z",
"renewAfter": "2026-09-15T00:00:00Z",
])
}
/// A fake installer that invokes the real `client.enroll` (so the transport sees
/// the enroll request with the login-minted bearer), then returns a summary.
/// Records the subdomain/deviceName it was handed. `@unchecked Sendable`: lock.
private final class SpyInstaller: @unchecked Sendable {
private let lock = NSLock()
private(set) var subdomain: String?
private(set) var deviceName: String?
func install(
_ client: DeviceEnrollmentClient, _ subdomain: String, _ deviceName: String
) async throws -> ClientCertificateSummary? {
lock.withLock { self.subdomain = subdomain; self.deviceName = deviceName }
_ = try await client.enroll(csrDER: Data([0xAB]), subdomain: subdomain, deviceName: deviceName)
return ClientCertificateSummary(
subjectCommonName: deviceName, issuerCommonName: "webterm-device-ca", notAfter: nil
)
}
}
@Test("flow logs in then enrolls with the login-minted bearer (end to end)")
func flowEndToEnd() async throws {
// Arrange
let transport = RoutingStubTransport(loginBody: loginResponseBody(enrollToken: "TOKEN-XYZ"))
let installer = SpyInstaller()
let flow = DeviceEnrollmentFlow(
baseURL: flowBaseURL,
transport: transport,
install: installer.install
)
// Act
let summary = try await flow.run(password: "pw", subdomain: "alice", deviceName: "Alice iPhone")
// Assert login happened, THEN enroll with the exact bearer from login.
#expect(transport.request(forPathSuffix: "/auth/login") != nil)
let enroll = try #require(transport.request(forPathSuffix: "/device/enroll"))
#expect(enroll.value(forHTTPHeaderField: "Authorization") == "Bearer TOKEN-XYZ")
#expect(installer.subdomain == "alice")
#expect(installer.deviceName == "Alice iPhone")
#expect(summary?.subjectCommonName == "Alice iPhone")
}
@Test("a login failure short-circuits: enroll is never attempted")
func flowLoginFailureShortCircuits() async {
// 401 login the flow must NOT reach /device/enroll.
let body = try! JSONSerialization.data(withJSONObject: ["error": "login rejected"])
let transport = RoutingStubTransport(loginStatus: 401, loginBody: body)
let installer = SpyInstaller()
let flow = DeviceEnrollmentFlow(
baseURL: flowBaseURL, transport: transport, install: installer.install
)
await #expect(throws: ControlPlaneLoginError.http(status: 401, code: "login rejected")) {
_ = try await flow.run(password: "wrong", subdomain: "alice", deviceName: "d")
}
#expect(transport.request(forPathSuffix: "/device/enroll") == nil)
#expect(installer.subdomain == nil) // installer never ran
}
@Test("an empty password is rejected before any network")
func flowEmptyPassword() async {
let transport = RoutingStubTransport(loginBody: loginResponseBody(enrollToken: "t"))
let flow = DeviceEnrollmentFlow(
baseURL: flowBaseURL, transport: transport, install: { _, _, _ in nil }
)
await #expect(throws: ControlPlaneLoginError.emptyPassword) {
_ = try await flow.run(password: "", subdomain: "alice", deviceName: "d")
}
#expect(transport.requests.isEmpty)
}