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,134 @@
|
||||
import Foundation
|
||||
import Security
|
||||
@testable import ClientTLS
|
||||
|
||||
// B4 · Direct `SecItem*` access for the store tests. Two jobs:
|
||||
// 1. INSPECT what `KeychainClientIdentityStore` actually wrote — the protection
|
||||
// class and the no-iCloud-sync flag are security requirements (plan §5.3 /
|
||||
// contract §1.1) that only a raw read can confirm.
|
||||
// 2. SEED / CORRUPT the persisted records so the read-side error paths and the
|
||||
// renew path can be driven without first performing a real enrollment
|
||||
// (which would have to install a certificate — see `undecodableCertificateDER`).
|
||||
|
||||
/// The keychain coordinates a `KeychainClientIdentityStore(service:account:)`
|
||||
/// derives internally. Mirrored here ON PURPOSE: the persisted layout is a
|
||||
/// migration contract, so the tests pin it rather than infer it.
|
||||
struct StoreKeychainKeys {
|
||||
let service: String
|
||||
let account: String
|
||||
|
||||
/// `KeychainClientIdentityStore.enrollmentAccount`.
|
||||
var enrollmentAccount: String { "\(account).enrollment" }
|
||||
/// `KeychainClientIdentityStore.deviceKeyTag`.
|
||||
var deviceKeyTag: Data { Data("\(service).device-key".utf8) }
|
||||
/// `KeychainClientIdentityStore.leafLabel`.
|
||||
var leafLabel: String { "\(service).device-leaf" }
|
||||
|
||||
/// A fresh, collision-free coordinate set for one test.
|
||||
static func unique() -> StoreKeychainKeys {
|
||||
StoreKeychainKeys(
|
||||
service: "com.yaojia.webterm.clienttls.test-\(UUID().uuidString)",
|
||||
account: "device-identity"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum KeychainProbe {
|
||||
private static func query(service: String, account: String) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
}
|
||||
|
||||
/// How many generic-password items sit at `(service, account)`. `0` = nothing
|
||||
/// stored; `> 1` = a write duplicated instead of replacing.
|
||||
///
|
||||
/// Attributes-only, `kSecMatchLimitAll`: asking for `kSecReturnData`
|
||||
/// together with `kSecMatchLimitAll` is `errSecParam (-50)` on the macOS
|
||||
/// file-based keychain (verified 2026-07-30), so counting and reading are
|
||||
/// separate calls.
|
||||
static func count(service: String, account: String) -> Int {
|
||||
var request = query(service: service, account: account)
|
||||
request[kSecReturnAttributes as String] = true
|
||||
request[kSecMatchLimit as String] = kSecMatchLimitAll
|
||||
var result: CFTypeRef?
|
||||
guard SecItemCopyMatching(request as CFDictionary, &result) == errSecSuccess else {
|
||||
return 0
|
||||
}
|
||||
if let array = result as? [Any] { return array.count }
|
||||
return result == nil ? 0 : 1
|
||||
}
|
||||
|
||||
/// The stored bytes at `(service, account)`; `nil` when no item exists.
|
||||
static func data(service: String, account: String) -> Data? {
|
||||
var request = query(service: service, account: account)
|
||||
request[kSecReturnData as String] = true
|
||||
request[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: CFTypeRef?
|
||||
guard SecItemCopyMatching(request as CFDictionary, &result) == errSecSuccess else {
|
||||
return nil
|
||||
}
|
||||
return result as? Data
|
||||
}
|
||||
|
||||
/// Write raw bytes at `(service, account)` with the store's own protection
|
||||
/// class — used to seed or corrupt a record.
|
||||
@discardableResult
|
||||
static func write(service: String, account: String, data: Data) -> OSStatus {
|
||||
SecItemDelete(query(service: service, account: account) as CFDictionary)
|
||||
var attributes = query(service: service, account: account)
|
||||
attributes[kSecValueData as String] = data
|
||||
attributes[kSecAttrAccessible as String] =
|
||||
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
return SecItemAdd(attributes as CFDictionary, nil)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func delete(service: String, account: String) -> OSStatus {
|
||||
SecItemDelete(query(service: service, account: account) as CFDictionary)
|
||||
}
|
||||
|
||||
/// Remove everything a test could have created under `keys`.
|
||||
static func purge(_ keys: StoreKeychainKeys) {
|
||||
delete(service: keys.service, account: keys.account)
|
||||
delete(service: keys.service, account: keys.enrollmentAccount)
|
||||
try? SecureEnclaveKeyFactory.delete(tag: keys.deviceKeyTag)
|
||||
}
|
||||
}
|
||||
|
||||
/// The two fields of the stored `.p12` envelope, decoded from the keychain item.
|
||||
///
|
||||
/// Compared field-by-field rather than byte-by-byte because Foundation's
|
||||
/// `JSONEncoder` serializes through an unordered dictionary on Darwin: two
|
||||
/// encodings of the SAME value differ in key order (verified 2026-07-30 — both
|
||||
/// blobs 2109 bytes, not equal), so byte equality is not a valid invariant.
|
||||
func storedP12Fields(service: String, account: String) -> (p12: String, passphrase: String)? {
|
||||
guard let data = KeychainProbe.data(service: service, account: account),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let p12 = json["p12"] as? String,
|
||||
let passphrase = json["passphrase"] as? String
|
||||
else { return nil }
|
||||
return (p12, passphrase)
|
||||
}
|
||||
|
||||
/// The JSON `StoredEnrollment` shape (`JSONEncoder` defaults: `Data` → base64
|
||||
/// string, `Date` → seconds since the reference date). Written directly so the
|
||||
/// read path can be exercised without installing a leaf certificate.
|
||||
func storedEnrollmentJSON(
|
||||
deviceId: String,
|
||||
deviceName: String,
|
||||
caChain: [Data] = [],
|
||||
notAfter: Date? = nil,
|
||||
renewAfter: Date? = nil
|
||||
) -> Data {
|
||||
var json: [String: Any] = [
|
||||
"deviceId": deviceId,
|
||||
"deviceName": deviceName,
|
||||
"caChain": caChain.map { $0.base64EncodedString() },
|
||||
]
|
||||
if let notAfter { json["notAfter"] = notAfter.timeIntervalSinceReferenceDate }
|
||||
if let renewAfter { json["renewAfter"] = renewAfter.timeIntervalSinceReferenceDate }
|
||||
return try! JSONSerialization.data(withJSONObject: json)
|
||||
}
|
||||
Reference in New Issue
Block a user