243 lines
10 KiB
Swift
243 lines
10 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodexBarCore
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
@Suite(.serialized)
|
|
struct CodexOAuthRequestTests {
|
|
@Test
|
|
func `authenticated transport disables shared network state`() {
|
|
let configuration = CodexAuthenticatedHTTPTransport.makeConfiguration()
|
|
|
|
#expect(configuration.urlCache == nil)
|
|
#expect(configuration.requestCachePolicy == .reloadIgnoringLocalCacheData)
|
|
#expect(configuration.httpCookieStorage == nil)
|
|
#expect(configuration.httpShouldSetCookies == false)
|
|
#expect(configuration.urlCredentialStorage == nil)
|
|
}
|
|
|
|
@Test
|
|
func `usage requests fetch distinct cacheable responses for each account`() async throws {
|
|
defer { CodexOAuthAccountURLProtocol.reset() }
|
|
CodexOAuthAccountURLProtocol.reset()
|
|
|
|
let configuration = CodexAuthenticatedHTTPTransport.makeConfiguration()
|
|
configuration.protocolClasses = [CodexOAuthAccountURLProtocol.self]
|
|
let transport = CodexAuthenticatedHTTPTransport.makeClient(configuration: configuration)
|
|
|
|
let (refreshed, depleted) = try await CodexAuthenticatedHTTPTransport.$overrideForTesting
|
|
.withValue(transport) {
|
|
let refreshed = try await CodexOAuthUsageFetcher.fetchUsage(
|
|
accessToken: "token-a",
|
|
accountId: "account-a",
|
|
env: ["CODEX_HOME": "/tmp/codexbar-oauth-request-test"])
|
|
let depleted = try await CodexOAuthUsageFetcher.fetchUsage(
|
|
accessToken: "token-b",
|
|
accountId: "account-b",
|
|
env: ["CODEX_HOME": "/tmp/codexbar-oauth-request-test"])
|
|
return (refreshed, depleted)
|
|
}
|
|
|
|
#expect(refreshed.rateLimit?.primaryWindow?.usedPercent == 7)
|
|
#expect(refreshed.rateLimit?.secondaryWindow?.usedPercent == 9)
|
|
#expect(depleted.rateLimit?.primaryWindow?.usedPercent == 100)
|
|
#expect(depleted.rateLimit?.secondaryWindow?.usedPercent == 63)
|
|
#expect(depleted.additionalRateLimits?.first?.rateLimit?.primaryWindow?.usedPercent == 4)
|
|
|
|
let requests = CodexOAuthAccountURLProtocol.recordedRequests
|
|
#expect(requests.count == 2)
|
|
#expect(requests.allSatisfy { $0.cachePolicy == .reloadIgnoringLocalCacheData })
|
|
#expect(requests.map { $0.value(forHTTPHeaderField: "ChatGPT-Account-Id") } == ["account-a", "account-b"])
|
|
}
|
|
|
|
#if os(macOS)
|
|
@MainActor
|
|
@Test
|
|
func `dashboard cookie requests fetch distinct cacheable responses`() async {
|
|
defer { CodexOAuthAccountURLProtocol.reset() }
|
|
CodexOAuthAccountURLProtocol.reset()
|
|
|
|
let configuration = CodexAuthenticatedHTTPTransport.makeConfiguration()
|
|
configuration.protocolClasses = [CodexOAuthAccountURLProtocol.self]
|
|
let transport = CodexAuthenticatedHTTPTransport.makeClient(configuration: configuration)
|
|
|
|
let (usageA, usageB) = await CodexAuthenticatedHTTPTransport.$overrideForTesting
|
|
.withValue(transport) {
|
|
let usageA = await OpenAIDashboardFetcher.fetchDashboardUsageAPI(
|
|
cookieHeader: "session=a",
|
|
deadline: nil,
|
|
logger: { _ in })
|
|
let usageB = await OpenAIDashboardFetcher.fetchDashboardUsageAPI(
|
|
cookieHeader: "session=b",
|
|
deadline: nil,
|
|
logger: { _ in })
|
|
return (usageA, usageB)
|
|
}
|
|
|
|
#expect(usageA?.primaryLimit?.usedPercent == 7)
|
|
#expect(usageB?.primaryLimit?.usedPercent == 100)
|
|
let requests = CodexOAuthAccountURLProtocol.recordedRequests
|
|
#expect(requests.count == 2)
|
|
#expect(requests.allSatisfy { $0.cachePolicy == .reloadIgnoringLocalCacheData })
|
|
#expect(requests.map { $0.value(forHTTPHeaderField: "Cookie") } == ["session=a", "session=b"])
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `dashboard and cookie importer identity calls use isolated transport`() async throws {
|
|
defer { CodexOAuthAccountURLProtocol.reset() }
|
|
CodexOAuthAccountURLProtocol.reset()
|
|
|
|
let configuration = CodexAuthenticatedHTTPTransport.makeConfiguration()
|
|
configuration.protocolClasses = [CodexOAuthAccountURLProtocol.self]
|
|
let transport = CodexAuthenticatedHTTPTransport.makeClient(configuration: configuration)
|
|
let cookie = try #require(HTTPCookie(properties: [
|
|
.domain: "chatgpt.com",
|
|
.path: "/",
|
|
.name: "session",
|
|
.value: "a",
|
|
]))
|
|
|
|
let (dashboardEmail, importerEmail) = try await CodexAuthenticatedHTTPTransport.$overrideForTesting
|
|
.withValue(transport) {
|
|
let dashboardEmail = await OpenAIDashboardFetcher.fetchSignedInEmailFromAPI(
|
|
cookieHeader: "session=a",
|
|
deadline: nil,
|
|
logger: { _ in })
|
|
let importer = OpenAIDashboardBrowserCookieImporter(browserDetection: BrowserDetection(cacheTTL: 0))
|
|
let importerEmail = try await importer.fetchSignedInEmailFromAPI(
|
|
cookies: [cookie],
|
|
deadline: nil,
|
|
logger: { _ in })
|
|
return (dashboardEmail, importerEmail)
|
|
}
|
|
|
|
#expect(dashboardEmail == "account-a@example.com")
|
|
#expect(importerEmail == "account-a@example.com")
|
|
let requests = CodexOAuthAccountURLProtocol.recordedRequests
|
|
#expect(requests.count == 2)
|
|
#expect(requests.allSatisfy { $0.cachePolicy == .reloadIgnoringLocalCacheData })
|
|
#expect(requests.allSatisfy { $0.url?.path == "/backend-api/me" })
|
|
}
|
|
#endif
|
|
|
|
@Test
|
|
func `token refresh request uses isolated cache policy`() async throws {
|
|
let transport = ProviderHTTPTransportStub { request in
|
|
#expect(request.url?.absoluteString == "https://auth.openai.com/oauth/token")
|
|
#expect(request.httpMethod == "POST")
|
|
#expect(request.cachePolicy == .reloadIgnoringLocalCacheData)
|
|
let url = try #require(request.url)
|
|
let response = try #require(HTTPURLResponse(
|
|
url: url,
|
|
statusCode: 200,
|
|
httpVersion: nil,
|
|
headerFields: ["Cache-Control": "public, max-age=300"]))
|
|
return (Data(#"{"access_token":"new-a","refresh_token":"new-r"}"#.utf8), response)
|
|
}
|
|
let credentials = CodexOAuthCredentials(
|
|
accessToken: "old-a",
|
|
refreshToken: "old-r",
|
|
idToken: nil,
|
|
accountId: "account-a",
|
|
lastRefresh: nil)
|
|
|
|
let refreshed = try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) {
|
|
try await CodexTokenRefresher.refresh(credentials)
|
|
}
|
|
|
|
#expect(refreshed.accessToken == "new-a")
|
|
#expect(refreshed.refreshToken == "new-r")
|
|
#expect(await transport.requests().count == 1)
|
|
}
|
|
}
|
|
|
|
private final class CodexOAuthAccountURLProtocol: URLProtocol {
|
|
private(set) nonisolated(unsafe) static var recordedRequests: [URLRequest] = []
|
|
|
|
static func reset() {
|
|
self.recordedRequests = []
|
|
}
|
|
|
|
override static func canInit(with _: URLRequest) -> Bool {
|
|
true
|
|
}
|
|
|
|
override static func canonicalRequest(for request: URLRequest) -> URLRequest {
|
|
request
|
|
}
|
|
|
|
override func startLoading() {
|
|
Self.recordedRequests.append(self.request)
|
|
guard let url = self.request.url else {
|
|
self.client?.urlProtocol(self, didFailWithError: URLError(.badURL))
|
|
return
|
|
}
|
|
|
|
let accountKey = self.request.value(forHTTPHeaderField: "ChatGPT-Account-Id")
|
|
?? self.request.value(forHTTPHeaderField: "Cookie")
|
|
if self.request.url?.path == "/backend-api/me" {
|
|
let email = accountKey == "session=a" ? "account-a@example.com" : "account-b@example.com"
|
|
guard let response = HTTPURLResponse(
|
|
url: url,
|
|
statusCode: 200,
|
|
httpVersion: nil,
|
|
headerFields: ["Cache-Control": "public, max-age=300"])
|
|
else {
|
|
self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
|
|
return
|
|
}
|
|
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed)
|
|
self.client?.urlProtocol(self, didLoad: Data(#"{"email":"\#(email)"}"#.utf8))
|
|
self.client?.urlProtocolDidFinishLoading(self)
|
|
return
|
|
}
|
|
let payload: String? = switch accountKey {
|
|
case "account-a", "session=a": Self.payload(primary: 7, secondary: 9, spark: 2)
|
|
case "account-b", "session=b": Self.payload(primary: 100, secondary: 63, spark: 4)
|
|
default: nil
|
|
}
|
|
guard let payload,
|
|
let response = HTTPURLResponse(
|
|
url: url,
|
|
statusCode: 200,
|
|
httpVersion: nil,
|
|
headerFields: ["Cache-Control": "public, max-age=300"])
|
|
else {
|
|
self.client?.urlProtocol(self, didFailWithError: URLError(.userAuthenticationRequired))
|
|
return
|
|
}
|
|
|
|
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed)
|
|
self.client?.urlProtocol(self, didLoad: Data(payload.utf8))
|
|
self.client?.urlProtocolDidFinishLoading(self)
|
|
}
|
|
|
|
override func stopLoading() {}
|
|
|
|
private static func payload(primary: Int, secondary: Int, spark: Int) -> String {
|
|
"""
|
|
{
|
|
"rate_limit": {
|
|
"primary_window": {
|
|
"used_percent": \(primary), "reset_at": 1766948068, "limit_window_seconds": 18000
|
|
},
|
|
"secondary_window": {
|
|
"used_percent": \(secondary), "reset_at": 1767407914, "limit_window_seconds": 604800
|
|
}
|
|
},
|
|
"additional_rate_limits": [{
|
|
"limit_name": "GPT-5.3-Codex-Spark",
|
|
"rate_limit": {
|
|
"primary_window": {
|
|
"used_percent": \(spark), "reset_at": 1766948068, "limit_window_seconds": 18000
|
|
}
|
|
}
|
|
}]
|
|
}
|
|
"""
|
|
}
|
|
}
|