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,103 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
// B4 · Two real, DER-decodable leaf certificates for the enrolled-identity
|
||||
// persistence tests, plus the cleanup they need.
|
||||
//
|
||||
// Both carry the SAME public key (the `KnownGoodCSR` fixed key) and the SAME
|
||||
// subject, and differ only in serial + validity — exactly the shape of a
|
||||
// rotation: `SecItemAdd` treats them as two distinct items, while re-adding one
|
||||
// of them byte-for-byte is `errSecDuplicateItem`.
|
||||
//
|
||||
// CLEANUP, and why it needs its own path: on macOS the file-based keychain
|
||||
// OVERRIDES the `kSecAttrLabel` supplied at add time with the certificate's own
|
||||
// subject summary (verified 2026-07-30: added under
|
||||
// `…test-XYZ.device-leaf`, found only under `enrolled-device-fixture`). So the
|
||||
// production `KeychainClientIdentityStore` label-keyed delete cannot remove them
|
||||
// on macOS, and the tests must sweep by subject instead — `purgeFixtureLeaves()`
|
||||
// runs both BEFORE (in case a previous run was killed) and AFTER every test that
|
||||
// installs one. `SecItemDelete` removes ONE match per call here, hence the loop.
|
||||
//
|
||||
// Regenerated with (OpenSSL 3.0.18, key = KnownGoodCSR.privateKeyX963Base64):
|
||||
// openssl req -x509 -new -key fixed.key.pem -subj "/CN=enrolled-device-fixture" \
|
||||
// -days 3650 -sha256 -outform DER -out leaf1.der # then -days 3651 → leaf2
|
||||
// openssl req -x509 -new -key chain.key.pem \
|
||||
// -subj "/CN=webterm-enrollment-ca-fixture" -days 3650 -sha256 -outform DER
|
||||
enum EnrolledLeafFixtures {
|
||||
/// The subject summary macOS files these certificates under.
|
||||
static let subjectSummary = "enrolled-device-fixture"
|
||||
|
||||
static var leaf: Data { der(leafBase64) }
|
||||
/// A second, DISTINCT leaf (different serial) for the rotation case.
|
||||
static var rotatedLeaf: Data { der(rotatedLeafBase64) }
|
||||
/// An issuer certificate for the stored `caChain`.
|
||||
static var issuer: Data { der(issuerBase64) }
|
||||
|
||||
private static func der(_ base64: String) -> Data {
|
||||
Data(base64Encoded: base64, options: .ignoreUnknownCharacters)!
|
||||
}
|
||||
|
||||
private static let leafBase64 = """
|
||||
MIIBmTCCAT+gAwIBAgIUcHFyXdi5QFelGKhdzcsbFlgnYzUwCgYIKoZIzj0EAwIwIjEgMB4GA1UE\
|
||||
AwwXZW5yb2xsZWQtZGV2aWNlLWZpeHR1cmUwHhcNMjYwNzMwMDc1NTAxWhcNMzYwNzI3MDc1NTAx\
|
||||
WjAiMSAwHgYDVQQDDBdlbnJvbGxlZC1kZXZpY2UtZml4dHVyZTBZMBMGByqGSM49AgEGCCqGSM49\
|
||||
AwEHA0IABGInyJAZfMKCGjVNFg801yeq2Ar+auw1/4IC+g9tIf7Xa/VDY59VGBU9Vh5LYA7fLzs5\
|
||||
6X1TF7ZCweW4mcaiJ9KjUzBRMB0GA1UdDgQWBBRAua2aufygDDq2CJTBF31OG7cNSDAfBgNVHSME\
|
||||
GDAWgBRAua2aufygDDq2CJTBF31OG7cNSDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gA\
|
||||
MEUCIQCoLSypPlXvtMsQRMpztt/RpbYKc6iTmBLypqOZc0MxTAIgF/MVWpaLLYVCmcKBPxf7y9yQ\
|
||||
NX3c6BrYB/fi/WzJsmA=
|
||||
"""
|
||||
|
||||
private static let rotatedLeafBase64 = """
|
||||
MIIBmTCCAT+gAwIBAgIUarD2zMEMcxZJNQevMMJenCAlLpkwCgYIKoZIzj0EAwIwIjEgMB4GA1UE\
|
||||
AwwXZW5yb2xsZWQtZGV2aWNlLWZpeHR1cmUwHhcNMjYwNzMwMDc1NTAyWhcNMzYwNzI4MDc1NTAy\
|
||||
WjAiMSAwHgYDVQQDDBdlbnJvbGxlZC1kZXZpY2UtZml4dHVyZTBZMBMGByqGSM49AgEGCCqGSM49\
|
||||
AwEHA0IABGInyJAZfMKCGjVNFg801yeq2Ar+auw1/4IC+g9tIf7Xa/VDY59VGBU9Vh5LYA7fLzs5\
|
||||
6X1TF7ZCweW4mcaiJ9KjUzBRMB0GA1UdDgQWBBRAua2aufygDDq2CJTBF31OG7cNSDAfBgNVHSME\
|
||||
GDAWgBRAua2aufygDDq2CJTBF31OG7cNSDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gA\
|
||||
MEUCID2FFmKH2+qHB8sICyDuxQPtyj/wOLE24t4ghBEitLRvAiEAnTQQ2FP8Wei+8ITL/K57UP2Z\
|
||||
YtYbEokKkBCj2bMr3vk=
|
||||
"""
|
||||
|
||||
private static let issuerBase64 = """
|
||||
MIIBpTCCAUugAwIBAgIUIkl7VDf6o9lBxtWxh7zFxPYTpo8wCgYIKoZIzj0EAwIwKDEmMCQGA1UE\
|
||||
Awwdd2VidGVybS1lbnJvbGxtZW50LWNhLWZpeHR1cmUwHhcNMjYwNzMwMDc1NTAyWhcNMzYwNzI3\
|
||||
MDc1NTAyWjAoMSYwJAYDVQQDDB13ZWJ0ZXJtLWVucm9sbG1lbnQtY2EtZml4dHVyZTBZMBMGByqG\
|
||||
SM49AgEGCCqGSM49AwEHA0IABM4iWgauVe86+SKZg+jlHkh8VbVf70pgMdGOcpJW+xWb5oqfzxY3\
|
||||
fW6pIdn3SCo9PG7X22qGtq/PLgpv97wtJvajUzBRMB0GA1UdDgQWBBTT9mdiVZTWcatuYz3NkxCb\
|
||||
DxDSAjAfBgNVHSMEGDAWgBTT9mdiVZTWcatuYz3NkxCbDxDSAjAPBgNVHRMBAf8EBTADAQH/MAoG\
|
||||
CCqGSM49BAMCA0gAMEUCIQCxfF8zyRHMW7nSmieDoBxIsOXkMFVPko86THW3TbwrUwIgMZlBP5rz\
|
||||
FT4Y/G2oA+87xceEDjCrA7FnRZrCrZ4AADk=
|
||||
"""
|
||||
|
||||
private static func labelQuery() -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassCertificate,
|
||||
kSecAttrLabel as String: subjectSummary,
|
||||
]
|
||||
}
|
||||
|
||||
/// How many fixture leaves are currently installed.
|
||||
static func installedCount() -> Int {
|
||||
var query = labelQuery()
|
||||
query[kSecReturnRef as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitAll
|
||||
var result: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else {
|
||||
return 0
|
||||
}
|
||||
if let array = result as? [Any] { return array.count }
|
||||
return result == nil ? 0 : 1
|
||||
}
|
||||
|
||||
/// Remove every installed fixture leaf. Idempotent; safe to call when none
|
||||
/// are installed.
|
||||
static func purgeFixtureLeaves() {
|
||||
var guardCounter = 0
|
||||
let maxSweeps = 16 // a leaf per enroll in a test; never unbounded
|
||||
while SecItemDelete(labelQuery() as CFDictionary) == errSecSuccess,
|
||||
guardCounter < maxSweeps {
|
||||
guardCounter += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user