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:
Yaojia Wang
2026-07-30 12:45:26 +02:00
parent 850531fd07
commit a5fa843f00
14 changed files with 1733 additions and 46 deletions

View File

@@ -0,0 +1,92 @@
import Foundation
import Security
import Testing
// B4 · Why every real-keychain test in this package runs one at a time.
//
// `KeychainClientIdentityStore` / `SecureEnclaveKeyFactory` call `SecItem*` and
// `SecKeyCreateRandomKey` WITHOUT `kSecUseDataProtectionKeychain`, so on macOS
// `swift test` reaches the FILE-BASED login keychain. Two problems follow from
// Swift Testing's default parallel execution:
//
// 1. That backend is not safe for concurrent writes from one process: two
// simultaneous permanent-key generations intermittently fail with
// keyGenerationFailed(" Code=-25300 \"failed to generate CDSA key\" ")
// (observed 2026-07-30 green under `--no-parallel`, red in parallel).
// 2. Certificate items are keyed by the cert's own subject on macOS, so the
// fixture-leaf sweep is GLOBAL: one test's cleanup would delete a
// concurrently-running test's freshly installed leaf.
//
// A global actor is NOT enough: an actor releases its executor at every `await`,
// and these tests await network-stub calls in the middle of their critical
// section (proved by exactly failure mode 2 appearing when a `@globalActor` was
// used). What is needed is a mutex held ACROSS suspension points applied as a
// custom trait so the whole test body, setup and cleanup included, is inside it.
/// A FIFO async mutex.
actor KeychainTestMutex {
static let shared = KeychainTestMutex()
private var isHeld = false
private var waiters: [CheckedContinuation<Void, Never>] = []
func acquire() async {
guard isHeld else {
isHeld = true
return
}
await withCheckedContinuation { waiters.append($0) }
}
func release() {
guard waiters.isEmpty else {
waiters.removeFirst().resume() // hand the lock straight to the next waiter
return
}
isHeld = false
}
}
/// Serializes a test against every other keychain-touching test.
struct KeychainSerializedTrait: TestTrait, SuiteTrait, TestScoping {
func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: @Sendable () async throws -> Void
) async throws {
await KeychainTestMutex.shared.acquire()
do {
try await function()
} catch {
await KeychainTestMutex.shared.release()
throw error
}
await KeychainTestMutex.shared.release()
}
}
extension Trait where Self == KeychainSerializedTrait {
/// Apply to every test that touches the real keychain.
static var keychainSerialized: Self { Self() }
}
/// Does the default keychain hand back an identity for a tag that was never
/// installed?
///
/// On macOS's file-based keychain, `SecItemCopyMatching(kSecClass:
/// kSecClassIdentity, kSecAttrApplicationTag: )` IGNORES the tag and returns an
/// arbitrary identity from the login keychain (verified 2026-07-30 with a random
/// tag: `errSecSuccess` + an unrelated `localhost` identity). On iOS's
/// data-protection keychain the tag IS honored, which is why the production code
/// is correct on the shipping platform but it means the `.p12` fallback branch
/// of `loadIdentity()` is only reachable in a `swift test` run whose default
/// keychain holds no identities (a clean CI runner). Tests that depend on that
/// branch are gated on this probe instead of being silently wrong.
func defaultKeychainYieldsForeignIdentity() -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrApplicationTag as String: Data("clienttls-probe-\(UUID().uuidString)".utf8),
kSecMatchLimit as String: kSecMatchLimitOne,
]
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
}