Files
web-terminal/ios/Packages/ClientTLS/Tests/ClientTLSTests/EnrollmentStubs.swift
Yaojia Wang a5fa843f00 test(ios): ClientTLS coverage 55.76% -> 89.49%, gate it, fix the three dead CI legs
ClientTLS was the most security-sensitive package in the tree and the least
covered, and it was not in the coverage gate at all (the gate's 4-package set
predates it). 48 -> 84 tests against the real macOS keychain, serialized with a
custom Testing trait after a @globalActor proved insufficient (actors yield at
await, so cross-await critical sections got interleaved by other cases' cleanup).

CI: the app/ipad/ios17 legs ran a bundle containing LiveServerSmokeTests, which
spawns tsx, with no npm ci -- a hard failure, not a skip, on a bare checkout.
Adds the missing iPad UI-test leg, and makes a missing iOS 17 runtime fail loudly
instead of silently reporting green.
2026-07-30 12:45:26 +02:00

96 lines
3.9 KiB
Swift

import Foundation
@testable import ClientTLS
// B4 · Shared enrollment-transport double for the store / flow tests.
//
// Unlike the per-file stubs in DeviceEnrollmentClientTests (which only need the
// LAST request), the store tests must re-parse the CSR that was actually sent
// that is how "renew re-signs with the SAME device key" is pinned so this one
// records every request body and replays a scripted reply per call.
/// A recording, scriptable `EnrollmentTransport`.
///
/// `@unchecked Sendable`: all mutable state is behind `lock`.
final class RecordingEnrollmentTransport: EnrollmentTransport, @unchecked Sendable {
struct Reply {
let status: Int
let body: Data
}
private let lock = NSLock()
private var pendingReplies: [Reply]
private var recorded: [URLRequest] = []
init(replies: [Reply]) {
pendingReplies = replies
}
convenience init(status: Int, body: Data) {
self.init(replies: [Reply(status: status, body: body)])
}
var requests: [URLRequest] { lock.withLock { recorded } }
var callCount: Int { lock.withLock { recorded.count } }
/// The JSON body of call `index`, decoded as a `[String: Any]` object.
func jsonBody(at index: Int) -> [String: Any]? {
guard let data = requests.indices.contains(index)
? requests[index].httpBody : nil
else { return nil }
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
}
/// The `csr` field of call `index`, base64-decoded back to DER.
func csrDER(at index: Int) -> Data? {
guard let base64 = jsonBody(at: index)?["csr"] as? String else { return nil }
return Data(base64Encoded: base64)
}
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let reply: Reply = lock.withLock {
recorded.append(request)
guard !pendingReplies.isEmpty else { return Reply(status: 500, body: Data()) }
return pendingReplies.removeFirst()
}
let response = HTTPURLResponse(
url: request.url!, statusCode: reply.status, httpVersion: "HTTP/1.1", headerFields: nil
)!
return (reply.body, response)
}
}
/// DER bytes that `SecCertificateCreateWithData` rejects (a truncated SEQUENCE).
///
/// Every store test that drives `enroll` / `renew` to the persistence step uses
/// this as the server-returned leaf ON PURPOSE: it makes `storeEnrolledLeaf`
/// fail at the very first line BEFORE any `SecItemAdd(kSecClassCertificate)`.
/// That matters on macOS, where `swift test` talks to the file-based login
/// keychain: it OVERRIDES the `kSecAttrLabel` of an added certificate with the
/// cert's own subject summary, so `KeychainClientIdentityStore`'s label-keyed
/// delete can never remove it again (verified 2026-07-30: adding the leaf, then
/// `SecItemDelete(kSecClass: kSecClassCertificate, kSecAttrLabel: )`
/// `errSecItemNotFound (-25300)`, and the cert had to be swept manually with
/// `security delete-certificate -Z <sha1>`). A test that installed a leaf would
/// therefore permanently pollute the developer's login keychain.
let undecodableCertificateDER = Data([0x30, 0x01, 0x02])
/// A `POST /device/enroll` / `/renew` 201 body in the A4 wire shape.
func enrollmentResponseJSON(
deviceId: String = "dev-b4",
certDER: Data = undecodableCertificateDER,
caChain: [Data] = [],
notBefore: String? = "2026-07-30T00:00:00.000Z",
notAfter: String? = "2026-10-28T00:00:00.000Z",
renewAfter: String? = "2026-09-27T00:00:00.000Z"
) -> Data {
var json: [String: Any] = [
"deviceId": deviceId,
"cert": certDER.base64EncodedString(),
"caChain": caChain.map { $0.base64EncodedString() },
]
if let notBefore { json["notBefore"] = notBefore }
if let notAfter { json["notAfter"] = notAfter }
if let renewAfter { json["renewAfter"] = renewAfter }
return try! JSONSerialization.data(withJSONObject: json)
}