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,210 @@
import Foundation
import Security
import Testing
@testable import ClientTLS
// B4 · `KeychainClientIdentityStore` against the REAL keychain the legacy
// `.p12` half (the dual-trust migration window: devices enrolled before the
// Secure-Enclave path still carry an imported `.p12`).
//
// This package has no `SecItemShim` seam (unlike HostRegistry), so these tests
// drive the actual `SecItem*` calls. Each test gets a UUID-scoped service and
// purges it afterwards, so runs never collide and nothing is left behind.
//
// WHAT THIS LAYER CANNOT CHECK: the `kSecAttrAccessible` protection class. On
// macOS `swift test` reaches the FILE-BASED keychain, which has no
// data-protection class at all a stored item's attributes come back as only
// `acct/cdat/class/labl/mdat/svce`, with no `pdmn` (verified 2026-07-30). The
// "AfterFirstUnlockThisDeviceOnly, never synchronized" assertion therefore
// belongs to a SIGNED simulator host, exactly as plan §9 already records for
// `KeychainHostStore`. It is NOT covered by this package's gate.
@Test("save persists exactly one item holding the .p12 and its passphrase", .keychainSerialized)
func keychainStoreSavePersistsSingleItem() async throws {
// Arrange
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
// Act
try store.save(
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
// Assert one item, and the blob carries BOTH halves (the passphrase is
// required to re-import at every launch, so storing the .p12 alone would
// brick the identity after a relaunch).
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1)
let stored = try #require(storedP12Fields(service: keys.service, account: keys.account))
#expect(stored.p12 == ClientTLSFixtures.deviceP12Data.base64EncodedString())
#expect(stored.passphrase == ClientTLSFixtures.passphrase)
}
@Test("saving again REPLACES the stored blob instead of duplicating the item", .keychainSerialized)
func keychainStoreSaveReplaces() async throws {
// Arrange
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
try store.save(
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
let first = try #require(storedP12Fields(service: keys.service, account: keys.account))
// Act same coordinates, re-saved (the rotation / re-install path).
try store.save(
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
// Assert still exactly one item (a duplicate would make loads
// order-dependent, i.e. an old cert could resurface after rotation) and the
// content is intact.
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1)
let second = try #require(storedP12Fields(service: keys.service, account: keys.account))
#expect(second == first)
}
@Test("a wrong passphrase throws and writes NOTHING to the keychain", .keychainSerialized)
func keychainStoreSaveRejectsWrongPassphrase() async throws {
// Arrange
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
// Act / Assert validation happens BEFORE persistence.
#expect(throws: PKCS12ImportError.wrongPassphrase) {
try store.save(p12Data: ClientTLSFixtures.deviceP12Data, passphrase: "wrong")
}
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0)
}
@Test("a corrupt .p12 throws .corruptFile and leaves the PRIOR identity installed", .keychainSerialized)
func keychainStoreSaveKeepsPriorIdentityOnCorruptFile() async throws {
// Arrange a good identity is already installed.
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
try store.save(
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
let good = try #require(storedP12Fields(service: keys.service, account: keys.account))
// Act a truncated file arrives (bad download / wrong file picked).
#expect(throws: PKCS12ImportError.corruptFile) {
try store.save(
p12Data: ClientTLSFixtures.deviceP12Data.prefix(64),
passphrase: ClientTLSFixtures.passphrase
)
}
// Assert the working identity is untouched, not wiped by the failed install.
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1)
let survivor = try #require(storedP12Fields(service: keys.service, account: keys.account))
#expect(survivor == good)
}
@Test("remove deletes the stored blob and a second remove is a no-op", .keychainSerialized)
func keychainStoreRemoveIsIdempotent() async throws {
// Arrange
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
try store.save(
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1)
// Act
try store.remove()
// Assert gone, and removing again must NOT throw errSecItemNotFound
// (removal is reachable from "delete host" with no identity installed).
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0)
#expect(throws: Never.self) { try store.remove() }
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0)
}
@Test("remove also clears the enrollment record and the device key", .keychainSerialized)
func keychainStoreRemoveClearsEnrolledState() async throws {
// Arrange a device that enrolled (record + device key) AND carries a
// legacy .p12, i.e. mid-migration.
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
try store.save(
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
KeychainProbe.write(
service: keys.service, account: keys.enrollmentAccount,
data: storedEnrollmentJSON(deviceId: "dev-remove", deviceName: "iPhone")
)
_ = try SecureEnclaveKeyFactory.generateSoftware(tag: keys.deviceKeyTag, permanent: true)
// Act
try store.remove()
// Assert removal is unconditional across BOTH paths; a leftover device key
// would make a later enroll bind a new leaf to a stale key.
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0)
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
#expect(try SecureEnclaveKeyFactory.load(tag: keys.deviceKeyTag) == nil)
#expect(try store.renewalState() == nil)
}
// The `.p12` fallback inside `loadIdentity()` is only reachable when the default
// keychain holds no identities: on macOS the `kSecClassIdentity` lookup ignores
// `kSecAttrApplicationTag` and returns an arbitrary foreign identity, which
// short-circuits the fallback (see `defaultKeychainYieldsForeignIdentity`). The
// test is gated rather than fudged it runs on a clean runner and is reported as
// skipped on a developer machine with identities in the login keychain.
@Test(
"save → loadIdentity → loadSummary roundtrips through the real keychain",
.enabled(if: !defaultKeychainYieldsForeignIdentity())
, .keychainSerialized)
func keychainStoreLoadsBackTheStoredIdentity() async throws {
// Arrange
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
#expect(store.hasInstalledIdentity() == false)
#expect(try store.loadIdentity() == nil)
// Act
try store.save(
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
// Assert re-imported from the stored bytes + passphrase.
#expect(store.hasInstalledIdentity() == true)
let identity = try #require(try store.loadIdentity())
#expect(identity.summary()?.subjectCommonName == ClientTLSFixtures.leafCommonName)
let summary = try #require(try store.loadSummary())
#expect(summary.issuerCommonName == ClientTLSFixtures.issuerCommonName)
// Act / Assert and after removal the gate closes again.
try store.remove()
#expect(store.hasInstalledIdentity() == false)
#expect(store.loadedIdentityOrNil() == nil)
}
@Test(
"a corrupt stored blob surfaces .corruptStoredBlob, and loadedIdentityOrNil swallows it",
.enabled(if: !defaultKeychainYieldsForeignIdentity())
, .keychainSerialized)
func keychainStoreCorruptBlobIsTyped() async throws {
// Arrange bytes that are not the stored JSON envelope (a partial write, or
// an item written by an older schema).
let keys = StoreKeychainKeys.unique()
defer { KeychainProbe.purge(keys) }
KeychainProbe.write(
service: keys.service, account: keys.account, data: Data("not-json".utf8)
)
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
// Act / Assert typed error for the install UI
#expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
_ = try store.loadIdentity()
}
// and launch does not crash: the convenience wrapper logs and degrades.
#expect(store.loadedIdentityOrNil() == nil)
}