Files
steipete--codexbar/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift
2026-07-13 12:22:33 +08:00

722 lines
29 KiB
Swift

import Foundation
import Testing
@testable import CodexBarCore
@Suite(.serialized)
struct CopilotBudgetWebFetcherTests {
@Test
func `maps positive copilot budgets to extra rate windows`() {
let budgets: [CopilotBudgetWebFetcher.Budget] = [
.init(
id: "product-budget",
budgetProductSkus: ["copilot"],
budgetAmount: 100,
currentAmount: 15),
.init(
id: "agent-budget",
budgetProductSkus: ["copilot_agent_premium_request"],
budgetAmount: 20,
currentAmount: 5),
.init(
id: "zero-budget",
budgetProductSkus: ["spark_premium_request"],
budgetAmount: 0,
currentAmount: 0),
]
let windows = CopilotBudgetWebFetcher.extraRateWindows(
from: budgets,
now: Date(timeIntervalSince1970: 1_780_358_400))
#expect(windows.map(\.id) == ["copilot-budget-product-budget", "copilot-budget-agent-budget"])
#expect(windows.map(\.title) == ["Budget - Copilot", "Budget - Copilot Agent Premium Requests"])
#expect(windows[0].window.usedPercent == 15)
#expect(windows[1].window.usedPercent == 25)
#expect(windows.allSatisfy { $0.window.resetsAt != nil })
}
@Test
func `decodes github web budget response shape`() throws {
let data = Data("""
{
"payload": {
"budgets": [
{
"uuid": "budget-1",
"targetName": "Example",
"pricingTargetType": "BundlePricing",
"pricingTargetId": "premium_requests",
"targetAmount": 30.0,
"currentAmount": 0.0
}
],
"has_next_page": false
}
}
""".utf8)
let response = try JSONDecoder().decode(CopilotBudgetWebFetcher.BudgetResponse.self, from: data)
let budget = try #require(response.budgets.first)
#expect(response.hasNextPage == false)
#expect(budget.id == "budget-1")
#expect(budget.budgetEntityName == "Example")
#expect(budget.budgetAmount == 30)
#expect(budget.currentAmount == 0)
let windows = CopilotBudgetWebFetcher.extraRateWindows(
from: response.budgets,
now: Date(timeIntervalSince1970: 1_780_358_400))
#expect(windows.map(\.title) == ["Budget - All Premium Request SKUs"])
#expect(windows.first?.window.usedPercent == 0)
}
@Test
func `ignores malformed embedded minus amounts`() throws {
let data = Data("""
{
"budgets": [
{
"uuid": "budget-1",
"pricingTargetId": "premium_requests",
"targetAmount": "1-5",
"currentAmount": "$5.00"
},
{
"uuid": "budget-2",
"pricingTargetId": "premium_requests",
"targetAmount": "-$15.00",
"currentAmount": "$5.00"
}
]
}
""".utf8)
let response = try JSONDecoder().decode(CopilotBudgetWebFetcher.BudgetResponse.self, from: data)
#expect(response.budgets.map(\.budgetAmount) == [0, -15])
#expect(CopilotBudgetWebFetcher.extraRateWindows(
from: response.budgets,
now: Date(timeIntervalSince1970: 1_780_358_400)).isEmpty)
}
@Test
func `normalizes documented copilot billing names`() {
#expect(CopilotBudgetWebFetcher.normalizedBillingIdentifier("Copilot") == "copilot")
#expect(
CopilotBudgetWebFetcher.normalizedBillingIdentifier("Copilot Premium Request") ==
"copilot_premium_request")
#expect(
CopilotBudgetWebFetcher.normalizedBillingIdentifier("Copilot Agent Premium Request") ==
"copilot_agent_premium_request")
#expect(
CopilotBudgetWebFetcher.normalizedBillingIdentifier("Spark Premium Request") ==
"spark_premium_request")
#expect(
CopilotBudgetWebFetcher.normalizedBillingIdentifier("Premium requests") ==
"copilot_premium_request")
#expect(
CopilotBudgetWebFetcher.normalizedBillingIdentifier("Bundled premium request budget") ==
"copilot_premium_request")
#expect(
CopilotBudgetWebFetcher.normalizedBillingIdentifier("Copilot cloud agent premium requests") ==
"copilot_agent_premium_request")
#expect(
CopilotBudgetWebFetcher.normalizedBillingIdentifier("coding_agent_premium_request") ==
"copilot_agent_premium_request")
}
@Test
func `extracts github fetch nonce from html`() {
let html = #"<meta name="x-fetch-nonce" content="v2:abc-123">"#
#expect(CopilotBudgetWebFetcher.extractFetchNonce(from: html) == "v2:abc-123")
}
@Test
func `extracts github web identity from html`() throws {
let html = """
<meta name="octolytics-actor-id" content="123">
<meta content = "octocat" name = "user-login">
"""
let identity = try #require(CopilotBudgetWebFetcher.extractGitHubWebIdentity(from: html))
#expect(identity.id == "123")
#expect(identity.login == "octocat")
#expect(CopilotBudgetWebFetcher.webIdentity(identity, matches: "github:user:123"))
#expect(CopilotBudgetWebFetcher.webIdentity(identity, matches: "OctoCat"))
#expect(!CopilotBudgetWebFetcher.webIdentity(identity, matches: "github:user:456"))
}
@Test
func `missing github web identity with expected account maps to unknown account mismatch`() async throws {
let transport = ProviderHTTPTransportStub { request in
guard let url = request.url,
let response = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: nil)
else {
throw URLError(.badServerResponse)
}
if request.url?.query?.contains("page=") == true {
Issue.record("Missing identity should not reach budget JSON endpoint")
return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response)
}
return (Data(#"<meta name="x-fetch-nonce" content="nonce">"#.utf8), response)
}
let fetcher = CopilotBudgetWebFetcher(
cookieHeaderOverride: "user_session=missing-identity",
expectedGitHubAccountIdentifier: "github:user:123",
transport: transport)
do {
_ = try await fetcher.fetchBudgetWindows()
Issue.record("Expected account mismatch")
} catch let error as CopilotBudgetWebFetcher.Error {
#expect(error == .accountMismatch(expected: "github:user:123", actual: nil))
}
#expect(await transport.requests().count == 1)
}
@Test
func `invalid github budget page html encoding maps to invalid response`() async throws {
let transport = ProviderHTTPTransportStub { request in
guard let url = request.url,
let response = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: nil)
else {
throw URLError(.badServerResponse)
}
if request.url?.query?.contains("page=") == true {
Issue.record("Invalid HTML encoding should not reach budget JSON endpoint")
return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response)
}
return (Data([0xC3, 0x28]), response)
}
let fetcher = CopilotBudgetWebFetcher(
cookieHeaderOverride: "user_session=invalid-html",
expectedGitHubAccountIdentifier: "github:user:123",
transport: transport)
do {
_ = try await fetcher.fetchBudgetWindows()
Issue.record("Expected invalid response")
} catch let error as CopilotBudgetWebFetcher.Error {
#expect(error == .invalidResponse)
}
#expect(await transport.requests().count == 1)
}
@Test
func `manual budget cookie for different github account is ignored before budget request`() async throws {
let transport = ProviderHTTPTransportStub { request in
guard let url = request.url,
let response = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: nil)
else {
throw URLError(.badServerResponse)
}
if request.url?.query?.contains("page=") == true {
Issue.record("Mismatched cookie should not reach budget JSON endpoint")
return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response)
}
return (
Data("""
<meta name="x-fetch-nonce" content="nonce">
<meta name="octolytics-actor-id" content="456">
<meta name="user-login" content="otheruser">
""".utf8),
response)
}
let fetcher = CopilotBudgetWebFetcher(
cookieHeaderOverride: "user_session=other",
expectedGitHubAccountIdentifier: "github:user:123",
transport: transport)
do {
_ = try await fetcher.fetchBudgetWindows()
Issue.record("Expected account mismatch")
} catch let error as CopilotBudgetWebFetcher.Error {
#expect(error == .accountMismatch(expected: "github:user:123", actual: "otheruser"))
}
#expect(await transport.requests().count == 1)
}
@Test
func `manual budget cookie with matching github account appends budget windows`() async throws {
let transport = ProviderHTTPTransportStub { request in
guard let url = request.url,
let response = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: nil)
else {
throw URLError(.badServerResponse)
}
if request.url?.query?.contains("page=") == true {
return (
Data("""
{
"budgets": [
{
"uuid": "budget-1",
"pricingTargetId": "premium_requests",
"targetAmount": 100.0,
"currentAmount": 40.0
}
],
"has_next_page": false
}
""".utf8),
response)
}
return (
Data("""
<meta name="x-fetch-nonce" content="nonce">
<meta name="octolytics-actor-id" content="123">
<meta name="user-login" content="octocat">
""".utf8),
response)
}
let fetcher = CopilotBudgetWebFetcher(
cookieHeaderOverride: "user_session=matching",
expectedGitHubAccountIdentifier: "github:user:123",
transport: transport,
now: { Date(timeIntervalSince1970: 1_780_358_400) })
let windows = try await fetcher.fetchBudgetWindows()
#expect(windows.map(\.id) == ["copilot-budget-budget-1"])
#expect(windows.first?.window.usedPercent == 40)
#expect(await transport.requests().count == 2)
}
@Test
func `mismatched manual budget cookie leaves normal copilot usage unchanged`() async {
let registered = URLProtocol.registerClass(CopilotBudgetBindingStubURLProtocol.self)
defer {
if registered {
URLProtocol.unregisterClass(CopilotBudgetBindingStubURLProtocol.self)
}
CopilotBudgetBindingStubURLProtocol.reset()
}
CopilotBudgetBindingStubURLProtocol.reset()
CopilotBudgetBindingStubURLProtocol.handler = { request in
guard let url = request.url else {
throw URLError(.badURL)
}
if url.host == "api.github.com", url.path == "/copilot_internal/user" {
return Self.stubResponse(
url: url,
data: Data("""
{
"quota_snapshots": {
"premium_interactions": {
"entitlement": 300,
"remaining": 240,
"percent_remaining": 80,
"quota_id": "premium"
}
},
"copilot_plan": "pro"
}
""".utf8))
}
if url.host == "api.github.com", url.path == "/user" {
return Self.stubResponse(
url: url,
data: Data(#"{"id":123,"login":"expecteduser"}"#.utf8))
}
if url.host == "github.com", url.path == "/settings/billing/budgets", url.query == nil {
return Self.stubResponse(
url: url,
data: Data("""
<meta name="x-fetch-nonce" content="nonce">
<meta name="octolytics-actor-id" content="456">
<meta name="user-login" content="otheruser">
""".utf8))
}
Issue.record("Unexpected request: \(url.absoluteString)")
return Self.stubResponse(url: url, data: Data("{}".utf8), statusCode: 404)
}
let descriptor = ProviderDescriptorRegistry.descriptor(for: .copilot)
let settings = ProviderSettingsSnapshot.make(copilot: .init(
apiToken: "selected-token",
selectedAccountExternalIdentifier: "github:user:123",
budgetExtrasEnabled: true,
budgetCookieSource: .manual,
manualBudgetCookieHeader: "user_session=other"))
let context = Self.makeFetchContext(settings: settings)
let outcome = await descriptor.fetchPlan.fetchOutcome(context: context, provider: .copilot)
guard case let .success(result) = outcome.result else {
Issue.record("Expected Copilot usage fetch to succeed")
return
}
#expect(result.usage.primary?.usedPercent == 20)
#expect(result.usage.extraRateWindows == nil)
#expect(CopilotBudgetBindingStubURLProtocol.requests().contains {
$0.url?.query?.contains("page=") == true
} == false)
}
@Test
func `stale selected account identifier is ignored for budget cookie binding`() async {
let registered = URLProtocol.registerClass(CopilotBudgetBindingStubURLProtocol.self)
defer {
if registered {
URLProtocol.unregisterClass(CopilotBudgetBindingStubURLProtocol.self)
}
CopilotBudgetBindingStubURLProtocol.reset()
}
CopilotBudgetBindingStubURLProtocol.reset()
CopilotBudgetBindingStubURLProtocol.handler = { request in
guard let url = request.url else {
throw URLError(.badURL)
}
if url.host == "api.github.com", url.path == "/copilot_internal/user" {
return Self.stubResponse(
url: url,
data: Data("""
{
"quota_snapshots": {
"premium_interactions": {
"entitlement": 300,
"remaining": 240,
"percent_remaining": 80,
"quota_id": "premium"
}
},
"copilot_plan": "pro"
}
""".utf8))
}
if url.host == "api.github.com", url.path == "/user" {
return Self.stubResponse(
url: url,
data: Data(#"{"id":999,"login":"newuser"}"#.utf8))
}
if url.host == "github.com", url.path == "/settings/billing/budgets", url.query == nil {
return Self.stubResponse(
url: url,
data: Data("""
<meta name="x-fetch-nonce" content="nonce">
<meta name="octolytics-actor-id" content="123">
<meta name="user-login" content="olduser">
""".utf8))
}
Issue.record("Unexpected request: \(url.absoluteString)")
return Self.stubResponse(url: url, data: Data("{}".utf8), statusCode: 404)
}
let descriptor = ProviderDescriptorRegistry.descriptor(for: .copilot)
let settings = ProviderSettingsSnapshot.make(copilot: .init(
apiToken: "new-selected-token",
selectedAccountExternalIdentifier: "github:user:123",
budgetExtrasEnabled: true,
budgetCookieSource: .manual,
manualBudgetCookieHeader: "user_session=old-browser-account"))
let context = Self.makeFetchContext(settings: settings)
let outcome = await descriptor.fetchPlan.fetchOutcome(context: context, provider: .copilot)
guard case let .success(result) = outcome.result else {
Issue.record("Expected Copilot usage fetch to succeed")
return
}
#expect(result.usage.primary?.usedPercent == 20)
#expect(result.usage.extraRateWindows == nil)
#expect(CopilotBudgetBindingStubURLProtocol.requests().contains {
$0.url?.path == "/user"
})
#expect(CopilotBudgetBindingStubURLProtocol.requests().contains {
$0.url?.query?.contains("page=") == true
} == false)
}
@Test
func `invalid github budget JSON maps to invalid response`() async throws {
let transport = ProviderHTTPTransportStub { request in
guard let url = request.url,
let response = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: nil)
else {
throw URLError(.badServerResponse)
}
if request.url?.query?.contains("page=") == true {
return (Data("{".utf8), response)
}
return (Data(#"<meta name="x-fetch-nonce" content="nonce">"#.utf8), response)
}
let fetcher = CopilotBudgetWebFetcher(
transport: transport,
now: { Date(timeIntervalSince1970: 1_780_358_400) })
do {
_ = try await fetcher.fetchBudgetWindows(cookieHeader: "user_session=abc")
Issue.record("Expected invalidResponse")
} catch let error as CopilotBudgetWebFetcher.Error {
#expect(error == .invalidResponse)
}
}
@Test
func `cached cookie non auth errors do not fall back to browser import`() async throws {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
CookieHeaderCache.store(provider: .copilot, cookieHeader: "user_session=cached", sourceLabel: "Chrome")
defer { CookieHeaderCache.clear(provider: .copilot) }
let transport = ProviderHTTPTransportStub { request in
guard let url = request.url,
let response = HTTPURLResponse(
url: url,
statusCode: 500,
httpVersion: "HTTP/1.1",
headerFields: nil)
else {
throw URLError(.badServerResponse)
}
return (Data("{}".utf8), response)
}
let fetcher = CopilotBudgetWebFetcher(transport: transport)
do {
_ = try await fetcher.fetchBudgetWindows()
Issue.record("Expected badStatus")
} catch let error as CopilotBudgetWebFetcher.Error {
#expect(error == .badStatus(500))
}
#expect(await transport.requests().count == 2)
#expect(CookieHeaderCache.load(provider: .copilot)?.cookieHeader == "user_session=cached")
}
@Test
func `cached cookie account mismatch clears cache before browser fallback`() async throws {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
CookieHeaderCache.store(provider: .copilot, cookieHeader: "user_session=cached", sourceLabel: "Chrome")
defer { CookieHeaderCache.clear(provider: .copilot) }
let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: temp) }
let transport = ProviderHTTPTransportStub { request in
guard let url = request.url,
let response = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: nil)
else {
throw URLError(.badServerResponse)
}
if request.url?.query?.contains("page=") == true {
Issue.record("Mismatched cached cookie should not reach budget JSON endpoint")
return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response)
}
return (
Data("""
<meta name="x-fetch-nonce" content="nonce">
<meta name="octolytics-actor-id" content="456">
<meta name="user-login" content="otheruser">
""".utf8),
response)
}
let fetcher = CopilotBudgetWebFetcher(
expectedGitHubAccountIdentifier: "github:user:123",
browserDetection: BrowserDetection(homeDirectory: temp.path, cacheTTL: 0),
transport: transport)
do {
_ = try await fetcher.fetchBudgetWindows()
Issue.record("Expected browser fallback to exhaust without a session")
} catch let error as CopilotBudgetWebFetcher.Error {
#expect(error == .noSessionCookie)
}
#expect(await transport.requests().count == 1)
#expect(CookieHeaderCache.load(provider: .copilot) == nil)
}
@Test
func `cached cookie missing identity clears cache before browser fallback`() async throws {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
CookieHeaderCache.store(provider: .copilot, cookieHeader: "user_session=cached", sourceLabel: "Chrome")
defer { CookieHeaderCache.clear(provider: .copilot) }
let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: temp) }
let transport = ProviderHTTPTransportStub { request in
guard let url = request.url,
let response = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: nil)
else {
throw URLError(.badServerResponse)
}
if request.url?.query?.contains("page=") == true {
Issue.record("Unverifiable cached cookie should not reach budget JSON endpoint")
return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response)
}
return (Data(#"<meta name="x-fetch-nonce" content="nonce">"#.utf8), response)
}
let fetcher = CopilotBudgetWebFetcher(
expectedGitHubAccountIdentifier: "github:user:123",
browserDetection: BrowserDetection(homeDirectory: temp.path, cacheTTL: 0),
transport: transport)
do {
_ = try await fetcher.fetchBudgetWindows()
Issue.record("Expected browser fallback to exhaust without a session")
} catch let error as CopilotBudgetWebFetcher.Error {
#expect(error == .noSessionCookie)
}
#expect(await transport.requests().count == 1)
#expect(CookieHeaderCache.load(provider: .copilot) == nil)
}
@Test
func `budget page request omits content type on get`() async throws {
let transport = ProviderHTTPTransportStub { request in
guard let url = request.url,
let response = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: nil)
else {
throw URLError(.badServerResponse)
}
if request.url?.query?.contains("page=") == true {
return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response)
}
return (Data(#"<meta name="x-fetch-nonce" content="nonce">"#.utf8), response)
}
let fetcher = CopilotBudgetWebFetcher(transport: transport)
_ = try await fetcher.fetchBudgetWindows(cookieHeader: "user_session=abc")
let pageRequest = try #require(await transport.requests().first { $0.url?.query?.contains("page=") == true })
#expect(pageRequest.value(forHTTPHeaderField: "Content-Type") == nil)
}
private static func makeFetchContext(settings: ProviderSettingsSnapshot) -> ProviderFetchContext {
let browserDetection = BrowserDetection(cacheTTL: 0)
return ProviderFetchContext(
runtime: .app,
sourceMode: .api,
includeCredits: false,
webTimeout: 1,
webDebugDumpHTML: false,
verbose: false,
env: [:],
settings: settings,
fetcher: UsageFetcher(environment: [:]),
claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection),
browserDetection: browserDetection)
}
private static func stubResponse(
url: URL,
data: Data,
statusCode: Int = 200) -> (Data, URLResponse)
{
let response = HTTPURLResponse(
url: url,
statusCode: statusCode,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"])!
return (data, response)
}
}
final class CopilotBudgetBindingStubURLProtocol: URLProtocol {
private static let lock = NSLock()
nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (Data, URLResponse))?
private nonisolated(unsafe) static var recordedRequests: [URLRequest] = []
static func reset() {
self.lock.lock()
defer { self.lock.unlock() }
self.handler = nil
self.recordedRequests = []
}
static func requests() -> [URLRequest] {
self.lock.lock()
defer { self.lock.unlock() }
return self.recordedRequests
}
override static func canInit(with request: URLRequest) -> Bool {
guard self.hasHandler else { return false }
guard request.url?.scheme == "https" else { return false }
switch (request.url?.host, request.url?.path) {
case ("api.github.com", "/copilot_internal/user"),
("api.github.com", "/user"),
("github.com", "/settings/billing/budgets"):
return true
default:
return false
}
}
override static func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
Self.lock.lock()
Self.recordedRequests.append(self.request)
let handler = Self.handler
Self.lock.unlock()
guard let handler else {
self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
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() {}
}
extension CopilotBudgetBindingStubURLProtocol {
fileprivate static var hasHandler: Bool {
self.lock.lock()
defer { self.lock.unlock() }
return self.handler != nil
}
}