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.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user