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,315 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// B4 · The Secure-Enclave enrollment half of `KeychainClientIdentityStore`:
|
||||
// `enroll`, `renew`, `renewalState`. What is pinned here is the ORCHESTRATION —
|
||||
// which key signs the CSR, which endpoint it goes to, what the request body is
|
||||
// allowed to contain, and what happens to local state when the server's answer
|
||||
// is unusable. The signing key is injected (`keyProvider`) or pre-installed
|
||||
// under the store's derived tag, so no Secure Enclave is needed.
|
||||
//
|
||||
// Every test stops at the leaf-installation step by having the server return an
|
||||
// undecodable certificate (`undecodableCertificateDER`) — see the note there for
|
||||
// why installing a real leaf is not possible in a macOS `swift test` run. The
|
||||
// happy-path leaf install + `SecItemCopyMatching(kSecClassIdentity)` assembly
|
||||
// stays a device/simulator concern.
|
||||
|
||||
private let controlPlaneURL = URL(string: "https://cp.terminal.yaojia.wang")!
|
||||
|
||||
@Test("renewalState is nil before any enrollment", .keychainSerialized)
|
||||
func renewalStateNilWhenNotEnrolled() async throws {
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
|
||||
#expect(try store.renewalState() == nil)
|
||||
}
|
||||
|
||||
@Test("renewalState reads back the persisted enrollment record verbatim", .keychainSerialized)
|
||||
func renewalStateReadsPersistedRecord() async throws {
|
||||
// Arrange — the record layout is a migration contract, so it is seeded
|
||||
// directly and read back through the public API.
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let notAfter = Date(timeIntervalSinceReferenceDate: 800_000_000)
|
||||
let renewAfter = Date(timeIntervalSinceReferenceDate: 790_000_000)
|
||||
KeychainProbe.write(
|
||||
service: keys.service, account: keys.enrollmentAccount,
|
||||
data: storedEnrollmentJSON(
|
||||
deviceId: "dev-42", deviceName: "Yaojia iPhone",
|
||||
caChain: [Data([0x30, 0xAA])], notAfter: notAfter, renewAfter: renewAfter
|
||||
)
|
||||
)
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
|
||||
// Act
|
||||
let state = try #require(try store.renewalState())
|
||||
|
||||
// Assert — deviceId drives POST /device/:id/renew; the dates drive the
|
||||
// scheduler's decision.
|
||||
#expect(state.deviceId == "dev-42")
|
||||
#expect(state.notAfter == notAfter)
|
||||
#expect(state.renewAfter == renewAfter)
|
||||
#expect(state.isRenewalDue(asOf: renewAfter.addingTimeInterval(1)))
|
||||
#expect(state.isRenewalDue(asOf: renewAfter.addingTimeInterval(-1)) == false)
|
||||
}
|
||||
|
||||
@Test("a record with no renewAfter never reports renewal due (fail-safe)", .keychainSerialized)
|
||||
func renewalStateWithoutRenewAfterNeverDue() async throws {
|
||||
// Arrange — an older server that omitted the advisory field.
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
KeychainProbe.write(
|
||||
service: keys.service, account: keys.enrollmentAccount,
|
||||
data: storedEnrollmentJSON(deviceId: "dev-legacy", deviceName: "iPad")
|
||||
)
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
|
||||
// Act
|
||||
let state = try #require(try store.renewalState())
|
||||
|
||||
// Assert — the TLS stack stays the real gate; the scheduler must not guess.
|
||||
#expect(state.notAfter == nil)
|
||||
#expect(state.renewAfter == nil)
|
||||
#expect(state.isRenewalDue(asOf: Date.distantFuture) == false)
|
||||
}
|
||||
|
||||
@Test("an undecodable enrollment record surfaces .corruptStoredBlob", .keychainSerialized)
|
||||
func renewalStateCorruptRecord() async throws {
|
||||
// Arrange
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
KeychainProbe.write(
|
||||
service: keys.service, account: keys.enrollmentAccount,
|
||||
data: Data("{\"deviceId\":".utf8) // truncated JSON
|
||||
)
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
|
||||
// Act / Assert — never a silent "not enrolled" (that would make the
|
||||
// scheduler skip rotation forever on a device that IS enrolled).
|
||||
#expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try store.renewalState()
|
||||
}
|
||||
}
|
||||
|
||||
@Test("enroll signs the CSR with the injected key and posts it to /device/enroll", .keychainSerialized)
|
||||
func enrollPostsCSRSignedByTheDeviceKey() async throws {
|
||||
// Arrange
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let deviceKey = try SecureEnclaveKeyFactory.generateSoftware()
|
||||
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||
let client = DeviceEnrollmentClient(
|
||||
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
|
||||
)
|
||||
|
||||
// Act — the response's leaf is undecodable, so persistence fails; the CSR has
|
||||
// already been built and sent, which is what this test is about.
|
||||
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try await store.enroll(
|
||||
using: client, subdomain: "yaojia", deviceName: "Yaojia iPhone",
|
||||
keyProvider: { deviceKey }
|
||||
)
|
||||
}
|
||||
|
||||
// Assert — one request, to the enroll endpoint, carrying the A4 body.
|
||||
#expect(transport.callCount == 1)
|
||||
let request = try #require(transport.requests.first)
|
||||
#expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/enroll")
|
||||
let body = try #require(transport.jsonBody(at: 0))
|
||||
#expect(body["subdomain"] as? String == "yaojia")
|
||||
#expect(body["deviceName"] as? String == "Yaojia iPhone")
|
||||
#expect(body["keyAlg"] as? String == "ec-p256")
|
||||
|
||||
// …and the CSR is bound to the injected key, with the device name as CN.
|
||||
let csrDER = try #require(transport.csrDER(at: 0))
|
||||
let csr = try #require(parseCSR(csrDER))
|
||||
#expect(csr.subjectCommonName == "Yaojia iPhone")
|
||||
#expect(csr.publicPointX963 == (try deviceKey.publicKeyX963()))
|
||||
}
|
||||
|
||||
@Test("an undecodable leaf leaves NO enrollment record behind", .keychainSerialized)
|
||||
func enrollDoesNotPersistOnUndecodableLeaf() async throws {
|
||||
// Arrange
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||
let client = DeviceEnrollmentClient(
|
||||
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
|
||||
)
|
||||
|
||||
// Act
|
||||
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try await store.enroll(
|
||||
using: client, subdomain: "yaojia", deviceName: "iPhone",
|
||||
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||
)
|
||||
}
|
||||
|
||||
// Assert — a half-written record would make `renewalState` claim an
|
||||
// enrollment that has no leaf, and the scheduler would renew a phantom.
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
|
||||
#expect(try store.renewalState() == nil)
|
||||
}
|
||||
|
||||
@Test("a rejected enrollment (403) propagates the server error and stores nothing", .keychainSerialized)
|
||||
func enrollPropagatesServerRejection() async throws {
|
||||
// Arrange — subdomain not owned by the account.
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let body = try JSONSerialization.data(withJSONObject: ["error": "subdomain_not_owned"])
|
||||
let transport = RecordingEnrollmentTransport(status: 403, body: body)
|
||||
let client = DeviceEnrollmentClient(
|
||||
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
|
||||
)
|
||||
|
||||
// Act / Assert
|
||||
await #expect(
|
||||
throws: DeviceEnrollmentError.http(status: 403, code: "subdomain_not_owned")
|
||||
) {
|
||||
_ = try await store.enroll(
|
||||
using: client, subdomain: "someone-else", deviceName: "iPhone",
|
||||
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||
)
|
||||
}
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
|
||||
}
|
||||
|
||||
@Test("renew without an enrollment record fails locally and never calls the server", .keychainSerialized)
|
||||
func renewWithoutRecordDoesNotCallServer() async throws {
|
||||
// Arrange
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||
let client = DeviceEnrollmentClient(baseURL: controlPlaneURL, transport: transport)
|
||||
|
||||
// Act / Assert — nothing to renew: a fresh install or a legacy .p12-only
|
||||
// device. The scheduler must not fire a request it cannot authenticate.
|
||||
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try await store.renew(using: client)
|
||||
}
|
||||
#expect(transport.callCount == 0)
|
||||
}
|
||||
|
||||
@Test("renew without the device key fails locally and never calls the server", .keychainSerialized)
|
||||
func renewWithoutDeviceKeyDoesNotCallServer() async throws {
|
||||
// Arrange — a record exists but the Secure-Enclave key is gone (restored
|
||||
// backup / manually cleared keychain).
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
KeychainProbe.write(
|
||||
service: keys.service, account: keys.enrollmentAccount,
|
||||
data: storedEnrollmentJSON(deviceId: "dev-99", deviceName: "iPhone")
|
||||
)
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||
let client = DeviceEnrollmentClient(baseURL: controlPlaneURL, transport: transport)
|
||||
|
||||
// Act / Assert — a renew CSR signed by a NEW key would be rejected by the
|
||||
// server (PoP against the enrolled key), so it must not be attempted.
|
||||
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try await store.renew(using: client)
|
||||
}
|
||||
#expect(transport.callCount == 0)
|
||||
}
|
||||
|
||||
@Test("renew re-signs with the SAME device key and posts a csr-only body to /device/:id/renew", .keychainSerialized)
|
||||
func renewReusesTheEnrolledDeviceKey() async throws {
|
||||
// Arrange — an enrolled device: record + the permanent key under the store's
|
||||
// derived tag (the shape `enroll` leaves behind).
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let enrolledKey = try SecureEnclaveKeyFactory.generateSoftware(
|
||||
tag: keys.deviceKeyTag, permanent: true
|
||||
)
|
||||
let originalRecord = storedEnrollmentJSON(
|
||||
deviceId: "dev-77", deviceName: "Yaojia iPad",
|
||||
notAfter: Date(timeIntervalSinceReferenceDate: 800_000_000),
|
||||
renewAfter: Date(timeIntervalSinceReferenceDate: 790_000_000)
|
||||
)
|
||||
KeychainProbe.write(
|
||||
service: keys.service, account: keys.enrollmentAccount, data: originalRecord
|
||||
)
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||
// Nil bearer: the renew endpoint authenticates by the CURRENT client cert.
|
||||
let client = DeviceEnrollmentClient(baseURL: controlPlaneURL, transport: transport)
|
||||
|
||||
// Act
|
||||
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try await store.renew(using: client)
|
||||
}
|
||||
|
||||
// Assert — the endpoint carries the stored deviceId…
|
||||
#expect(transport.callCount == 1)
|
||||
let request = try #require(transport.requests.first)
|
||||
#expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/dev-77/renew")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
|
||||
|
||||
// …the body is `{ csr }` ONLY (a stray field 400s every silent renewal)…
|
||||
let body = try #require(transport.jsonBody(at: 0))
|
||||
#expect(Set(body.keys) == ["csr"])
|
||||
|
||||
// …and the CSR re-uses the ENROLLED key (a new key would fail the server's
|
||||
// PoP-against-the-enrolled-key check) with the stored device name as CN.
|
||||
let csrDER = try #require(transport.csrDER(at: 0))
|
||||
let csr = try #require(parseCSR(csrDER))
|
||||
#expect(csr.publicPointX963 == (try enrolledKey.publicKeyX963()))
|
||||
#expect(csr.subjectCommonName == "Yaojia iPad")
|
||||
|
||||
// …and a failed rotation leaves the EXISTING enrollment intact.
|
||||
let state = try #require(try store.renewalState())
|
||||
#expect(state.deviceId == "dev-77")
|
||||
#expect(state.renewAfter == Date(timeIntervalSinceReferenceDate: 790_000_000))
|
||||
}
|
||||
|
||||
@Test("the flow's production install step is wired to the keychain store's SE enroll", .keychainSerialized)
|
||||
func enrollmentFlowWiresTheKeychainStore() async throws {
|
||||
// Arrange — the `store:` convenience initializer is the composition the app
|
||||
// uses: login → bearer → enroll → install. Its install step deliberately
|
||||
// takes NO keyProvider, i.e. it always asks for a real Secure-Enclave key.
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let loginBody = try JSONSerialization.data(withJSONObject: [
|
||||
"enrollToken": "enroll-bearer", "accountId": "acct-1", "expiresIn": 300,
|
||||
])
|
||||
let transport = RecordingEnrollmentTransport(replies: [
|
||||
.init(status: 201, body: loginBody),
|
||||
.init(status: 201, body: enrollmentResponseJSON()),
|
||||
])
|
||||
let flow = DeviceEnrollmentFlow(
|
||||
baseURL: controlPlaneURL, transport: transport, store: store
|
||||
)
|
||||
|
||||
// Act
|
||||
var thrown: Error?
|
||||
do {
|
||||
_ = try await flow.run(
|
||||
password: "account-password", subdomain: "yaojia", deviceName: "iPhone"
|
||||
)
|
||||
} catch {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
// Assert — login happened first, with the password and nothing else…
|
||||
let loginRequest = try #require(transport.requests.first)
|
||||
#expect(loginRequest.url?.path == "/auth/login")
|
||||
#expect(Set(try #require(transport.jsonBody(at: 0)).keys) == ["password"])
|
||||
|
||||
// …and the failure came from the INSTALL step, not the login step: the
|
||||
// convenience initializer really does route into the keychain store. Off
|
||||
// device (macOS `swift test`, Simulator) `generateSecureEnclave` cannot mint
|
||||
// a key, so `.secureEnclaveUnavailable` is the expected stop; an entitled
|
||||
// host gets as far as the undecodable leaf (`.corruptStoredBlob`).
|
||||
let error = try #require(thrown)
|
||||
#expect(error is SecureEnclaveKeyError || error is ClientIdentityStoreError)
|
||||
#expect(error is ControlPlaneLoginError == false)
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
|
||||
}
|
||||
Reference in New Issue
Block a user