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.
124 lines
4.7 KiB
Swift
124 lines
4.7 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import ClientTLS
|
|
|
|
// B3 · ControlPlaneLoginClient request-building + response-mapping, driven by a
|
|
// stub transport (no network). Mirrors the PINNED login contract exactly:
|
|
//
|
|
// POST /auth/login { "password": "<operator secret>" }
|
|
// → 201 { "enrollToken": "<jwt/capability>", "accountId": "<uuid>", "expiresIn": <seconds> }
|
|
|
|
private let cpBaseURL = URL(string: "https://cp.terminal.yaojia.wang")!
|
|
|
|
/// Records the last request and replays a canned response. `@unchecked Sendable`:
|
|
/// the mutable capture is guarded by a lock.
|
|
private final class LoginStubTransport: EnrollmentTransport, @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var _lastRequest: URLRequest?
|
|
private let status: Int
|
|
private let body: Data
|
|
|
|
init(status: Int, body: Data) {
|
|
self.status = status
|
|
self.body = body
|
|
}
|
|
|
|
var lastRequest: URLRequest? { lock.withLock { _lastRequest } }
|
|
|
|
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
|
lock.withLock { _lastRequest = request }
|
|
let response = HTTPURLResponse(
|
|
url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil
|
|
)!
|
|
return (body, response)
|
|
}
|
|
}
|
|
|
|
private func loginBody(
|
|
enrollToken: String = "v4.public.enroll-token",
|
|
accountId: String = "11111111-2222-3333-4444-555555555555",
|
|
expiresIn: Int = 600
|
|
) -> Data {
|
|
let json: [String: Any] = [
|
|
"enrollToken": enrollToken,
|
|
"accountId": accountId,
|
|
"expiresIn": expiresIn,
|
|
]
|
|
return try! JSONSerialization.data(withJSONObject: json)
|
|
}
|
|
|
|
@Test("login builds a POST /auth/login carrying only the password (never bearer)")
|
|
func loginBuildsRequest() async throws {
|
|
// Arrange
|
|
let stub = LoginStubTransport(status: 201, body: loginBody())
|
|
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
|
|
|
|
// Act
|
|
_ = try await client.login(password: "operator-secret")
|
|
|
|
// Assert
|
|
let request = try #require(stub.lastRequest)
|
|
#expect(request.httpMethod == "POST")
|
|
#expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/auth/login")
|
|
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
|
// Login is credential-based, NOT bearer-authed — no Authorization header.
|
|
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
|
|
|
|
let sent = try #require(request.httpBody)
|
|
let object = try #require(try JSONSerialization.jsonObject(with: sent) as? [String: Any])
|
|
#expect(object["password"] as? String == "operator-secret")
|
|
#expect(object.keys.count == 1) // password ONLY — no extra fields leak
|
|
}
|
|
|
|
@Test("login maps a 201 response into a typed LoginResult")
|
|
func loginMapsResponse() async throws {
|
|
// Arrange
|
|
let stub = LoginStubTransport(status: 201, body: loginBody(
|
|
enrollToken: "v4.public.abc", accountId: "acct-uuid", expiresIn: 900
|
|
))
|
|
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
|
|
|
|
// Act
|
|
let result = try await client.login(password: "pw")
|
|
|
|
// Assert
|
|
#expect(result.enrollToken == "v4.public.abc")
|
|
#expect(result.accountId == "acct-uuid")
|
|
#expect(result.expiresIn == 900)
|
|
}
|
|
|
|
@Test("an empty password fails fast without any network call")
|
|
func loginEmptyPasswordRejected() async throws {
|
|
// Arrange
|
|
let stub = LoginStubTransport(status: 201, body: loginBody())
|
|
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
|
|
|
|
// Act / Assert — boundary validation: reject before sending.
|
|
await #expect(throws: ControlPlaneLoginError.emptyPassword) {
|
|
_ = try await client.login(password: "")
|
|
}
|
|
#expect(stub.lastRequest == nil) // proves zero network happened
|
|
}
|
|
|
|
@Test("a 401 login response surfaces http with the server error code")
|
|
func loginRejectSurfacesStatus() async throws {
|
|
// The login stub seam denies-by-default → { error: "login rejected" }.
|
|
let body = try! JSONSerialization.data(withJSONObject: ["error": "login rejected"])
|
|
let stub = LoginStubTransport(status: 401, body: body)
|
|
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
|
|
|
|
await #expect(throws: ControlPlaneLoginError.http(status: 401, code: "login rejected")) {
|
|
_ = try await client.login(password: "wrong")
|
|
}
|
|
}
|
|
|
|
@Test("a malformed 201 body throws malformedResponse")
|
|
func loginMalformedBody() async throws {
|
|
let stub = LoginStubTransport(status: 201, body: Data("not json".utf8))
|
|
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
|
|
|
|
await #expect(throws: ControlPlaneLoginError.malformedResponse) {
|
|
_ = try await client.login(password: "pw")
|
|
}
|
|
}
|