274 lines
10 KiB
Swift
274 lines
10 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodexBarCore
|
|
|
|
@Suite(.serialized)
|
|
struct ProviderHTTPClientTests {
|
|
@Test
|
|
func `default client configuration fails blocked connections promptly`() {
|
|
let configuration = ProviderHTTPClient.defaultConfiguration()
|
|
|
|
#expect(configuration.timeoutIntervalForRequest == 30)
|
|
#expect(configuration.timeoutIntervalForResource == 90)
|
|
#if !os(Linux)
|
|
#expect(configuration.waitsForConnectivity == false)
|
|
#endif
|
|
}
|
|
|
|
@Test
|
|
func `client loads requests through an injected session`() async throws {
|
|
StubURLProtocol.requests = []
|
|
StubURLProtocol.handler = { request in
|
|
StubURLProtocol.requests.append(request)
|
|
let response = try HTTPURLResponse(
|
|
url: #require(request.url),
|
|
statusCode: 200,
|
|
httpVersion: "HTTP/1.1",
|
|
headerFields: ["Content-Type": "application/json"])!
|
|
return (Data(#"{"ok":true}"#.utf8), response)
|
|
}
|
|
defer {
|
|
StubURLProtocol.handler = nil
|
|
StubURLProtocol.requests = []
|
|
}
|
|
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [StubURLProtocol.self]
|
|
let client = ProviderHTTPClient(session: URLSession(configuration: configuration))
|
|
let request = try URLRequest(url: #require(URL(string: "https://example.com/status")))
|
|
|
|
let (data, response) = try await client.data(for: request)
|
|
|
|
let body = try #require(String(data: data, encoding: .utf8))
|
|
#expect(body == #"{"ok":true}"#)
|
|
#expect((response as? HTTPURLResponse)?.statusCode == 200)
|
|
#expect(StubURLProtocol.requests.count == 1)
|
|
#expect(StubURLProtocol.requests.first?.url?.host == "example.com")
|
|
}
|
|
|
|
@Test
|
|
func `response helper unwraps HTTP responses`() async throws {
|
|
let transport = ProviderHTTPTransportHandler { request in
|
|
let response = try HTTPURLResponse(
|
|
url: #require(request.url),
|
|
statusCode: 204,
|
|
httpVersion: "HTTP/1.1",
|
|
headerFields: ["X-Test": "ok"])!
|
|
return (Data("done".utf8), response)
|
|
}
|
|
let request = try URLRequest(url: #require(URL(string: "https://example.com/ok")))
|
|
|
|
let response = try await transport.response(for: request)
|
|
|
|
#expect(response.statusCode == 204)
|
|
#expect(response.response.value(forHTTPHeaderField: "X-Test") == "ok")
|
|
#expect(String(data: response.data, encoding: .utf8) == "done")
|
|
}
|
|
|
|
@Test
|
|
func `response helper rejects non HTTP responses`() async throws {
|
|
let transport = ProviderHTTPTransportHandler { request in
|
|
let response = URLResponse(
|
|
url: request.url ?? URL(string: "https://example.com/not-http")!,
|
|
mimeType: nil,
|
|
expectedContentLength: 0,
|
|
textEncodingName: nil)
|
|
return (Data(), response)
|
|
}
|
|
let request = try URLRequest(url: #require(URL(string: "https://example.com/not-http")))
|
|
|
|
await #expect(throws: URLError.self) {
|
|
_ = try await transport.response(for: request)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `response helper retries transient HTTP status once`() async throws {
|
|
let script = ScriptedHTTPTransport(statusCodes: [503, 200])
|
|
let request = try URLRequest(url: #require(URL(string: "https://example.com/retry")))
|
|
|
|
let response = try await script.response(for: request, retryPolicy: .testOneRetry)
|
|
|
|
#expect(response.statusCode == 200)
|
|
#expect(await script.requestCount() == 2)
|
|
}
|
|
|
|
@Test
|
|
func `response helper retries transient URL error once`() async throws {
|
|
let script = ScriptedHTTPTransport(results: [
|
|
.failure(URLError(.timedOut)),
|
|
.success(200),
|
|
])
|
|
let request = try URLRequest(url: #require(URL(string: "https://example.com/retry-error")))
|
|
|
|
let response = try await script.response(for: request, retryPolicy: .testOneRetry)
|
|
|
|
#expect(response.statusCode == 200)
|
|
#expect(await script.requestCount() == 2)
|
|
}
|
|
|
|
@Test
|
|
func `response helper does not retry non idempotent methods`() async throws {
|
|
let script = ScriptedHTTPTransport(statusCodes: [503, 200])
|
|
var request = try URLRequest(url: #require(URL(string: "https://example.com/post")))
|
|
request.httpMethod = "POST"
|
|
|
|
let response = try await script.response(for: request, retryPolicy: .testOneRetry)
|
|
|
|
#expect(response.statusCode == 503)
|
|
#expect(await script.requestCount() == 1)
|
|
}
|
|
|
|
@Test
|
|
func `response helper does not retry auth failures`() async throws {
|
|
let script = ScriptedHTTPTransport(statusCodes: [403, 200])
|
|
let request = try URLRequest(url: #require(URL(string: "https://example.com/forbidden")))
|
|
|
|
let response = try await script.response(for: request, retryPolicy: .testOneRetry)
|
|
|
|
#expect(response.statusCode == 403)
|
|
#expect(await script.requestCount() == 1)
|
|
}
|
|
|
|
@Test
|
|
func `redirect guard blocks cross origin redirects`() throws {
|
|
var redirectRequest = try URLRequest(url: #require(URL(string: "https://attacker.example/capture")))
|
|
redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Cookie")
|
|
redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "x-api-key")
|
|
|
|
let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest(
|
|
originalURL: URL(string: "https://provider.example/usage"),
|
|
redirectRequest: redirectRequest)
|
|
|
|
#expect(guarded == nil)
|
|
}
|
|
|
|
@Test
|
|
func `redirect guard blocks non HTTPS redirects`() throws {
|
|
var redirectRequest = try URLRequest(url: #require(URL(string: "http://provider.example/capture")))
|
|
redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Cookie")
|
|
|
|
let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest(
|
|
originalURL: URL(string: "https://provider.example/usage"),
|
|
redirectRequest: redirectRequest)
|
|
|
|
#expect(guarded == nil)
|
|
}
|
|
|
|
@Test
|
|
func `redirect guard blocks redirects without an original URL`() throws {
|
|
let redirectRequest = try URLRequest(url: #require(URL(string: "https://provider.example/usage/next")))
|
|
|
|
let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest(
|
|
originalURL: nil,
|
|
redirectRequest: redirectRequest)
|
|
|
|
#expect(guarded == nil)
|
|
}
|
|
|
|
@Test
|
|
func `redirect guard blocks port changes`() throws {
|
|
let redirectRequest = try URLRequest(url: #require(URL(string: "https://provider.example:8443/usage")))
|
|
|
|
let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest(
|
|
originalURL: URL(string: "https://provider.example/usage"),
|
|
redirectRequest: redirectRequest)
|
|
|
|
#expect(guarded == nil)
|
|
}
|
|
|
|
@Test
|
|
func `redirect guard preserves same origin HTTPS requests`() throws {
|
|
var redirectRequest = try URLRequest(url: #require(URL(string: "https://provider.example/usage/next")))
|
|
redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Cookie")
|
|
redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Authorization")
|
|
redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "x-api-key")
|
|
redirectRequest.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
let guarded = try #require(ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest(
|
|
originalURL: URL(string: "https://provider.example/usage"),
|
|
redirectRequest: redirectRequest))
|
|
|
|
#expect(guarded.value(forHTTPHeaderField: "Cookie") == "[REDACTED]")
|
|
#expect(guarded.value(forHTTPHeaderField: "Authorization") == "[REDACTED]")
|
|
#expect(guarded.value(forHTTPHeaderField: "x-api-key") == "[REDACTED]")
|
|
#expect(guarded.value(forHTTPHeaderField: "Accept") == "application/json")
|
|
}
|
|
}
|
|
|
|
extension ProviderHTTPRetryPolicy {
|
|
fileprivate static let testOneRetry = ProviderHTTPRetryPolicy(
|
|
maxRetries: 1,
|
|
baseDelaySeconds: 0,
|
|
maxDelaySeconds: 0)
|
|
}
|
|
|
|
private actor ScriptedHTTPTransport: ProviderHTTPTransport {
|
|
enum Result {
|
|
case success(Int)
|
|
case failure(URLError)
|
|
}
|
|
|
|
private var results: [Result]
|
|
private var requests: [URLRequest] = []
|
|
|
|
init(statusCodes: [Int]) {
|
|
self.results = statusCodes.map(Result.success)
|
|
}
|
|
|
|
init(results: [Result]) {
|
|
self.results = results
|
|
}
|
|
|
|
func requestCount() -> Int {
|
|
self.requests.count
|
|
}
|
|
|
|
func data(for request: URLRequest) throws -> (Data, URLResponse) {
|
|
self.requests.append(request)
|
|
let next = self.results.isEmpty ? .success(200) : self.results.removeFirst()
|
|
switch next {
|
|
case let .success(statusCode):
|
|
let response = HTTPURLResponse(
|
|
url: request.url ?? URL(string: "https://example.com")!,
|
|
statusCode: statusCode,
|
|
httpVersion: "HTTP/1.1",
|
|
headerFields: nil)!
|
|
return (Data(#"{"ok":true}"#.utf8), response)
|
|
case let .failure(error):
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
final class StubURLProtocol: URLProtocol {
|
|
nonisolated(unsafe) static var handler: ((URLRequest) throws -> (Data, URLResponse))?
|
|
nonisolated(unsafe) static var requests: [URLRequest] = []
|
|
|
|
override static func canInit(with request: URLRequest) -> Bool {
|
|
true
|
|
}
|
|
|
|
override static func canonicalRequest(for request: URLRequest) -> URLRequest {
|
|
request
|
|
}
|
|
|
|
override func startLoading() {
|
|
guard let handler = Self.handler else {
|
|
self.client?.urlProtocol(self, didFailWithError: URLError(.cannotLoadFromNetwork))
|
|
return
|
|
}
|
|
|
|
do {
|
|
let (data, response) = try handler(self.request)
|
|
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
|
self.client?.urlProtocol(self, didLoad: data)
|
|
self.client?.urlProtocolDidFinishLoading(self)
|
|
} catch {
|
|
self.client?.urlProtocol(self, didFailWithError: error)
|
|
}
|
|
}
|
|
|
|
override func stopLoading() {}
|
|
}
|