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