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.
96 lines
3.9 KiB
Swift
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)
|
|
}
|