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.
93 lines
3.7 KiB
Swift
93 lines
3.7 KiB
Swift
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
|
|
}
|