import Foundation import Testing @testable import CodexBarCore @Suite(.serialized) struct ClaudeWebCookieRenewalTests { @Test func `cached web session key renews from set cookie after successful fetch`() async throws { try await self.withIsolatedCookieCache { CookieHeaderCache.store( provider: .claude, cookieHeader: "sessionKey=sk-ant-old-token", sourceLabel: "Chrome") defer { CookieHeaderCache.clear(provider: .claude) } let usageCookies = RequestHeaderLog() try await self.withClaudeWebStub { request in if request.url?.path == "/api/organizations/org-123/usage" { usageCookies.append(request.value(forHTTPHeaderField: "Cookie")) } return try Self.response(for: request, setCookie: Self.renewedSessionCookie) } operation: { let usage = try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) #expect(usage.sessionPercentUsed == 11) #expect(usage.weeklyPercentUsed == 22) #expect(usageCookies.values == ["sessionKey=sk-ant-renewed-token"]) let cached = try #require(CookieHeaderCache.load(provider: .claude)) #expect(cached.cookieHeader == "sessionKey=sk-ant-renewed-token") #expect(cached.sourceLabel == "Chrome") } } } @Test func `cached fetch without renewal does not block concurrent renewal`() async throws { try await self.withIsolatedCookieCache { CookieHeaderCache.store( provider: .claude, cookieHeader: "sessionKey=sk-ant-old-token", sourceLabel: "Chrome", now: Date(timeIntervalSince1970: 1)) defer { CookieHeaderCache.clear(provider: .claude) } let initial = try #require(CookieHeaderCache.load(provider: .claude)) try await self.withClaudeWebStub { request in try Self.response(for: request, setCookie: nil) } operation: { _ = try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) } let renewed = CookieHeaderCache.storeIfCurrent( provider: .claude, expected: initial, cookieHeader: "sessionKey=sk-ant-concurrent-renewal", sourceLabel: "Chrome") #expect(renewed) #expect(CookieHeaderCache.load(provider: .claude)?.cookieHeader == "sessionKey=sk-ant-concurrent-renewal") } } @Test func `browser fallback replaces stale cache when conditional clear fails`() async throws { try await self.withIsolatedCookieCache { CookieHeaderCache.store( provider: .claude, cookieHeader: "sessionKey=sk-ant-stale-token", sourceLabel: "Chrome") defer { CookieHeaderCache.clear(provider: .claude) } let imported = ClaudeWebAPIFetcher.SessionKeyInfo( key: "sk-ant-imported-token", sourceLabel: "Safari", cookieCount: 1) try await KeychainCacheStore.withClearFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { try await ClaudeWebSessionKeyImport.$overrideForTesting.withValue(imported) { try await self.withClaudeWebStub { request in let isStale = request.value(forHTTPHeaderField: "Cookie") == "sessionKey=sk-ant-stale-token" if request.url?.path == "/api/organizations", isStale { let url = try #require(request.url) return Self.jsonResponse( url: url, body: "{}", statusCode: 401, setCookie: nil) } return try Self.response(for: request, setCookie: nil) } operation: { _ = try await ClaudeWebAPIFetcher.fetchUsage( browserDetection: BrowserDetection(cacheTTL: 0)) } } } let cached = try #require(CookieHeaderCache.load(provider: .claude)) #expect(cached.cookieHeader == "sessionKey=sk-ant-imported-token") #expect(cached.sourceLabel == "Safari") } } @Test func `concurrent cached fetches serialize session key rotations`() async throws { try await self.withIsolatedCookieCache { CookieHeaderCache.store( provider: .claude, cookieHeader: "sessionKey=sk-ant-initial-token", sourceLabel: "Chrome") defer { CookieHeaderCache.clear(provider: .claude) } let probe = ConcurrentClaudeFetchProbe() let transport = ProviderHTTPTransportHandler { request in let setCookie: String? = if request.url?.path == "/api/organizations" { await probe.organizationSessionCookie( requestCookie: request.value(forHTTPHeaderField: "Cookie")) } else { nil } let (response, data) = try Self.response(for: request, setCookie: setCookie) return (data, response) } try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { let first = Task { try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) } await probe.waitForOrganizationCount(1) let second = Task { try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) } for _ in 0..<20 { await Task.yield() } #expect(await probe.organizationRequestCount == 1) await probe.releaseFirstRequest() _ = try await first.value _ = try await second.value } #expect(await probe.organizationRequestCookies == [ "sessionKey=sk-ant-initial-token", "sessionKey=sk-ant-first-rotation", ]) #expect(CookieHeaderCache.load(provider: .claude)?.cookieHeader == "sessionKey=sk-ant-second-rotation") } } @Test func `cancelled waiting fetch relinquishes the serialization gate`() async throws { try await self.withIsolatedCookieCache { CookieHeaderCache.store( provider: .claude, cookieHeader: "sessionKey=sk-ant-initial-token", sourceLabel: "Chrome") defer { CookieHeaderCache.clear(provider: .claude) } let probe = ConcurrentClaudeFetchProbe() let transport = ProviderHTTPTransportHandler { request in let setCookie: String? = if request.url?.path == "/api/organizations" { await probe.organizationSessionCookie( requestCookie: request.value(forHTTPHeaderField: "Cookie")) } else { nil } let (response, data) = try Self.response(for: request, setCookie: setCookie) return (data, response) } try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { let first = Task { try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) } await probe.waitForOrganizationCount(1) let cancelled = Task { try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) } for _ in 0..<20 { await Task.yield() } cancelled.cancel() await #expect(throws: CancellationError.self) { try await cancelled.value } #expect(await probe.organizationRequestCount == 1) await probe.releaseFirstRequest() _ = try await first.value _ = try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) } #expect(await probe.organizationRequestCookies == [ "sessionKey=sk-ant-initial-token", "sessionKey=sk-ant-first-rotation", ]) #expect(CookieHeaderCache.load(provider: .claude)?.cookieHeader == "sessionKey=sk-ant-second-rotation") } } @Test func `manual web session fetch does not rewrite cached cookie`() async throws { try await self.withIsolatedCookieCache { CookieHeaderCache.store( provider: .claude, cookieHeader: "sessionKey=sk-ant-cache-token", sourceLabel: "Chrome") defer { CookieHeaderCache.clear(provider: .claude) } let usageCookies = RequestHeaderLog() try await self.withClaudeWebStub { request in if request.url?.path == "/api/organizations/org-123/usage" { usageCookies.append(request.value(forHTTPHeaderField: "Cookie")) } return try Self.response(for: request, setCookie: Self.renewedSessionCookie) } operation: { let usage = try await ClaudeWebAPIFetcher.fetchUsage(cookieHeader: "sessionKey=sk-ant-manual-token") #expect(usage.sessionPercentUsed == 11) #expect(usageCookies.values == ["sessionKey=sk-ant-renewed-token"]) let cached = try #require(CookieHeaderCache.load(provider: .claude)) #expect(cached.cookieHeader == "sessionKey=sk-ant-cache-token") #expect(cached.sourceLabel == "Chrome") } } } @Test func `usage response renewal propagates to later requests and cache`() async throws { try await self.withIsolatedCookieCache { CookieHeaderCache.store( provider: .claude, cookieHeader: "sessionKey=sk-ant-old-token", sourceLabel: "Chrome") defer { CookieHeaderCache.clear(provider: .claude) } let usageCookies = RequestHeaderLog() let overageCookies = RequestHeaderLog() let accountCookies = RequestHeaderLog() try await self.withClaudeWebStub { request in let path = request.url?.path switch path { case "/api/organizations/org-123/usage": usageCookies.append(request.value(forHTTPHeaderField: "Cookie")) case "/api/organizations/org-123/overage_spend_limit": overageCookies.append(request.value(forHTTPHeaderField: "Cookie")) case "/api/account": accountCookies.append(request.value(forHTTPHeaderField: "Cookie")) default: break } return try Self.response( for: request, setCookie: path == "/api/organizations/org-123/usage" ? Self.renewedSessionCookie : nil) } operation: { _ = try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) #expect(usageCookies.values == ["sessionKey=sk-ant-old-token"]) #expect(overageCookies.values == ["sessionKey=sk-ant-renewed-token"]) #expect(accountCookies.values == ["sessionKey=sk-ant-renewed-token"]) let cached = try #require(CookieHeaderCache.load(provider: .claude)) #expect(cached.cookieHeader == "sessionKey=sk-ant-renewed-token") } } } @Test func `renewal can return to initial session key`() async throws { try await self.withIsolatedCookieCache { CookieHeaderCache.store( provider: .claude, cookieHeader: "sessionKey=sk-ant-initial-token", sourceLabel: "Chrome") defer { CookieHeaderCache.clear(provider: .claude) } let usageCookies = RequestHeaderLog() let overageCookies = RequestHeaderLog() let accountCookies = RequestHeaderLog() try await self.withClaudeWebStub { request in let path = request.url?.path switch path { case "/api/organizations/org-123/usage": usageCookies.append(request.value(forHTTPHeaderField: "Cookie")) case "/api/organizations/org-123/overage_spend_limit": overageCookies.append(request.value(forHTTPHeaderField: "Cookie")) case "/api/account": accountCookies.append(request.value(forHTTPHeaderField: "Cookie")) default: break } let setCookie: String? = switch path { case "/api/organizations": "sessionKey=sk-ant-intermediate-token; Path=/; HttpOnly" case "/api/organizations/org-123/usage": "sessionKey=sk-ant-initial-token; Path=/; HttpOnly" default: nil } return try Self.response(for: request, setCookie: setCookie) } operation: { _ = try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) #expect(usageCookies.values == ["sessionKey=sk-ant-intermediate-token"]) #expect(overageCookies.values == ["sessionKey=sk-ant-initial-token"]) #expect(accountCookies.values == ["sessionKey=sk-ant-initial-token"]) let cached = try #require(CookieHeaderCache.load(provider: .claude)) #expect(cached.cookieHeader == "sessionKey=sk-ant-initial-token") #expect(cached.sourceLabel == "Chrome") } } } @Test func `last session key assignment in one response wins`() async throws { try await self.withIsolatedCookieCache { CookieHeaderCache.store( provider: .claude, cookieHeader: "sessionKey=sk-ant-old-token", sourceLabel: "Chrome") defer { CookieHeaderCache.clear(provider: .claude) } let usageCookies = RequestHeaderLog() let overageCookies = RequestHeaderLog() let accountCookies = RequestHeaderLog() try await self.withClaudeWebStub { request in let path = request.url?.path switch path { case "/api/organizations/org-123/usage": usageCookies.append(request.value(forHTTPHeaderField: "Cookie")) case "/api/organizations/org-123/overage_spend_limit": overageCookies.append(request.value(forHTTPHeaderField: "Cookie")) case "/api/account": accountCookies.append(request.value(forHTTPHeaderField: "Cookie")) default: break } let setCookie = path == "/api/organizations" ? "sessionKey=sk-ant-first-token; Expires=Wed, 21 Oct 2030 07:28:00 GMT; Path=/, " + "sessionKey=sk-ant-final-token; Path=/; HttpOnly" : nil return try Self.response(for: request, setCookie: setCookie) } operation: { _ = try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) #expect(usageCookies.values == ["sessionKey=sk-ant-final-token"]) #expect(overageCookies.values == ["sessionKey=sk-ant-final-token"]) #expect(accountCookies.values == ["sessionKey=sk-ant-final-token"]) let cached = try #require(CookieHeaderCache.load(provider: .claude)) #expect(cached.cookieHeader == "sessionKey=sk-ant-final-token") #expect(cached.sourceLabel == "Chrome") } } } private static let renewedSessionCookie = "sessionKey=sk-ant-renewed-token; Path=/; HttpOnly; Secure; SameSite=Lax" private func withIsolatedCookieCache(_ operation: () async throws -> T) async rethrows -> T { let legacyBase = FileManager.default.temporaryDirectory .appendingPathComponent("claude-web-renewal-\(UUID().uuidString)", isDirectory: true) return try await KeychainCacheStore.withServiceOverrideForTesting("claude-web-renewal-\(UUID().uuidString)") { try await CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } CookieHeaderCache.resetDisplayCacheForTesting() defer { CookieHeaderCache.resetDisplayCacheForTesting() } return try await operation() } } } private func withClaudeWebStub( handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data), operation: () async throws -> T) async rethrows -> T { let transport = ProviderHTTPTransportHandler { request in let (response, data) = try handler(request) return (data, response) } return try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { try await operation() } } private static func response( for request: URLRequest, setCookie: String?) throws -> (HTTPURLResponse, Data) { let url = try #require(request.url) switch url.path { case "/api/organizations": return self.jsonResponse( url: url, body: #"[{"uuid":"org-123","name":"Test Org","capabilities":["chat"]}]"#, setCookie: setCookie) case "/api/organizations/org-123/usage": return self.jsonResponse( url: url, body: """ { "five_hour": { "utilization": 11 }, "seven_day": { "utilization": 22 } } """, setCookie: setCookie) case "/api/account", "/api/organizations/org-123/overage_spend_limit": return self.jsonResponse(url: url, body: "{}", statusCode: 404, setCookie: setCookie) default: return self.jsonResponse(url: url, body: "{}", statusCode: 404, setCookie: setCookie) } } private static func jsonResponse( url: URL, body: String, statusCode: Int = 200, setCookie: String?) -> (HTTPURLResponse, Data) { var headerFields = ["Content-Type": "application/json"] if let setCookie { headerFields["Set-Cookie"] = setCookie } let response = HTTPURLResponse( url: url, statusCode: statusCode, httpVersion: "HTTP/1.1", headerFields: headerFields)! return (response, Data(body.utf8)) } } private final class RequestHeaderLog: @unchecked Sendable { private let lock = NSLock() private var storage: [String?] = [] var values: [String?] { self.lock.lock() defer { self.lock.unlock() } return self.storage } func append(_ value: String?) { self.lock.lock() self.storage.append(value) self.lock.unlock() } } private actor ConcurrentClaudeFetchProbe { private var requestCookies: [String?] = [] private var organizationCountWaiters: [(Int, CheckedContinuation)] = [] private var firstRequestReleased = false private var firstRequestReleaseWaiters: [CheckedContinuation] = [] var organizationRequestCount: Int { self.requestCookies.count } var organizationRequestCookies: [String?] { self.requestCookies } func organizationSessionCookie(requestCookie: String?) async -> String { self.requestCookies.append(requestCookie) let ordinal = self.requestCookies.count let readyWaiters = self.organizationCountWaiters.filter { $0.0 <= ordinal } self.organizationCountWaiters.removeAll { $0.0 <= ordinal } readyWaiters.forEach { $0.1.resume() } if ordinal == 1, !self.firstRequestReleased { await withCheckedContinuation { continuation in self.firstRequestReleaseWaiters.append(continuation) } } let value = ordinal == 1 ? "sk-ant-first-rotation" : "sk-ant-second-rotation" return "sessionKey=\(value); Path=/; HttpOnly" } func waitForOrganizationCount(_ count: Int) async { if self.requestCookies.count >= count { return } await withCheckedContinuation { continuation in self.organizationCountWaiters.append((count, continuation)) } } func releaseFirstRequest() { self.firstRequestReleased = true let waiters = self.firstRequestReleaseWaiters self.firstRequestReleaseWaiters.removeAll() waiters.forEach { $0.resume() } } }