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 Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// B4 · The URLSession seam. `ClientTLSSessionDelegate` is the object every HTTP
|
||||
// call to an mTLS host hangs off, so the one behaviour that matters is that the
|
||||
// session-level callback ALWAYS invokes the completion handler exactly once with
|
||||
// the responder's decision — a delegate that forgets to call back hangs the
|
||||
// request forever (no timeout maps to a user-visible error).
|
||||
|
||||
private func makeChallenge(method: String) -> URLAuthenticationChallenge {
|
||||
let space = URLProtectionSpace(
|
||||
host: "t1.terminal.yaojia.wang", port: 443, protocol: "https",
|
||||
realm: nil, authenticationMethod: method
|
||||
)
|
||||
return URLAuthenticationChallenge(
|
||||
protectionSpace: space, proposedCredential: nil, previousFailureCount: 0,
|
||||
failureResponse: nil, error: nil, sender: DelegateTestSender()
|
||||
)
|
||||
}
|
||||
|
||||
/// The responder never calls back into the sender; it only reads the protection
|
||||
/// space. Present because the designated initializer demands a non-optional one.
|
||||
private final class DelegateTestSender: NSObject, URLAuthenticationChallengeSender {
|
||||
func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {}
|
||||
func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {}
|
||||
func cancel(_ challenge: URLAuthenticationChallenge) {}
|
||||
}
|
||||
|
||||
/// Drive the delegate and capture what it handed back.
|
||||
private func resolve(
|
||||
identity: ClientIdentity?, method: String
|
||||
) -> (calls: Int, disposition: URLSession.AuthChallengeDisposition?, credential: URLCredential?) {
|
||||
let delegate = ClientTLSSessionDelegate(identity: identity)
|
||||
var calls = 0
|
||||
var disposition: URLSession.AuthChallengeDisposition?
|
||||
var credential: URLCredential?
|
||||
delegate.urlSession(
|
||||
URLSession.shared, didReceive: makeChallenge(method: method)
|
||||
) { receivedDisposition, receivedCredential in
|
||||
calls += 1
|
||||
disposition = receivedDisposition
|
||||
credential = receivedCredential
|
||||
}
|
||||
return (calls, disposition, credential)
|
||||
}
|
||||
|
||||
@Test("a ClientCertificate challenge answers once with the installed identity")
|
||||
func delegateAnswersClientCertificateWithIdentity() throws {
|
||||
// Arrange
|
||||
let identity = try PKCS12Importer.importIdentity(
|
||||
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
|
||||
// Act
|
||||
let result = resolve(
|
||||
identity: identity, method: NSURLAuthenticationMethodClientCertificate
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(result.calls == 1) // exactly once — never zero (hang) or twice (crash)
|
||||
#expect(result.disposition == .useCredential)
|
||||
#expect(result.credential?.identity != nil)
|
||||
}
|
||||
|
||||
@Test("a ClientCertificate challenge with no identity cancels instead of hanging")
|
||||
func delegateCancelsWithoutIdentity() {
|
||||
// Act
|
||||
let result = resolve(identity: nil, method: NSURLAuthenticationMethodClientCertificate)
|
||||
|
||||
// Assert
|
||||
#expect(result.calls == 1)
|
||||
#expect(result.disposition == .cancelAuthenticationChallenge)
|
||||
#expect(result.credential == nil)
|
||||
}
|
||||
|
||||
@Test("server-trust challenges fall through to the system evaluation")
|
||||
func delegateDefersServerTrust() throws {
|
||||
// Arrange
|
||||
let identity = try PKCS12Importer.importIdentity(
|
||||
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
|
||||
// Act / Assert — the client cert must never be offered as a server-trust
|
||||
// answer, and pinning is NOT this package's job (plan §5: default handling).
|
||||
for candidate in [identity, nil] as [ClientIdentity?] {
|
||||
let result = resolve(identity: candidate, method: NSURLAuthenticationMethodServerTrust)
|
||||
#expect(result.calls == 1)
|
||||
#expect(result.disposition == .performDefaultHandling)
|
||||
#expect(result.credential == nil)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user