mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
feat: add native Cursor quota connection
This commit is contained in:
parent
93b7b9a7da
commit
bce49926c3
7 changed files with 635 additions and 4 deletions
|
|
@ -22,12 +22,18 @@ let package = Package(
|
|||
],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency")
|
||||
],
|
||||
linkerSettings: [
|
||||
.linkedLibrary("sqlite3")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "CodeBurnMenubarTests",
|
||||
dependencies: ["CodeBurnMenubar"],
|
||||
path: "Tests/CodeBurnMenubarTests"
|
||||
path: "Tests/CodeBurnMenubarTests",
|
||||
linkerSettings: [
|
||||
.linkedLibrary("sqlite3")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,10 +18,24 @@ enum CapacityDockProviderRefreshInteraction {
|
|||
final class CapacityDockProviderQuotaService {
|
||||
struct Dependencies: Sendable {
|
||||
var refreshClinePass: @Sendable (String) async throws -> QuotaSummary
|
||||
var refreshCursor: @Sendable () async throws -> QuotaSummary
|
||||
|
||||
init(
|
||||
refreshClinePass: @escaping @Sendable (String) async throws -> QuotaSummary,
|
||||
refreshCursor: @escaping @Sendable () async throws -> QuotaSummary = {
|
||||
try await CursorSubscriptionService.refresh()
|
||||
}
|
||||
) {
|
||||
self.refreshClinePass = refreshClinePass
|
||||
self.refreshCursor = refreshCursor
|
||||
}
|
||||
|
||||
static let live = Dependencies(
|
||||
refreshClinePass: { apiKey in
|
||||
try await ClinePassSubscriptionService.refresh(apiKey: apiKey)
|
||||
},
|
||||
refreshCursor: {
|
||||
try await CursorSubscriptionService.refresh()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -39,6 +53,14 @@ final class CapacityDockProviderQuotaService {
|
|||
credential: CapacityDockProviderCredential
|
||||
) async throws -> QuotaSummary {
|
||||
switch provider.id {
|
||||
case "cursor":
|
||||
do {
|
||||
return try await dependencies.refreshCursor()
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
throw CapacityDockProviderFetchFailure(error: error)
|
||||
}
|
||||
case "clinepass":
|
||||
guard let apiKey = credential.sanitizedOverride.apiKey else {
|
||||
throw CapacityDockProviderFetchFailure(
|
||||
|
|
@ -98,6 +120,14 @@ struct CapacityDockProviderFetchFailure: LocalizedError, Equatable, Sendable {
|
|||
return .transient
|
||||
}
|
||||
}
|
||||
if let error = error as? CursorSubscriptionService.FetchError {
|
||||
switch error.classification {
|
||||
case .terminalAuth:
|
||||
return .terminal
|
||||
case .transient, .parseFailure:
|
||||
return .transient
|
||||
}
|
||||
}
|
||||
if error is URLError {
|
||||
return .transient
|
||||
}
|
||||
|
|
|
|||
368
mac/Sources/CodeBurnMenubar/Data/CursorSubscriptionService.swift
Normal file
368
mac/Sources/CodeBurnMenubar/Data/CursorSubscriptionService.swift
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
/// CodeBurn-owned Cursor quota adapter. It reads Cursor.app's existing local
|
||||
/// session database in read-only mode, keeps the token in memory for one
|
||||
/// request, and never copies it into CodeBurn's Keychain or support directory.
|
||||
enum CursorSubscriptionService {
|
||||
static let usageURL = URL(string: "https://cursor.com/api/usage-summary")!
|
||||
private static let timeoutSeconds: TimeInterval = 15
|
||||
|
||||
enum FetchError: Error, Equatable, LocalizedError, Sendable {
|
||||
case noCredentials
|
||||
case expiredSession
|
||||
case authenticationRejected
|
||||
case rateLimited
|
||||
case providerUnavailable
|
||||
case parseFailure
|
||||
case network
|
||||
|
||||
enum Classification: Equatable, Sendable {
|
||||
case terminalAuth
|
||||
case transient
|
||||
case parseFailure
|
||||
}
|
||||
|
||||
var classification: Classification {
|
||||
switch self {
|
||||
case .noCredentials, .expiredSession, .authenticationRejected:
|
||||
return .terminalAuth
|
||||
case .rateLimited, .providerUnavailable, .network:
|
||||
return .transient
|
||||
case .parseFailure:
|
||||
return .parseFailure
|
||||
}
|
||||
}
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noCredentials:
|
||||
return "Sign in to the Cursor app, then click Retry."
|
||||
case .expiredSession:
|
||||
return "The Cursor app session is expired or invalid. Sign in again, then click Retry."
|
||||
case .authenticationRejected:
|
||||
return "Cursor rejected the current app session. Sign in again, then click Retry."
|
||||
case .rateLimited:
|
||||
return "Cursor rate-limited the quota request."
|
||||
case .providerUnavailable:
|
||||
return "Cursor is temporarily unavailable."
|
||||
case .parseFailure:
|
||||
return "Cursor returned an unrecognized quota response."
|
||||
case .network:
|
||||
return "Network error fetching Cursor quota."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Deps: Sendable {
|
||||
var loadAccessToken: @Sendable () throws -> String?
|
||||
var fetch: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
|
||||
static let live = Deps(
|
||||
loadAccessToken: {
|
||||
try CursorAppSessionStore().loadAccessToken()
|
||||
},
|
||||
fetch: { request in
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.httpCookieStorage = nil
|
||||
configuration.httpShouldSetCookies = false
|
||||
configuration.urlCache = nil
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
let session = URLSession(configuration: configuration)
|
||||
defer { session.invalidateAndCancel() }
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw FetchError.network }
|
||||
return (data, http)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func refresh(deps: Deps = .live) async throws -> QuotaSummary {
|
||||
let accessToken: String
|
||||
do {
|
||||
guard let loaded = try deps.loadAccessToken()?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!loaded.isEmpty else {
|
||||
throw FetchError.noCredentials
|
||||
}
|
||||
accessToken = loaded
|
||||
} catch let error as FetchError {
|
||||
throw error
|
||||
} catch {
|
||||
throw FetchError.network
|
||||
}
|
||||
|
||||
let session = try session(from: accessToken)
|
||||
var request = URLRequest(url: usageURL)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = timeoutSeconds
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue(session.cookieHeader, forHTTPHeaderField: "Cookie")
|
||||
|
||||
let data: Data
|
||||
let response: HTTPURLResponse
|
||||
do {
|
||||
(data, response) = try await deps.fetch(request)
|
||||
} catch let error as FetchError {
|
||||
throw error
|
||||
} catch {
|
||||
throw FetchError.network
|
||||
}
|
||||
|
||||
switch response.statusCode {
|
||||
case 200:
|
||||
break
|
||||
case 401, 403:
|
||||
throw FetchError.authenticationRejected
|
||||
case 429:
|
||||
throw FetchError.rateLimited
|
||||
case 500...599:
|
||||
throw FetchError.providerUnavailable
|
||||
default:
|
||||
throw FetchError.parseFailure
|
||||
}
|
||||
|
||||
return try decode(data)
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) throws -> QuotaSummary {
|
||||
let summary: UsageSummary
|
||||
do {
|
||||
summary = try JSONDecoder().decode(UsageSummary.self, from: data)
|
||||
} catch {
|
||||
throw FetchError.parseFailure
|
||||
}
|
||||
|
||||
let plan = summary.individualUsage?.plan
|
||||
let overall = summary.individualUsage?.overall
|
||||
let pooled = summary.teamUsage?.pooled
|
||||
let autoPercent = normalizedProviderPercent(plan?.autoPercentUsed)
|
||||
let apiPercent = normalizedProviderPercent(plan?.apiPercentUsed)
|
||||
|
||||
let monthlyPercent: Double? = {
|
||||
if let total = normalizedProviderPercent(plan?.totalPercentUsed) { return total }
|
||||
if let autoPercent, let apiPercent { return (autoPercent + apiPercent) / 2 }
|
||||
if let apiPercent { return apiPercent }
|
||||
if let autoPercent { return autoPercent }
|
||||
if let ratio = ratio(used: plan?.used, limit: plan?.limit) { return ratio }
|
||||
if let ratio = ratio(used: overall?.used, limit: overall?.limit) { return ratio }
|
||||
if let ratio = ratio(used: pooled?.used, limit: pooled?.limit) { return ratio }
|
||||
if summary.isUnlimited == true { return 0 }
|
||||
return nil
|
||||
}()
|
||||
guard let monthlyPercent else { throw FetchError.parseFailure }
|
||||
|
||||
let reset = parseDate(summary.billingCycleEnd)
|
||||
let primary = QuotaSummary.Window(label: "Monthly", percent: monthlyPercent, resetsAt: reset)
|
||||
var details = [primary]
|
||||
if let autoPercent {
|
||||
details.append(QuotaSummary.Window(label: "Auto", percent: autoPercent, resetsAt: reset))
|
||||
}
|
||||
if let apiPercent {
|
||||
details.append(QuotaSummary.Window(label: "API", percent: apiPercent, resetsAt: reset))
|
||||
}
|
||||
if let onDemand = summary.individualUsage?.onDemand,
|
||||
onDemand.enabled != false,
|
||||
let percent = ratio(used: onDemand.used, limit: onDemand.limit) {
|
||||
details.append(QuotaSummary.Window(label: "On-demand", percent: percent, resetsAt: reset))
|
||||
}
|
||||
if let percent = ratio(used: pooled?.used, limit: pooled?.limit) {
|
||||
details.append(QuotaSummary.Window(label: "Team pool", percent: percent, resetsAt: reset))
|
||||
}
|
||||
|
||||
return QuotaSummary(
|
||||
providerFilter: .cursor,
|
||||
connection: .connected,
|
||||
primary: primary,
|
||||
details: details,
|
||||
planLabel: planLabel(summary.membershipType),
|
||||
footerLines: ["Source: Cursor app"]
|
||||
)
|
||||
}
|
||||
|
||||
private struct Session {
|
||||
let cookieHeader: String
|
||||
}
|
||||
|
||||
private struct Claims: Decodable {
|
||||
let sub: String?
|
||||
let exp: Double?
|
||||
}
|
||||
|
||||
private static func session(from token: String, now: Date = Date()) throws -> Session {
|
||||
let components = token.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard components.count == 3,
|
||||
let payload = decodeBase64URL(String(components[1])),
|
||||
let claims = try? JSONDecoder().decode(Claims.self, from: payload),
|
||||
let subject = claims.sub,
|
||||
let userID = subject.split(separator: "|", omittingEmptySubsequences: true).last.map(String.init),
|
||||
!userID.isEmpty,
|
||||
let expiration = claims.exp,
|
||||
Date(timeIntervalSince1970: expiration).timeIntervalSince(now) > 60 else {
|
||||
throw FetchError.expiredSession
|
||||
}
|
||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-"))
|
||||
guard userID.unicodeScalars.allSatisfy(allowed.contains) else {
|
||||
throw FetchError.expiredSession
|
||||
}
|
||||
return Session(cookieHeader: "WorkosCursorSessionToken=\(userID)%3A%3A\(token)")
|
||||
}
|
||||
|
||||
private static func decodeBase64URL(_ value: String) -> Data? {
|
||||
var base64 = value
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4)
|
||||
return Data(base64Encoded: base64)
|
||||
}
|
||||
|
||||
private static func normalizedProviderPercent(_ value: Double?) -> Double? {
|
||||
guard let value, value.isFinite, value >= 0 else { return nil }
|
||||
return min(1, value / 100)
|
||||
}
|
||||
|
||||
private static func ratio(used: Int?, limit: Int?) -> Double? {
|
||||
guard let used, let limit, used >= 0, limit > 0 else { return nil }
|
||||
return min(1, max(0, Double(used) / Double(limit)))
|
||||
}
|
||||
|
||||
private static func parseDate(_ raw: String?) -> Date? {
|
||||
guard let raw else { return nil }
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = formatter.date(from: raw) { return date }
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
return formatter.date(from: raw)
|
||||
}
|
||||
|
||||
private static func planLabel(_ raw: String?) -> String? {
|
||||
guard let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
switch value.lowercased() {
|
||||
case "pro": return "Pro"
|
||||
case "pro_plus", "pro plus": return "Pro Plus"
|
||||
case "business": return "Business"
|
||||
case "enterprise": return "Enterprise"
|
||||
case "hobby", "free": return "Hobby"
|
||||
case "ultra": return "Ultra"
|
||||
default: return value
|
||||
}
|
||||
}
|
||||
|
||||
private struct UsageSummary: Decodable {
|
||||
let billingCycleEnd: String?
|
||||
let membershipType: String?
|
||||
let isUnlimited: Bool?
|
||||
let individualUsage: IndividualUsage?
|
||||
let teamUsage: TeamUsage?
|
||||
}
|
||||
|
||||
private struct IndividualUsage: Decodable {
|
||||
let plan: Meter?
|
||||
let onDemand: Meter?
|
||||
let overall: Meter?
|
||||
}
|
||||
|
||||
private struct TeamUsage: Decodable {
|
||||
let pooled: Meter?
|
||||
}
|
||||
|
||||
private struct Meter: Decodable {
|
||||
let enabled: Bool?
|
||||
let used: Int?
|
||||
let limit: Int?
|
||||
let autoPercentUsed: Double?
|
||||
let apiPercentUsed: Double?
|
||||
let totalPercentUsed: Double?
|
||||
}
|
||||
}
|
||||
|
||||
/// Narrow read-only adapter for Cursor's own VS Code state database.
|
||||
struct CursorAppSessionStore: Sendable {
|
||||
private static let accessTokenKey = "cursorAuth/accessToken"
|
||||
let databaseURL: URL
|
||||
|
||||
init(databaseURL: URL = Self.defaultDatabaseURL()) {
|
||||
self.databaseURL = databaseURL
|
||||
}
|
||||
|
||||
static func defaultDatabaseURL(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL {
|
||||
home
|
||||
.appendingPathComponent("Library/Application Support/Cursor/User/globalStorage", isDirectory: true)
|
||||
.appendingPathComponent("state.vscdb", isDirectory: false)
|
||||
}
|
||||
|
||||
func loadAccessToken() throws -> String? {
|
||||
guard FileManager.default.fileExists(atPath: databaseURL.path) else { return nil }
|
||||
do {
|
||||
return try value(for: Self.accessTokenKey, immutable: false)
|
||||
} catch let error as SQLiteFailure
|
||||
where error.code == SQLITE_CANTOPEN && walSidecarsAreMissing {
|
||||
return try value(for: Self.accessTokenKey, immutable: true)
|
||||
} catch {
|
||||
throw CursorAppSessionStoreError.unreadable
|
||||
}
|
||||
}
|
||||
|
||||
private func value(for key: String, immutable: Bool) throws -> String? {
|
||||
var database: OpaquePointer?
|
||||
let filename = immutable ? "\(databaseURL.absoluteURL.absoluteString)?immutable=1" : databaseURL.path
|
||||
let flags = immutable ? SQLITE_OPEN_READONLY | SQLITE_OPEN_URI : SQLITE_OPEN_READONLY
|
||||
let openResult = sqlite3_open_v2(filename, &database, flags, nil)
|
||||
guard openResult == SQLITE_OK else {
|
||||
let failure = SQLiteFailure(code: database.map(sqlite3_errcode) ?? openResult)
|
||||
sqlite3_close(database)
|
||||
throw failure
|
||||
}
|
||||
defer { sqlite3_close(database) }
|
||||
sqlite3_busy_timeout(database, 250)
|
||||
|
||||
var statement: OpaquePointer?
|
||||
let prepareResult = sqlite3_prepare_v2(
|
||||
database,
|
||||
"SELECT value FROM ItemTable WHERE key = ? LIMIT 1;",
|
||||
-1,
|
||||
&statement,
|
||||
nil
|
||||
)
|
||||
guard prepareResult == SQLITE_OK else {
|
||||
throw SQLiteFailure(code: sqlite3_errcode(database))
|
||||
}
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, key, -1, sqliteTransient)
|
||||
|
||||
let stepResult = sqlite3_step(statement)
|
||||
if stepResult == SQLITE_DONE { return nil }
|
||||
guard stepResult == SQLITE_ROW else {
|
||||
throw SQLiteFailure(code: sqlite3_errcode(database))
|
||||
}
|
||||
switch sqlite3_column_type(statement, 0) {
|
||||
case SQLITE_TEXT:
|
||||
guard let text = sqlite3_column_text(statement, 0) else { return nil }
|
||||
return String(cString: text)
|
||||
case SQLITE_BLOB:
|
||||
guard let bytes = sqlite3_column_blob(statement, 0) else { return nil }
|
||||
let data = Data(bytes: bytes, count: Int(sqlite3_column_bytes(statement, 0)))
|
||||
return String(data: data, encoding: .utf8)
|
||||
?? String(data: data, encoding: .utf16LittleEndian)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var walSidecarsAreMissing: Bool {
|
||||
!FileManager.default.fileExists(atPath: databaseURL.path + "-wal") &&
|
||||
!FileManager.default.fileExists(atPath: databaseURL.path + "-shm")
|
||||
}
|
||||
|
||||
private struct SQLiteFailure: Error {
|
||||
let code: Int32
|
||||
}
|
||||
}
|
||||
|
||||
private enum CursorAppSessionStoreError: Error {
|
||||
case unreadable
|
||||
}
|
||||
|
||||
private let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||
|
|
@ -55,7 +55,7 @@ enum ProviderConnectionCatalog {
|
|||
entry("claude", "Claude", [.automatic, .api, .web, .cli, .oauth],
|
||||
[.localAppOrCLI, .oauth, .apiTokenOrCloudCredentials, .cookieOrWebSession], live: true),
|
||||
entry("clinepass", "ClinePass", [.automatic, .api], [.apiTokenOrCloudCredentials], live: true),
|
||||
entry("cursor", "Cursor", [.automatic, .cli, .web], [.localAppOrCLI, .cookieOrWebSession]),
|
||||
entry("cursor", "Cursor", [.automatic, .cli, .web], [.localAppOrCLI, .cookieOrWebSession], live: true),
|
||||
entry("opencode", "OpenCode", [.automatic, .web], [.cookieOrWebSession]),
|
||||
entry("opencodego", "OpenCode Go", [.automatic, .api, .web],
|
||||
[.localAppOrCLI, .apiTokenOrCloudCredentials, .cookieOrWebSession, .none]),
|
||||
|
|
@ -221,4 +221,3 @@ enum ProviderConnectionSubmissionPolicy {
|
|||
return .connect
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,26 @@ struct CapacityDockProviderQuotaServiceTests {
|
|||
#expect(capturedKeys == ["synthetic-clinepass-key"])
|
||||
}
|
||||
|
||||
@Test("Cursor dispatches through passive local-session discovery")
|
||||
func dispatchesCursor() async throws {
|
||||
let expected = Self.summary(percent: 0.37)
|
||||
let service = CapacityDockProviderQuotaService(dependencies: .init(
|
||||
refreshClinePass: { _ in
|
||||
Issue.record("Wrong adapter dispatched")
|
||||
return Self.summary()
|
||||
},
|
||||
refreshCursor: { expected }
|
||||
))
|
||||
let provider = try #require(CapacityDockProvider(rawValue: "cursor"))
|
||||
|
||||
let result = try await service.fetch(
|
||||
provider: provider,
|
||||
credential: CapacityDockProviderCredential()
|
||||
)
|
||||
|
||||
#expect(result == expected)
|
||||
}
|
||||
|
||||
@Test("ClinePass requires its own saved API key")
|
||||
func requiresClinePassKey() async throws {
|
||||
let service = CapacityDockProviderQuotaService(dependencies: .init(
|
||||
|
|
|
|||
208
mac/Tests/CodeBurnMenubarTests/CursorQuotaTests.swift
Normal file
208
mac/Tests/CodeBurnMenubarTests/CursorQuotaTests.swift
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import Foundation
|
||||
import SQLite3
|
||||
import XCTest
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
/// Fixture-driven coverage for CodeBurn's native Cursor quota adapter. Tests
|
||||
/// use a synthetic JWT and temporary SQLite database; they never inspect the
|
||||
/// operator's Cursor session or Keychain.
|
||||
@MainActor
|
||||
final class CursorQuotaTests: XCTestCase {
|
||||
private final class RequestRecorder: @unchecked Sendable {
|
||||
private(set) var requests: [URLRequest] = []
|
||||
func record(_ request: URLRequest) { requests.append(request) }
|
||||
}
|
||||
|
||||
nonisolated private static let successBody = """
|
||||
{
|
||||
"billingCycleStart": "2026-08-01T00:00:00Z",
|
||||
"billingCycleEnd": "2026-09-01T00:00:00Z",
|
||||
"membershipType": "pro",
|
||||
"individualUsage": {
|
||||
"plan": {
|
||||
"enabled": true,
|
||||
"used": 850,
|
||||
"limit": 2000,
|
||||
"remaining": 1150,
|
||||
"autoPercentUsed": 20,
|
||||
"apiPercentUsed": 65,
|
||||
"totalPercentUsed": 42.5
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
func testLocalCursorDatabaseIsReadWithoutWritingACodeBurnCredential() throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("codeburn-cursor-auth-\(UUID().uuidString)", isDirectory: true)
|
||||
let databaseURL = root.appendingPathComponent("state.vscdb")
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let token = Self.syntheticJWT()
|
||||
try Self.writeCursorDatabase(at: databaseURL, token: token)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o444], ofItemAtPath: databaseURL.path)
|
||||
|
||||
let loaded = try CursorAppSessionStore(databaseURL: databaseURL).loadAccessToken()
|
||||
|
||||
XCTAssertEqual(loaded, token)
|
||||
}
|
||||
|
||||
func testExistingCursorAppSessionConnectsAndMapsMonthlyQuota() async throws {
|
||||
let recorder = RequestRecorder()
|
||||
let token = Self.syntheticJWT()
|
||||
let deps = CursorSubscriptionService.Deps(
|
||||
loadAccessToken: { token },
|
||||
fetch: { request in
|
||||
recorder.record(request)
|
||||
return (
|
||||
Data(Self.successBody.utf8),
|
||||
Self.httpResponse(request, status: 200)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
let summary = try await CursorSubscriptionService.refresh(deps: deps)
|
||||
|
||||
XCTAssertEqual(summary.connection, .connected)
|
||||
XCTAssertEqual(summary.providerFilter, .cursor)
|
||||
XCTAssertEqual(summary.primary?.label, "Monthly")
|
||||
XCTAssertEqual(summary.primary?.percent ?? -1, 0.425, accuracy: 0.0001)
|
||||
XCTAssertEqual(summary.details.map(\.label), ["Monthly", "Auto", "API"])
|
||||
XCTAssertEqual(summary.details.map(\.percent), [0.425, 0.2, 0.65])
|
||||
XCTAssertEqual(summary.planLabel, "Pro")
|
||||
XCTAssertEqual(summary.footerLines, ["Source: Cursor app"])
|
||||
XCTAssertEqual(recorder.requests.count, 1)
|
||||
XCTAssertEqual(recorder.requests[0].url?.absoluteString, "https://cursor.com/api/usage-summary")
|
||||
XCTAssertEqual(recorder.requests[0].httpMethod, "GET")
|
||||
XCTAssertNil(recorder.requests[0].value(forHTTPHeaderField: "Authorization"))
|
||||
XCTAssertTrue(
|
||||
recorder.requests[0].value(forHTTPHeaderField: "Cookie")?
|
||||
.hasPrefix("WorkosCursorSessionToken=user_123%3A%3A") == true
|
||||
)
|
||||
}
|
||||
|
||||
func testMissingOrExpiredCursorSessionIsTerminal() async throws {
|
||||
let missing = CursorSubscriptionService.Deps(
|
||||
loadAccessToken: { nil },
|
||||
fetch: { request in
|
||||
XCTFail("Network must not run without a Cursor app session")
|
||||
return (Data(), Self.httpResponse(request, status: 500))
|
||||
}
|
||||
)
|
||||
await assertFetchError(.noCredentials, deps: missing)
|
||||
|
||||
let expired = CursorSubscriptionService.Deps(
|
||||
loadAccessToken: { Self.syntheticJWT(expiresAt: Date(timeIntervalSinceNow: -60)) },
|
||||
fetch: { request in
|
||||
XCTFail("Network must not run with an expired Cursor app session")
|
||||
return (Data(), Self.httpResponse(request, status: 500))
|
||||
}
|
||||
)
|
||||
await assertFetchError(.expiredSession, deps: expired)
|
||||
}
|
||||
|
||||
func testCursorAuthenticationResponsesAreTerminal() async throws {
|
||||
for status in [401, 403] {
|
||||
let deps = CursorSubscriptionService.Deps(
|
||||
loadAccessToken: { Self.syntheticJWT() },
|
||||
fetch: { request in (Data(), Self.httpResponse(request, status: status)) }
|
||||
)
|
||||
await assertFetchError(.authenticationRejected, deps: deps)
|
||||
}
|
||||
}
|
||||
|
||||
func testCursorRateLimitAndMalformedPayloadRemainRetryable() async throws {
|
||||
let rateLimited = CursorSubscriptionService.Deps(
|
||||
loadAccessToken: { Self.syntheticJWT() },
|
||||
fetch: { request in (Data(), Self.httpResponse(request, status: 429)) }
|
||||
)
|
||||
await assertFetchError(.rateLimited, deps: rateLimited)
|
||||
|
||||
let malformed = CursorSubscriptionService.Deps(
|
||||
loadAccessToken: { Self.syntheticJWT() },
|
||||
fetch: { request in (Data("not-json".utf8), Self.httpResponse(request, status: 200)) }
|
||||
)
|
||||
await assertFetchError(.parseFailure, deps: malformed)
|
||||
}
|
||||
|
||||
private func assertFetchError(
|
||||
_ expected: CursorSubscriptionService.FetchError,
|
||||
deps: CursorSubscriptionService.Deps
|
||||
) async {
|
||||
do {
|
||||
_ = try await CursorSubscriptionService.refresh(deps: deps)
|
||||
XCTFail("Expected Cursor refresh to fail with \(expected)")
|
||||
} catch let error as CursorSubscriptionService.FetchError {
|
||||
XCTAssertEqual(error, expected)
|
||||
} catch {
|
||||
XCTFail("Unexpected error type: \(type(of: error))")
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func syntheticJWT(
|
||||
expiresAt: Date = Date(timeIntervalSinceNow: 3_600)
|
||||
) -> String {
|
||||
let header = base64URL(Data(#"{"alg":"none","typ":"JWT"}"#.utf8))
|
||||
let expiration = Int(expiresAt.timeIntervalSince1970)
|
||||
let payload = base64URL(Data(#"{"sub":"auth0|user_123","exp":\#(expiration)}"#.utf8))
|
||||
return "\(header).\(payload).synthetic-signature"
|
||||
}
|
||||
|
||||
nonisolated private static func base64URL(_ data: Data) -> String {
|
||||
data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
nonisolated private static func httpResponse(
|
||||
_ request: URLRequest,
|
||||
status: Int
|
||||
) -> HTTPURLResponse {
|
||||
HTTPURLResponse(
|
||||
url: request.url ?? URL(string: "https://cursor.com/api/usage-summary")!,
|
||||
statusCode: status,
|
||||
httpVersion: nil,
|
||||
headerFields: nil
|
||||
)!
|
||||
}
|
||||
|
||||
nonisolated private static func writeCursorDatabase(at url: URL, token: String) throws {
|
||||
var database: OpaquePointer?
|
||||
guard sqlite3_open(url.path, &database) == SQLITE_OK else {
|
||||
sqlite3_close(database)
|
||||
throw SQLiteFixtureError.open
|
||||
}
|
||||
defer { sqlite3_close(database) }
|
||||
guard sqlite3_exec(
|
||||
database,
|
||||
"CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT NOT NULL);",
|
||||
nil,
|
||||
nil,
|
||||
nil
|
||||
) == SQLITE_OK else { throw SQLiteFixtureError.create }
|
||||
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(
|
||||
database,
|
||||
"INSERT INTO ItemTable(key, value) VALUES (?, ?);",
|
||||
-1,
|
||||
&statement,
|
||||
nil
|
||||
) == SQLITE_OK else { throw SQLiteFixtureError.prepare }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, "cursorAuth/accessToken", -1, sqliteTransientForTests)
|
||||
sqlite3_bind_text(statement, 2, token, -1, sqliteTransientForTests)
|
||||
guard sqlite3_step(statement) == SQLITE_DONE else { throw SQLiteFixtureError.insert }
|
||||
}
|
||||
|
||||
private enum SQLiteFixtureError: Error {
|
||||
case open
|
||||
case create
|
||||
case prepare
|
||||
case insert
|
||||
}
|
||||
}
|
||||
|
||||
private let sqliteTransientForTests = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||
|
|
@ -46,7 +46,7 @@ struct ProviderConnectionCatalogTests {
|
|||
.filter(\.hasLiveCodeBurnQuotaAdapter)
|
||||
.map(\.id)
|
||||
.sorted()
|
||||
#expect(live == ["antigravity", "claude", "clinepass", "codex", "copilot", "gemini", "kimi"])
|
||||
#expect(live == ["antigravity", "claude", "clinepass", "codex", "copilot", "cursor", "gemini", "kimi"])
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue