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