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 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)
|
|
}
|
|
}
|