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,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
|
||||
}
|
||||
Reference in New Issue
Block a user