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.
137 lines
5.6 KiB
Swift
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)
|
|
}
|