Files
web-terminal/ios/Packages/ClientTLS/Tests/ClientTLSTests/ControlPlaneLoginClientTests.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

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