Files
web-terminal/ios/Packages/ClientTLS/Tests/ClientTLSTests/ClientTLSSessionDelegateTests.swift
Yaojia Wang a5fa843f00 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.
2026-07-30 12:45:26 +02:00

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