From 261ebc4b2bf26f3374c7a220b5d63c36e7415190 Mon Sep 17 00:00:00 2001 From: William Mitchell Date: Thu, 30 Jul 2026 15:54:25 +0100 Subject: [PATCH 1/4] fix(alibaba): include sec token for Personal plans --- .../AlibabaTokenPlanUsageFetcher.swift | 94 ++++++----- .../AlibabaTokenPlanProviderTests.swift | 155 ++++++++++++++---- 2 files changed, 173 insertions(+), 76 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index b3a4700e49..9048fd0a8a 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -30,6 +30,7 @@ public enum AlibabaTokenPlanUsageError: LocalizedError, Sendable, Equatable { public struct AlibabaTokenPlanUsageFetcher: Sendable { private struct PersonalAPIContext: Sendable { let apiCookieHeader: String + let secToken: String? let region: AlibabaTokenPlanAPIRegion let environment: [String: String] let session: URLSession @@ -130,12 +131,20 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } if region.usesPersonalTokenPlanAPI { - return try await self.fetchPersonalUsage( + let secToken = await self.resolveSECToken( + dashboardCookieHeader: normalizedDashboardHeader, apiCookieHeader: normalizedAPIHeader, region: region, environment: environment, - now: now, - session: apiSession) + session: dashboardSession) + return try await self.fetchPersonalUsage( + context: PersonalAPIContext( + apiCookieHeader: normalizedAPIHeader, + secToken: secToken, + region: region, + environment: environment, + session: apiSession), + now: now) } let secToken = await self.resolveSECToken( @@ -311,25 +320,19 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } private static func fetchPersonalUsage( - apiCookieHeader: String, - region: AlibabaTokenPlanAPIRegion, - environment: [String: String], - now: Date, - session: URLSession) async throws -> AlibabaTokenPlanUsageSnapshot + context: PersonalAPIContext, + now: Date) async throws -> AlibabaTokenPlanUsageSnapshot { - let context = PersonalAPIContext( - apiCookieHeader: apiCookieHeader, - region: region, - environment: environment, - session: session) self.log.info( "Fetching Alibaba Token Plan Personal usage", metadata: [ - "apiHost": self.resolveQuotaURL(region: region, environment: environment).host ?? "unknown", - "region": region.rawValue, - "apiCookieNames": self.cookieNamesDescription(from: apiCookieHeader), - "hasCSRF": self.hasCSRF(in: apiCookieHeader) ? "1" : "0", - "secTokenSource": "not-required", + "apiHost": self.resolveQuotaURL( + region: context.region, + environment: context.environment).host ?? "unknown", + "region": context.region.rawValue, + "apiCookieNames": self.cookieNamesDescription(from: context.apiCookieHeader), + "hasCSRF": self.hasCSRF(in: context.apiCookieHeader) ? "1" : "0", + "secTokenSource": context.secToken == nil ? "missing" : "resolved", ]) let usageData = try await self.fetchPersonalAPI( @@ -338,7 +341,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { context: context) let subscriptionData = await self.fetchOptionalPersonalAPI( api: self.personalSubscriptionAPI, - dataParameters: ["commodityCode": region.tokenPlanProductCode], + dataParameters: ["commodityCode": context.region.tokenPlanProductCode], context: context) let quotaConfigData = await self.fetchOptionalPersonalAPI( api: self.personalQuotaConfigAPI, @@ -382,9 +385,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { request.httpBody = try self.personalAPIRequestBody( api: api, dataParameters: dataParameters, - apiCookieHeader: context.apiCookieHeader, - region: context.region, - environment: context.environment) + context: context) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") request.setValue(context.apiCookieHeader, forHTTPHeaderField: "Cookie") @@ -445,11 +446,9 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { private static func personalAPIRequestBody( api: String, dataParameters: [String: String], - apiCookieHeader: String, - region: AlibabaTokenPlanAPIRegion, - environment: [String: String]) throws -> Data + context: PersonalAPIContext) throws -> Data { - let dashboardURL = self.dashboardURL(region: region, environment: environment) + let dashboardURL = self.dashboardURL(region: context.region, environment: context.environment) var cornerstone: [String: Any] = [ "feTraceId": UUID().uuidString.lowercased(), "feURL": dashboardURL.absoluteString, @@ -459,12 +458,12 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "switchAgent": 1_233_135, "switchUserType": 3, "domain": dashboardURL.host ?? "", - "consoleSite": region.personalConsoleSite, + "consoleSite": context.region.personalConsoleSite, "userNickName": "", "userPrincipalName": "", "xsp_lang": "en-US", ] - if let anonymousID = self.extractCookieValue(name: "cna", from: apiCookieHeader), !anonymousID.isEmpty { + if let anonymousID = self.extractCookieValue(name: "cna", from: context.apiCookieHeader), !anonymousID.isEmpty { cornerstone["X-Anonymous-Id"] = anonymousID } var apiData = dataParameters as [String: Any] @@ -480,13 +479,17 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } var body = URLComponents() - body.queryItems = [ + var queryItems = [ URLQueryItem(name: "product", value: self.personalConsoleProduct), - URLQueryItem(name: "action", value: region.personalAPIAction), - URLQueryItem(name: "region", value: region.currentRegionID), + URLQueryItem(name: "action", value: context.region.personalAPIAction), + URLQueryItem(name: "region", value: context.region.currentRegionID), URLQueryItem(name: "language", value: "en-US"), URLQueryItem(name: "params", value: paramsJSON), ] + if let secToken = context.secToken, !secToken.isEmpty { + queryItems.append(URLQueryItem(name: "sec_token", value: secToken)) + } + body.queryItems = queryItems return Data((body.percentEncodedQuery ?? "").utf8) } @@ -714,11 +717,11 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { throw AlibabaTokenPlanUsageError.apiError(message) } - if self.findBoolValues(forKeys: ["Success", "success"], in: dictionary).contains(false) { - let code = self.findFirstString(forKeys: ["errorCode", "Code", "code"], in: dictionary) + if let failedPayload = self.findFailedPayload(in: dictionary) { + let code = self.findFirstString(forKeys: ["errorCode", "Code", "code"], in: failedPayload) let message = self.findFirstString( forKeys: ["errorMsg", "Message", "message", "msg", "Code", "code"], - in: dictionary) ?? "request was not successful" + in: failedPayload) ?? "request was not successful" if self.isLoginOrTokenError(code: code, message: message) { throw AlibabaTokenPlanUsageError.loginRequired } @@ -979,16 +982,25 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return nil } - private static func findBoolValues(forKeys keys: [String], in value: Any) -> [Bool] { - if let dict = value as? [String: Any] { - let directValues = keys.compactMap { self.parseBool(dict[$0]) } - let nestedValues = dict.values.flatMap { self.findBoolValues(forKeys: keys, in: $0) } - return directValues + nestedValues + private static func findFailedPayload(in value: Any) -> [String: Any]? { + if let dictionary = value as? [String: Any] { + if self.parseBool(dictionary["Success"]) == false || self.parseBool(dictionary["success"]) == false { + return dictionary + } + for nestedValue in dictionary.values { + if let failedPayload = self.findFailedPayload(in: nestedValue) { + return failedPayload + } + } } if let array = value as? [Any] { - return array.flatMap { self.findBoolValues(forKeys: keys, in: $0) } + for item in array { + if let failedPayload = self.findFailedPayload(in: item) { + return failedPayload + } + } } - return [] + return nil } private static func findFirstInt(forKeys keys: [String], in value: Any) -> Int? { diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index 30b0e4f3e8..0783b86b28 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -382,6 +382,31 @@ struct AlibabaTokenPlanUsageParsingTests { } } + @Test + func `Personal nested gateway error does not report outer success code`() { + let json = """ + { + "code": "200", + "data": { + "success": false, + "httpStatus": 200, + "errorCode": "BailianGateway.Workspace.NotAuthorised", + "errorMsg": "BailianGateway.Workspace.NotAuthorised" + }, + "httpStatusCode": "200", + "successResponse": true + } + """ + + #expect(throws: AlibabaTokenPlanUsageError.apiError("BailianGateway.Workspace.NotAuthorised")) { + try AlibabaTokenPlanPersonalUsageParser.parse( + from: Data(json.utf8), + subscriptionData: nil, + quotaConfigData: nil, + now: Date(timeIntervalSince1970: 1_700_000_000)) + } + } + @Test func `parses subscription summary payload`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) @@ -546,7 +571,7 @@ struct AlibabaTokenPlanUsageParsingTests { } @Test - func `mainland Personal fetch uses quota host cookies without requiring SEC token`() async throws { + func `Personal fetch includes resolved SEC token`() async throws { defer { AlibabaTokenPlanStubURLProtocol.handler = nil } @@ -556,46 +581,105 @@ struct AlibabaTokenPlanUsageParsingTests { let quotaBody = try #require( String(data: alibabaTokenPlanFixture("personal_quota_config"), encoding: .utf8)) - AlibabaTokenPlanStubURLProtocol.handler = { request in - guard let url = request.url else { throw URLError(.badURL) } - #expect(url.host == "bailian-cs.console.aliyun.com") - #expect(request.httpMethod == "POST") - #expect(request.value(forHTTPHeaderField: "Cookie") == "quota_only=quota") - #expect(request.value(forHTTPHeaderField: "Origin") == "https://bailian.console.aliyun.com") - let body = Self.requestBodyString(from: request) - #expect(!body.contains("sec_token")) - #expect(body.removingPercentEncoding?.contains("cornerstoneParam") == true) - - let api = URLComponents(url: url, resolvingAgainstBaseURL: false)? - .queryItems? - .first(where: { $0.name == "api" })? - .value - switch api { - case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage": - return Self.makeResponse(url: url, body: usageBody, statusCode: 200) - case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription": - #expect(body.removingPercentEncoding?.contains("sfm_tokenplansolo_public_cn") == true) - return Self.makeResponse(url: url, body: subscriptionBody, statusCode: 200) - case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config": - return Self.makeResponse(url: url, body: quotaBody, statusCode: 200) - default: - throw URLError(.unsupportedURL) + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] + let session = URLSession(configuration: configuration) + for region in [AlibabaTokenPlanAPIRegion.internationalPersonal, .chinaMainlandPersonal] { + AlibabaTokenPlanStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.host == region.dashboardURL.host, request.httpMethod == "GET" { + #expect(request.value(forHTTPHeaderField: "Cookie") == "dashboard_only=dashboard") + return Self.makeResponse( + url: url, + body: "", + statusCode: 200) + } + #expect(url.host == URL(string: region.quotaBaseURLString)?.host) + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Cookie") == "quota_only=quota") + #expect(request.value(forHTTPHeaderField: "Origin") == region.dashboardOriginURLString) + let body = Self.requestBodyString(from: request) + #expect(body.contains("sec_token=personal-token")) + #expect(body.removingPercentEncoding?.contains("cornerstoneParam") == true) + + let api = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "api" })? + .value + switch api { + case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage": + return Self.makeResponse(url: url, body: usageBody, statusCode: 200) + case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription": + #expect(body.removingPercentEncoding?.contains(region.tokenPlanProductCode) == true) + return Self.makeResponse(url: url, body: subscriptionBody, statusCode: 200) + case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config": + return Self.makeResponse(url: url, body: quotaBody, statusCode: 200) + default: + throw URLError(.unsupportedURL) + } } + + let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "quota_only=quota", + dashboardCookieHeader: "dashboard_only=dashboard", + region: region, + environment: [:], + session: session) + + #expect(snapshot.planName == "Pro") + #expect(snapshot.toUsageSnapshot().primary != nil) + #expect(snapshot.toUsageSnapshot().secondary != nil) } + } + @Test + func `Team fetch retains subscription summary routes`() async throws { + defer { + AlibabaTokenPlanStubURLProtocol.handler = nil + } + let body = """ + { + "Success": true, + "Data": { + "TotalCount": 1, + "TotalValue": 1000, + "TotalSurplusValue": 900 + } + } + """ let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] let session = URLSession(configuration: configuration) - let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( - apiCookieHeader: "quota_only=quota", - dashboardCookieHeader: "dashboard_only=dashboard", - region: .chinaMainlandPersonal, - environment: [:], - session: session) - #expect(snapshot.planName == "Pro") - #expect(snapshot.toUsageSnapshot().primary != nil) - #expect(snapshot.toUsageSnapshot().secondary != nil) + for region in [AlibabaTokenPlanAPIRegion.international, .chinaMainland] { + AlibabaTokenPlanStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.host == region.dashboardURL.host, request.httpMethod == "GET" { + return Self.makeResponse( + url: url, + body: "", + statusCode: 200) + } + #expect(url.host == region.dashboardURL.host) + #expect(request.httpMethod == "POST") + let requestBody = Self.requestBodyString(from: request) + #expect(requestBody.contains("GetSubscriptionSummary")) + #expect(requestBody.contains(region.tokenPlanProductCode)) + #expect(requestBody.contains("sec_token=team-token")) + #expect(!requestBody.contains("tokenplan%2Fpersonal")) + return Self.makeResponse(url: url, body: body, statusCode: 200) + } + + let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "api_only=api", + dashboardCookieHeader: "dashboard_only=dashboard", + region: region, + environment: [:], + session: session) + + #expect(snapshot.totalQuota == 1000) + #expect(snapshot.remainingQuota == 900) + } } @Test @@ -1147,7 +1231,8 @@ final class AlibabaTokenPlanStubURLProtocol: URLProtocol { override static func canInit(with request: URLRequest) -> Bool { guard let host = request.url?.host else { return false } - return host == "bailian.console.aliyun.com" || + return host == "modelstudio.console.alibabacloud.com" || + host == "bailian.console.aliyun.com" || host == "bailian-cs.console.aliyun.com" || host == "bailian-singapore-cs.alibabacloud.com" || host == "alibaba-token-plan.test" || From 697097d25570fb51e53df4ac53e54af54c1ef755 Mon Sep 17 00:00:00 2001 From: William Mitchell Date: Thu, 30 Jul 2026 16:39:54 +0100 Subject: [PATCH 2/4] fix(alibaba): fall back for Personal workspace errors --- .../AlibabaTokenPlanUsageFetcher.swift | 119 +++++++++++---- .../AlibabaTokenPlanProviderTests.swift | 139 ++++++++++++++++++ 2 files changed, 232 insertions(+), 26 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index 9048fd0a8a..4c7e9b0b96 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -26,6 +26,13 @@ public enum AlibabaTokenPlanUsageError: LocalizedError, Sendable, Equatable { } } +extension AlibabaTokenPlanUsageError { + fileprivate var isWorkspaceNotAuthorised: Bool { + guard case let .apiError(message) = self else { return false } + return message.localizedCaseInsensitiveContains("BailianGateway.Workspace.NotAuthorised") + } +} + // swiftlint:disable:next type_body_length public struct AlibabaTokenPlanUsageFetcher: Sendable { private struct PersonalAPIContext: Sendable { @@ -36,6 +43,14 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { let session: URLSession } + private struct TeamAPIContext: Sendable { + let apiCookieHeader: String + let secToken: String? + let region: AlibabaTokenPlanAPIRegion + let environment: [String: String] + let session: URLSession + } + private static let log = CodexBarLog.logger("alibaba-token-plan") private static let bssServiceCode = "BssOpenAPI-V3" private static let subscriptionSummaryAction = "GetSubscriptionSummary" @@ -102,7 +117,6 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { throw AlibabaTokenPlanSettingsError.invalidCookie } - let url = self.resolveQuotaURL(region: region, environment: environment) let apiRedirectDiagnostics = RedirectDiagnostics(cookieHeader: normalizedAPIHeader) let dashboardRedirectDiagnostics: RedirectDiagnostics? let apiSession: URLSession @@ -137,14 +151,34 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { region: region, environment: environment, session: dashboardSession) - return try await self.fetchPersonalUsage( - context: PersonalAPIContext( - apiCookieHeader: normalizedAPIHeader, - secToken: secToken, - region: region, - environment: environment, - session: apiSession), - now: now) + do { + return try await self.fetchPersonalUsage( + context: PersonalAPIContext( + apiCookieHeader: normalizedAPIHeader, + secToken: secToken, + region: region, + environment: environment, + session: apiSession), + now: now) + } catch let error as AlibabaTokenPlanUsageError where error.isWorkspaceNotAuthorised { + let fallbackRegion = self.teamFallbackRegion(for: region) + Self.log.info( + "Alibaba Token Plan Personal workspace unavailable; using Team summary fallback", + metadata: [ + "personalRegion": region.rawValue, + "fallbackRegion": fallbackRegion.rawValue, + ]) + return try await self.fetchTeamUsage( + context: TeamAPIContext( + apiCookieHeader: normalizedDashboardHeader, + secToken: secToken, + region: fallbackRegion, + environment: environment, + session: dashboardSession), + now: now, + apiRedirectDiagnostics: dashboardRedirectDiagnostics ?? apiRedirectDiagnostics, + dashboardRedirectDiagnostics: nil) + } } let secToken = await self.resolveSECToken( @@ -153,41 +187,61 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { region: region, environment: environment, session: dashboardSession) + return try await self.fetchTeamUsage( + context: TeamAPIContext( + apiCookieHeader: normalizedAPIHeader, + secToken: secToken, + region: region, + environment: environment, + session: apiSession), + now: now, + apiRedirectDiagnostics: apiRedirectDiagnostics, + dashboardRedirectDiagnostics: dashboardRedirectDiagnostics) + } + + private static func fetchTeamUsage( + context: TeamAPIContext, + now: Date, + apiRedirectDiagnostics: RedirectDiagnostics, + dashboardRedirectDiagnostics: RedirectDiagnostics?) async throws -> AlibabaTokenPlanUsageSnapshot + { + let url = self.resolveQuotaURL(region: context.region, environment: context.environment) Self.log.info( "Fetching Alibaba Token Plan usage", metadata: [ "apiHost": url.host ?? "unknown", - "region": region.rawValue, - "apiCookieNames": self.cookieNamesDescription(from: normalizedAPIHeader), - "dashboardCookieNames": self.cookieNamesDescription(from: normalizedDashboardHeader), - "hasCSRF": self.hasCSRF(in: normalizedAPIHeader) ? "1" : "0", - "secTokenSource": secToken == nil ? "missing" : "resolved", + "region": context.region.rawValue, + "apiCookieNames": self.cookieNamesDescription(from: context.apiCookieHeader), + "hasCSRF": self.hasCSRF(in: context.apiCookieHeader) ? "1" : "0", + "secTokenSource": context.secToken == nil ? "missing" : "resolved", ]) var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 20 - request.httpBody = self.subscriptionSummaryRequestBody(region: region, secToken: secToken) + request.httpBody = self.subscriptionSummaryRequestBody( + region: context.region, + secToken: context.secToken) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.setValue("*/*", forHTTPHeaderField: "Accept") - request.setValue(normalizedAPIHeader, forHTTPHeaderField: "Cookie") - if let csrf = self.extractCookieValue(name: "login_aliyunid_csrf", from: normalizedAPIHeader) ?? - self.extractCookieValue(name: "csrf", from: normalizedAPIHeader) + request.setValue(context.apiCookieHeader, forHTTPHeaderField: "Cookie") + if let csrf = self.extractCookieValue(name: "login_aliyunid_csrf", from: context.apiCookieHeader) ?? + self.extractCookieValue(name: "csrf", from: context.apiCookieHeader) { request.setValue(csrf, forHTTPHeaderField: "x-xsrf-token") request.setValue(csrf, forHTTPHeaderField: "x-csrf-token") } request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") request.setValue(Self.browserLikeUserAgent, forHTTPHeaderField: "User-Agent") - request.setValue(region.dashboardOriginURLString, forHTTPHeaderField: "Origin") + request.setValue(context.region.dashboardOriginURLString, forHTTPHeaderField: "Origin") request.setValue( - Self.dashboardURL(region: region, environment: environment).absoluteString, + Self.dashboardURL(region: context.region, environment: context.environment).absoluteString, forHTTPHeaderField: "Referer") let data: Data let response: URLResponse do { - (data, response) = try await apiSession.data(for: request) + (data, response) = try await context.session.data(for: request) } catch { Self.log.error( "Alibaba Token Plan request failed", @@ -524,6 +578,11 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { { let cookieSECToken = self.extractCookieValue(name: "sec_token", from: dashboardCookieHeader) ?? self.extractCookieValue(name: "sec_token", from: apiCookieHeader) + if let cookieSECToken, !cookieSECToken.isEmpty { + Self.log.info("Resolved Alibaba Token Plan sec_token from cookies") + return cookieSECToken + } + var request = URLRequest(url: self.dashboardURL(region: region, environment: environment)) request.httpMethod = "GET" request.timeoutInterval = 10 @@ -557,11 +616,6 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return token } - if let cookieSECToken, !cookieSECToken.isEmpty { - Self.log.info("Resolved Alibaba Token Plan sec_token from cookies") - return cookieSECToken - } - Self.log.info( "Alibaba Token Plan sec_token missing; continuing with cookie-only request", metadata: [ @@ -624,6 +678,19 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return components.url ?? URL(string: region.dashboardOriginURLString)! } + private static func teamFallbackRegion( + for region: AlibabaTokenPlanAPIRegion) -> AlibabaTokenPlanAPIRegion + { + switch region { + case .internationalPersonal: + .international + case .chinaMainlandPersonal: + .chinaMainland + case .international, .chinaMainland: + region + } + } + private static func quotaURL(from rawHost: String, region: AlibabaTokenPlanAPIRegion) -> URL? { let cleaned = AlibabaTokenPlanSettingsReader.cleaned(rawHost) guard let cleaned else { return nil } diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index 0783b86b28..32a2307c74 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -632,6 +632,145 @@ struct AlibabaTokenPlanUsageParsingTests { } } + @Test + func `Personal fetch uses cookie SEC token without preflight`() async throws { + defer { + AlibabaTokenPlanStubURLProtocol.handler = nil + } + let usageBody = try #require(String(data: alibabaTokenPlanFixture("personal_usage"), encoding: .utf8)) + let subscriptionBody = try #require( + String(data: alibabaTokenPlanFixture("personal_subscription"), encoding: .utf8)) + let quotaBody = try #require( + String(data: alibabaTokenPlanFixture("personal_quota_config"), encoding: .utf8)) + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] + let session = URLSession(configuration: configuration) + for region in [AlibabaTokenPlanAPIRegion.internationalPersonal, .chinaMainlandPersonal] { + AlibabaTokenPlanStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if request.httpMethod == "GET" { + Issue.record("A cookie sec_token should skip dashboard and user-info preflights") + throw URLError(.unsupportedURL) + } + + #expect(url.host == URL(string: region.quotaBaseURLString)?.host) + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Cookie") == "quota_only=quota") + let body = Self.requestBodyString(from: request) + #expect(body.contains("sec_token=cookie-token")) + + let api = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "api" })? + .value + switch api { + case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage": + return Self.makeResponse(url: url, body: usageBody, statusCode: 200) + case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription": + return Self.makeResponse(url: url, body: subscriptionBody, statusCode: 200) + case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config": + return Self.makeResponse(url: url, body: quotaBody, statusCode: 200) + default: + throw URLError(.unsupportedURL) + } + } + + let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "quota_only=quota", + dashboardCookieHeader: "dashboard_only=dashboard; sec_token=cookie-token", + region: region, + environment: [:], + session: session) + + #expect(snapshot.planName == "Pro") + #expect(snapshot.toUsageSnapshot().primary != nil) + #expect(snapshot.toUsageSnapshot().secondary != nil) + } + } + + @Test + func `Personal workspace error falls back to matching Team route`() async throws { + defer { + AlibabaTokenPlanStubURLProtocol.handler = nil + } + let workspaceError = """ + { + "code": "200", + "data": { + "success": false, + "httpStatus": 200, + "errorCode": "BailianGateway.Workspace.NotAuthorised", + "errorMsg": "BailianGateway.Workspace.NotAuthorised" + }, + "httpStatusCode": "200", + "successResponse": true + } + """ + let teamSummary = """ + { + "Success": true, + "Data": { + "TotalCount": 1, + "TotalValue": 1000, + "TotalSurplusValue": 900 + } + } + """ + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] + let session = URLSession(configuration: configuration) + + for personalRegion in [AlibabaTokenPlanAPIRegion.internationalPersonal, .chinaMainlandPersonal] { + let teamRegion: AlibabaTokenPlanAPIRegion = switch personalRegion { + case .internationalPersonal: + .international + case .chinaMainlandPersonal: + .chinaMainland + case .international, .chinaMainland: + personalRegion + } + AlibabaTokenPlanStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.host == personalRegion.dashboardURL.host, request.httpMethod == "GET" { + #expect(request.value(forHTTPHeaderField: "Cookie") == "dashboard_only=dashboard") + return Self.makeResponse( + url: url, + body: "", + statusCode: 200) + } + + if url.host == URL(string: personalRegion.quotaBaseURLString)?.host { + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Cookie") == "quota_only=quota") + #expect(Self.requestBodyString(from: request).contains("sec_token=fallback-token")) + return Self.makeResponse(url: url, body: workspaceError, statusCode: 200) + } + + #expect(url.host == teamRegion.dashboardURL.host) + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Cookie") == "dashboard_only=dashboard") + #expect(request.value(forHTTPHeaderField: "Origin") == teamRegion.dashboardOriginURLString) + let body = Self.requestBodyString(from: request) + #expect(body.contains("GetSubscriptionSummary")) + #expect(body.contains(teamRegion.tokenPlanProductCode)) + #expect(body.contains("sec_token=fallback-token")) + #expect(!body.contains("tokenplan%2Fpersonal")) + return Self.makeResponse(url: url, body: teamSummary, statusCode: 200) + } + + let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "quota_only=quota", + dashboardCookieHeader: "dashboard_only=dashboard", + region: personalRegion, + environment: [:], + session: session) + + #expect(snapshot.totalQuota == 1000) + #expect(snapshot.remainingQuota == 900) + } + } + @Test func `Team fetch retains subscription summary routes`() async throws { defer { From 81fd03439c5a5e32dd1754ded7c6c0753ee62a14 Mon Sep 17 00:00:00 2001 From: William Mitchell Date: Thu, 30 Jul 2026 18:03:16 +0100 Subject: [PATCH 3/4] fix(alibaba): correct Personal token plan requests Use the active Alibaba session instead of a hardcoded workspace, prefer an existing SEC token without preflight delay, and keep Personal failures from falling through to Team quota data. --- .../AlibabaTokenPlanUsageFetcher.swift | 128 +++++------------- .../AlibabaTokenPlanProviderTests.swift | 76 ++++------- 2 files changed, 61 insertions(+), 143 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index 4c7e9b0b96..987a2483b7 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -26,13 +26,6 @@ public enum AlibabaTokenPlanUsageError: LocalizedError, Sendable, Equatable { } } -extension AlibabaTokenPlanUsageError { - fileprivate var isWorkspaceNotAuthorised: Bool { - guard case let .apiError(message) = self else { return false } - return message.localizedCaseInsensitiveContains("BailianGateway.Workspace.NotAuthorised") - } -} - // swiftlint:disable:next type_body_length public struct AlibabaTokenPlanUsageFetcher: Sendable { private struct PersonalAPIContext: Sendable { @@ -43,14 +36,6 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { let session: URLSession } - private struct TeamAPIContext: Sendable { - let apiCookieHeader: String - let secToken: String? - let region: AlibabaTokenPlanAPIRegion - let environment: [String: String] - let session: URLSession - } - private static let log = CodexBarLog.logger("alibaba-token-plan") private static let bssServiceCode = "BssOpenAPI-V3" private static let subscriptionSummaryAction = "GetSubscriptionSummary" @@ -117,6 +102,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { throw AlibabaTokenPlanSettingsError.invalidCookie } + let url = self.resolveQuotaURL(region: region, environment: environment) let apiRedirectDiagnostics = RedirectDiagnostics(cookieHeader: normalizedAPIHeader) let dashboardRedirectDiagnostics: RedirectDiagnostics? let apiSession: URLSession @@ -151,34 +137,14 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { region: region, environment: environment, session: dashboardSession) - do { - return try await self.fetchPersonalUsage( - context: PersonalAPIContext( - apiCookieHeader: normalizedAPIHeader, - secToken: secToken, - region: region, - environment: environment, - session: apiSession), - now: now) - } catch let error as AlibabaTokenPlanUsageError where error.isWorkspaceNotAuthorised { - let fallbackRegion = self.teamFallbackRegion(for: region) - Self.log.info( - "Alibaba Token Plan Personal workspace unavailable; using Team summary fallback", - metadata: [ - "personalRegion": region.rawValue, - "fallbackRegion": fallbackRegion.rawValue, - ]) - return try await self.fetchTeamUsage( - context: TeamAPIContext( - apiCookieHeader: normalizedDashboardHeader, - secToken: secToken, - region: fallbackRegion, - environment: environment, - session: dashboardSession), - now: now, - apiRedirectDiagnostics: dashboardRedirectDiagnostics ?? apiRedirectDiagnostics, - dashboardRedirectDiagnostics: nil) - } + return try await self.fetchPersonalUsage( + context: PersonalAPIContext( + apiCookieHeader: normalizedAPIHeader, + secToken: secToken, + region: region, + environment: environment, + session: apiSession), + now: now) } let secToken = await self.resolveSECToken( @@ -187,61 +153,41 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { region: region, environment: environment, session: dashboardSession) - return try await self.fetchTeamUsage( - context: TeamAPIContext( - apiCookieHeader: normalizedAPIHeader, - secToken: secToken, - region: region, - environment: environment, - session: apiSession), - now: now, - apiRedirectDiagnostics: apiRedirectDiagnostics, - dashboardRedirectDiagnostics: dashboardRedirectDiagnostics) - } - - private static func fetchTeamUsage( - context: TeamAPIContext, - now: Date, - apiRedirectDiagnostics: RedirectDiagnostics, - dashboardRedirectDiagnostics: RedirectDiagnostics?) async throws -> AlibabaTokenPlanUsageSnapshot - { - let url = self.resolveQuotaURL(region: context.region, environment: context.environment) Self.log.info( "Fetching Alibaba Token Plan usage", metadata: [ "apiHost": url.host ?? "unknown", - "region": context.region.rawValue, - "apiCookieNames": self.cookieNamesDescription(from: context.apiCookieHeader), - "hasCSRF": self.hasCSRF(in: context.apiCookieHeader) ? "1" : "0", - "secTokenSource": context.secToken == nil ? "missing" : "resolved", + "region": region.rawValue, + "apiCookieNames": self.cookieNamesDescription(from: normalizedAPIHeader), + "dashboardCookieNames": self.cookieNamesDescription(from: normalizedDashboardHeader), + "hasCSRF": self.hasCSRF(in: normalizedAPIHeader) ? "1" : "0", + "secTokenSource": secToken == nil ? "missing" : "resolved", ]) var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 20 - request.httpBody = self.subscriptionSummaryRequestBody( - region: context.region, - secToken: context.secToken) + request.httpBody = self.subscriptionSummaryRequestBody(region: region, secToken: secToken) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.setValue("*/*", forHTTPHeaderField: "Accept") - request.setValue(context.apiCookieHeader, forHTTPHeaderField: "Cookie") - if let csrf = self.extractCookieValue(name: "login_aliyunid_csrf", from: context.apiCookieHeader) ?? - self.extractCookieValue(name: "csrf", from: context.apiCookieHeader) + request.setValue(normalizedAPIHeader, forHTTPHeaderField: "Cookie") + if let csrf = self.extractCookieValue(name: "login_aliyunid_csrf", from: normalizedAPIHeader) ?? + self.extractCookieValue(name: "csrf", from: normalizedAPIHeader) { request.setValue(csrf, forHTTPHeaderField: "x-xsrf-token") request.setValue(csrf, forHTTPHeaderField: "x-csrf-token") } request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") request.setValue(Self.browserLikeUserAgent, forHTTPHeaderField: "User-Agent") - request.setValue(context.region.dashboardOriginURLString, forHTTPHeaderField: "Origin") + request.setValue(region.dashboardOriginURLString, forHTTPHeaderField: "Origin") request.setValue( - Self.dashboardURL(region: context.region, environment: context.environment).absoluteString, + Self.dashboardURL(region: region, environment: environment).absoluteString, forHTTPHeaderField: "Referer") let data: Data let response: URLResponse do { - (data, response) = try await context.session.data(for: request) + (data, response) = try await apiSession.data(for: request) } catch { Self.log.error( "Alibaba Token Plan request failed", @@ -393,16 +339,21 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { api: self.personalUsageAPI, dataParameters: [:], context: context) - let subscriptionData = await self.fetchOptionalPersonalAPI( + _ = try AlibabaTokenPlanPersonalUsageParser.parse( + from: usageData, + subscriptionData: nil, + quotaConfigData: nil, + now: now) + async let subscriptionData = self.fetchOptionalPersonalAPI( api: self.personalSubscriptionAPI, dataParameters: ["commodityCode": context.region.tokenPlanProductCode], context: context) - let quotaConfigData = await self.fetchOptionalPersonalAPI( + async let quotaConfigData = self.fetchOptionalPersonalAPI( api: self.personalQuotaConfigAPI, dataParameters: [:], context: context) - return try AlibabaTokenPlanPersonalUsageParser.parse( + return try await AlibabaTokenPlanPersonalUsageParser.parse( from: usageData, subscriptionData: subscriptionData, quotaConfigData: quotaConfigData, @@ -509,8 +460,6 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "protocol": "V2", "console": "ONE_CONSOLE", "productCode": "p_efm", - "switchAgent": 1_233_135, - "switchUserType": 3, "domain": dashboardURL.host ?? "", "consoleSite": context.region.personalConsoleSite, "userNickName": "", @@ -520,7 +469,11 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { if let anonymousID = self.extractCookieValue(name: "cna", from: context.apiCookieHeader), !anonymousID.isEmpty { cornerstone["X-Anonymous-Id"] = anonymousID } - var apiData = dataParameters as [String: Any] + var apiData: [String: Any] = if api == self.personalSubscriptionAPI { + ["queryInstanceInfoRequest": dataParameters] + } else { + dataParameters + } apiData["cornerstoneParam"] = cornerstone let params: [String: Any] = [ "Api": api, @@ -678,19 +631,6 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return components.url ?? URL(string: region.dashboardOriginURLString)! } - private static func teamFallbackRegion( - for region: AlibabaTokenPlanAPIRegion) -> AlibabaTokenPlanAPIRegion - { - switch region { - case .internationalPersonal: - .international - case .chinaMainlandPersonal: - .chinaMainland - case .international, .chinaMainland: - region - } - } - private static func quotaURL(from rawHost: String, region: AlibabaTokenPlanAPIRegion) -> URL? { let cleaned = AlibabaTokenPlanSettingsReader.cleaned(rawHost) guard let cleaned else { return nil } diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index 32a2307c74..51416d5380 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -600,7 +600,10 @@ struct AlibabaTokenPlanUsageParsingTests { #expect(request.value(forHTTPHeaderField: "Origin") == region.dashboardOriginURLString) let body = Self.requestBodyString(from: request) #expect(body.contains("sec_token=personal-token")) - #expect(body.removingPercentEncoding?.contains("cornerstoneParam") == true) + let decodedBody = body.removingPercentEncoding ?? body + #expect(decodedBody.contains("cornerstoneParam")) + #expect(!decodedBody.contains("\"switchAgent\"")) + #expect(!decodedBody.contains("\"switchUserType\"")) let api = URLComponents(url: url, resolvingAgainstBaseURL: false)? .queryItems? @@ -610,7 +613,8 @@ struct AlibabaTokenPlanUsageParsingTests { case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage": return Self.makeResponse(url: url, body: usageBody, statusCode: 200) case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription": - #expect(body.removingPercentEncoding?.contains(region.tokenPlanProductCode) == true) + #expect(decodedBody.contains("\"queryInstanceInfoRequest\"")) + #expect(decodedBody.contains(region.tokenPlanProductCode)) return Self.makeResponse(url: url, body: subscriptionBody, statusCode: 200) case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config": return Self.makeResponse(url: url, body: quotaBody, statusCode: 200) @@ -690,7 +694,7 @@ struct AlibabaTokenPlanUsageParsingTests { } @Test - func `Personal workspace error falls back to matching Team route`() async throws { + func `Personal workspace error does not fall back to Team`() async throws { defer { AlibabaTokenPlanStubURLProtocol.handler = nil } @@ -707,67 +711,41 @@ struct AlibabaTokenPlanUsageParsingTests { "successResponse": true } """ - let teamSummary = """ - { - "Success": true, - "Data": { - "TotalCount": 1, - "TotalValue": 1000, - "TotalSurplusValue": 900 - } - } - """ let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] let session = URLSession(configuration: configuration) - for personalRegion in [AlibabaTokenPlanAPIRegion.internationalPersonal, .chinaMainlandPersonal] { - let teamRegion: AlibabaTokenPlanAPIRegion = switch personalRegion { - case .internationalPersonal: - .international - case .chinaMainlandPersonal: - .chinaMainland - case .international, .chinaMainland: - personalRegion - } + for region in [AlibabaTokenPlanAPIRegion.internationalPersonal, .chinaMainlandPersonal] { + let postCount = LockIsolated(0) AlibabaTokenPlanStubURLProtocol.handler = { request in guard let url = request.url else { throw URLError(.badURL) } - if url.host == personalRegion.dashboardURL.host, request.httpMethod == "GET" { + if url.host == region.dashboardURL.host, request.httpMethod == "GET" { #expect(request.value(forHTTPHeaderField: "Cookie") == "dashboard_only=dashboard") return Self.makeResponse( url: url, - body: "", + body: "", statusCode: 200) } - if url.host == URL(string: personalRegion.quotaBaseURLString)?.host { - #expect(request.httpMethod == "POST") - #expect(request.value(forHTTPHeaderField: "Cookie") == "quota_only=quota") - #expect(Self.requestBodyString(from: request).contains("sec_token=fallback-token")) - return Self.makeResponse(url: url, body: workspaceError, statusCode: 200) - } - - #expect(url.host == teamRegion.dashboardURL.host) + postCount.setValue(postCount.value + 1) + #expect(url.host == URL(string: region.quotaBaseURLString)?.host) #expect(request.httpMethod == "POST") - #expect(request.value(forHTTPHeaderField: "Cookie") == "dashboard_only=dashboard") - #expect(request.value(forHTTPHeaderField: "Origin") == teamRegion.dashboardOriginURLString) - let body = Self.requestBodyString(from: request) - #expect(body.contains("GetSubscriptionSummary")) - #expect(body.contains(teamRegion.tokenPlanProductCode)) - #expect(body.contains("sec_token=fallback-token")) - #expect(!body.contains("tokenplan%2Fpersonal")) - return Self.makeResponse(url: url, body: teamSummary, statusCode: 200) + #expect(request.value(forHTTPHeaderField: "Cookie") == "quota_only=quota") + #expect(Self.requestBodyString(from: request).contains("sec_token=personal-token")) + return Self.makeResponse(url: url, body: workspaceError, statusCode: 200) } - let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( - apiCookieHeader: "quota_only=quota", - dashboardCookieHeader: "dashboard_only=dashboard", - region: personalRegion, - environment: [:], - session: session) - - #expect(snapshot.totalQuota == 1000) - #expect(snapshot.remainingQuota == 900) + await #expect(throws: AlibabaTokenPlanUsageError.apiError( + "BailianGateway.Workspace.NotAuthorised")) + { + try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "quota_only=quota", + dashboardCookieHeader: "dashboard_only=dashboard", + region: region, + environment: [:], + session: session) + } + #expect(postCount.value == 1) } } From 43f77a92c1ef2ff9d066b16a1f35da59bae10ea0 Mon Sep 17 00:00:00 2001 From: William Mitchell Date: Thu, 30 Jul 2026 20:41:33 +0100 Subject: [PATCH 4/4] fix(alibaba): refresh rejected SEC tokens Retry Personal and Team quota requests once with a freshly resolved token when a cookie-backed token is rejected, without delaying the initial request or repeating the rejected token. --- .../AlibabaTokenPlanUsageFetcher.swift | 158 +++++++++++++----- .../AlibabaTokenPlanProviderTests.swift | 91 ++++++++++ 2 files changed, 205 insertions(+), 44 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index 987a2483b7..6809b189da 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -26,6 +26,17 @@ public enum AlibabaTokenPlanUsageError: LocalizedError, Sendable, Equatable { } } +extension AlibabaTokenPlanUsageError { + fileprivate var canRefreshSECToken: Bool { + switch self { + case .loginRequired, .invalidCredentials: + true + case .apiError, .networkError, .parseFailed: + false + } + } +} + // swiftlint:disable:next type_body_length public struct AlibabaTokenPlanUsageFetcher: Sendable { private struct PersonalAPIContext: Sendable { @@ -94,7 +105,8 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { region: AlibabaTokenPlanAPIRegion = .international, environment: [String: String] = ProcessInfo.processInfo.environment, now: Date = Date(), - session overrideSession: URLSession? = nil) async throws -> AlibabaTokenPlanUsageSnapshot + session overrideSession: URLSession? = nil, + rejectedSECToken: String? = nil) async throws -> AlibabaTokenPlanUsageSnapshot { guard let normalizedAPIHeader = CookieHeaderNormalizer.normalize(apiCookieHeader), let normalizedDashboardHeader = CookieHeaderNormalizer.normalize(dashboardCookieHeader) @@ -130,29 +142,58 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } } - if region.usesPersonalTokenPlanAPI { - let secToken = await self.resolveSECToken( - dashboardCookieHeader: normalizedDashboardHeader, - apiCookieHeader: normalizedAPIHeader, - region: region, - environment: environment, - session: dashboardSession) - return try await self.fetchPersonalUsage( - context: PersonalAPIContext( - apiCookieHeader: normalizedAPIHeader, - secToken: secToken, - region: region, - environment: environment, - session: apiSession), - now: now) - } - + let cookieSECToken = self.extractCookieValue(name: "sec_token", from: normalizedDashboardHeader) ?? + self.extractCookieValue(name: "sec_token", from: normalizedAPIHeader) let secToken = await self.resolveSECToken( dashboardCookieHeader: normalizedDashboardHeader, apiCookieHeader: normalizedAPIHeader, region: region, environment: environment, - session: dashboardSession) + session: dashboardSession, + useCookieToken: rejectedSECToken == nil, + rejectedToken: rejectedSECToken) + if rejectedSECToken != nil { + guard secToken != nil else { + throw AlibabaTokenPlanUsageError.loginRequired + } + } + let usedCookieSECToken = cookieSECToken != nil && secToken == cookieSECToken + + func retryAfterRejectedSECToken( + _ error: AlibabaTokenPlanUsageError) async throws -> AlibabaTokenPlanUsageSnapshot + { + guard rejectedSECToken == nil, + usedCookieSECToken, + error.canRefreshSECToken, + let secToken + else { + throw error + } + return try await self.fetchUsage( + apiCookieHeader: normalizedAPIHeader, + dashboardCookieHeader: normalizedDashboardHeader, + region: region, + environment: environment, + now: now, + session: overrideSession, + rejectedSECToken: secToken) + } + + if region.usesPersonalTokenPlanAPI { + do { + return try await self.fetchPersonalUsage( + context: PersonalAPIContext( + apiCookieHeader: normalizedAPIHeader, + secToken: secToken, + region: region, + environment: environment, + session: apiSession), + now: now) + } catch let error as AlibabaTokenPlanUsageError { + return try await retryAfterRejectedSECToken(error) + } + } + Self.log.info( "Fetching Alibaba Token Plan usage", metadata: [ @@ -164,25 +205,12 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "secTokenSource": secToken == nil ? "missing" : "resolved", ]) - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.timeoutInterval = 20 - request.httpBody = self.subscriptionSummaryRequestBody(region: region, secToken: secToken) - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.setValue("*/*", forHTTPHeaderField: "Accept") - request.setValue(normalizedAPIHeader, forHTTPHeaderField: "Cookie") - if let csrf = self.extractCookieValue(name: "login_aliyunid_csrf", from: normalizedAPIHeader) ?? - self.extractCookieValue(name: "csrf", from: normalizedAPIHeader) - { - request.setValue(csrf, forHTTPHeaderField: "x-xsrf-token") - request.setValue(csrf, forHTTPHeaderField: "x-csrf-token") - } - request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") - request.setValue(Self.browserLikeUserAgent, forHTTPHeaderField: "User-Agent") - request.setValue(region.dashboardOriginURLString, forHTTPHeaderField: "Origin") - request.setValue( - Self.dashboardURL(region: region, environment: environment).absoluteString, - forHTTPHeaderField: "Referer") + let request = self.teamUsageRequest( + url: url, + apiCookieHeader: normalizedAPIHeader, + secToken: secToken, + region: region, + environment: environment) let data: Data let response: URLResponse @@ -226,13 +254,17 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { ]) guard httpResponse.statusCode == 200 else { if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { - throw AlibabaTokenPlanUsageError.loginRequired + return try await retryAfterRejectedSECToken(.loginRequired) } Self.log.error("Alibaba Token Plan returned HTTP \(httpResponse.statusCode)") throw AlibabaTokenPlanUsageError.apiError("HTTP \(httpResponse.statusCode)") } - return try self.parseUsageSnapshot(from: data, now: now) + do { + return try self.parseUsageSnapshot(from: data, now: now) + } catch let error as AlibabaTokenPlanUsageError { + return try await retryAfterRejectedSECToken(error) + } } static func resolveQuotaURL( @@ -522,16 +554,51 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return Data((components.percentEncodedQuery ?? "").utf8) } + private static func teamUsageRequest( + url: URL, + apiCookieHeader: String, + secToken: String?, + region: AlibabaTokenPlanAPIRegion, + environment: [String: String]) -> URLRequest + { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 20 + request.httpBody = self.subscriptionSummaryRequestBody(region: region, secToken: secToken) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("*/*", forHTTPHeaderField: "Accept") + request.setValue(apiCookieHeader, forHTTPHeaderField: "Cookie") + if let csrf = self.extractCookieValue(name: "login_aliyunid_csrf", from: apiCookieHeader) ?? + self.extractCookieValue(name: "csrf", from: apiCookieHeader) + { + request.setValue(csrf, forHTTPHeaderField: "x-xsrf-token") + request.setValue(csrf, forHTTPHeaderField: "x-csrf-token") + } + request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") + request.setValue(Self.browserLikeUserAgent, forHTTPHeaderField: "User-Agent") + request.setValue(region.dashboardOriginURLString, forHTTPHeaderField: "Origin") + request.setValue( + Self.dashboardURL(region: region, environment: environment).absoluteString, + forHTTPHeaderField: "Referer") + return request + } + private static func resolveSECToken( dashboardCookieHeader: String, apiCookieHeader: String, region: AlibabaTokenPlanAPIRegion, environment: [String: String], - session: URLSession) async -> String? + session: URLSession, + useCookieToken: Bool = true, + rejectedToken: String? = nil) async -> String? { let cookieSECToken = self.extractCookieValue(name: "sec_token", from: dashboardCookieHeader) ?? self.extractCookieValue(name: "sec_token", from: apiCookieHeader) - if let cookieSECToken, !cookieSECToken.isEmpty { + if useCookieToken, + let cookieSECToken, + !cookieSECToken.isEmpty, + cookieSECToken != rejectedToken + { Self.log.info("Resolved Alibaba Token Plan sec_token from cookies") return cookieSECToken } @@ -549,7 +616,8 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, let html = String(data: data, encoding: .utf8), - let token = self.extractSECToken(from: html) + let token = self.extractSECToken(from: html), + token != rejectedToken { Self.log.info( "Resolved Alibaba Token Plan sec_token from dashboard HTML", @@ -566,7 +634,9 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { environment: environment, session: session) { - return token + if token != rejectedToken { + return token + } } Self.log.info( diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index 51416d5380..cff946832a 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -693,6 +693,97 @@ struct AlibabaTokenPlanUsageParsingTests { } } + @Test + func `rejected cookie SEC token refreshes once before retry`() async throws { + defer { + AlibabaTokenPlanStubURLProtocol.handler = nil + } + let usageBody = try #require(String(data: alibabaTokenPlanFixture("personal_usage"), encoding: .utf8)) + let tokenError = #"{"code":"PostonlyOrTokenError","successResponse":false}"# + let teamSummary = + #"{"Success":true,"Data":{"TotalCount":1,"TotalValue":1000,"TotalSurplusValue":900}}"# + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] + let session = URLSession(configuration: configuration) + + for region in [ + AlibabaTokenPlanAPIRegion.internationalPersonal, + .chinaMainlandPersonal, + .international, + .chinaMainland, + ] { + let sawStalePost = LockIsolated(false) + let sawFreshPost = LockIsolated(false) + let sawDashboard = LockIsolated(false) + let sawUserInfo = LockIsolated(false) + AlibabaTokenPlanStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if request.httpMethod == "GET" { + #expect(sawStalePost.value) + #expect(request.value(forHTTPHeaderField: "Cookie") == + "dashboard_only=dashboard; sec_token=stale-token") + if url.path == "/tool/user/info.json" { + #expect(sawDashboard.value) + #expect(!sawUserInfo.value) + sawUserInfo.setValue(true) + let userInfo = #"{"data":{"secToken":"fresh-token"}}"# + return Self.makeResponse(url: url, body: userInfo, statusCode: 200) + } + + #expect(!sawDashboard.value) + sawDashboard.setValue(true) + let token = region.usesPersonalTokenPlanAPI ? "fresh-token" : "stale-token" + return Self.makeResponse( + url: url, + body: "", + statusCode: 200) + } + + #expect(request.httpMethod == "POST") + let body = Self.requestBodyString(from: request) + let token = if body.contains("sec_token=stale-token") { + "stale" + } else if body.contains("sec_token=fresh-token") { + "fresh" + } else { + "missing" + } + if token == "stale" { + #expect(!sawStalePost.value) + sawStalePost.setValue(true) + return Self.makeResponse(url: url, body: tokenError, statusCode: 200) + } + #expect(token == "fresh") + sawFreshPost.setValue(true) + + if region.usesPersonalTokenPlanAPI { + let api = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "api" })? + .value + let responseBody = api == "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage" + ? usageBody + : "{}" + return Self.makeResponse(url: url, body: responseBody, statusCode: 200) + } + return Self.makeResponse(url: url, body: teamSummary, statusCode: 200) + } + + let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "quota_only=quota", + dashboardCookieHeader: "dashboard_only=dashboard; sec_token=stale-token", + region: region, + environment: [:], + session: session) + + #expect(snapshot.planName == (region.usesPersonalTokenPlanAPI ? "Personal" : "TOKEN PLAN")) + #expect(sawStalePost.value) + #expect(sawFreshPost.value) + #expect(sawDashboard.value) + #expect(sawUserInfo.value == !region.usesPersonalTokenPlanAPI) + } + } + @Test func `Personal workspace error does not fall back to Team`() async throws { defer {