335 lines
15 KiB
Swift
335 lines
15 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodexBar
|
|
@testable import CodexBarCLI
|
|
@testable import CodexBarCore
|
|
|
|
@Suite(.serialized)
|
|
struct OpenAIAPIProjectScopeTests {
|
|
@Test
|
|
@MainActor
|
|
func `token account strips configured project in app environment builder`() {
|
|
let settings = Self.makeSettingsStore(suite: "OpenAIAPIProjectScopeTests-app")
|
|
settings.openAIAPIKey = "config-token"
|
|
settings.openAIAPIProjectID = "proj_config"
|
|
settings.addTokenAccount(provider: .openai, label: "Configured account", token: "first-account-token")
|
|
settings.addTokenAccount(provider: .openai, label: "Selected account", token: "selected-account-token")
|
|
let selectedAccount = settings.tokenAccounts(for: .openai)[1]
|
|
|
|
let env = ProviderRegistry.makeEnvironment(
|
|
base: [OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_env"],
|
|
provider: .openai,
|
|
settings: settings,
|
|
tokenOverride: TokenAccountOverride(provider: .openai, account: selectedAccount))
|
|
|
|
#expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "selected-account-token")
|
|
#expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] != "config-token")
|
|
#expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] != "first-account-token")
|
|
#expect(env[OpenAIAPISettingsReader.projectIDEnvironmentKey] == nil)
|
|
}
|
|
|
|
@Test
|
|
func `token account strips configured project in CLI environment builder`() throws {
|
|
let account = ProviderTokenAccount(
|
|
id: UUID(),
|
|
label: "Project account",
|
|
token: "account-token",
|
|
addedAt: Date().timeIntervalSince1970,
|
|
lastUsed: nil)
|
|
let accounts = ProviderTokenAccountData(version: 1, accounts: [account], activeIndex: 0)
|
|
let config = CodexBarConfig(
|
|
providers: [
|
|
ProviderConfig(
|
|
id: .openai,
|
|
apiKey: "config-token",
|
|
workspaceID: "proj_config",
|
|
tokenAccounts: accounts),
|
|
])
|
|
let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false)
|
|
let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false)
|
|
|
|
let env = tokenContext.environment(
|
|
base: [OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_env"],
|
|
provider: .openai,
|
|
account: account)
|
|
|
|
#expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "account-token")
|
|
#expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] != "config-token")
|
|
#expect(env[OpenAIAPISettingsReader.projectIDEnvironmentKey] == nil)
|
|
}
|
|
|
|
@Test
|
|
@MainActor
|
|
func `configured app project scopes admin usage strategy`() async throws {
|
|
let settings = Self.makeSettingsStore(suite: "OpenAIAPIProjectScopeTests-configured-project")
|
|
settings.openAIAPIKey = "config-token"
|
|
settings.openAIAPIProjectID = "proj_config"
|
|
let env = ProviderRegistry.makeEnvironment(
|
|
base: [:],
|
|
provider: .openai,
|
|
settings: settings,
|
|
tokenOverride: nil)
|
|
let strategy = OpenAIAPIBalanceFetchStrategy(
|
|
usageFetcher: { credential, historyDays in
|
|
#expect(credential.apiKey == "config-token")
|
|
#expect(credential.projectID == "proj_config")
|
|
#expect(historyDays == 30)
|
|
return OpenAIAPIUsageSnapshot(
|
|
daily: [],
|
|
updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
|
projectID: credential.projectID)
|
|
},
|
|
balanceFetcher: { _ in
|
|
Issue.record("Configured project usage should not fetch legacy organization balance.")
|
|
return OpenAIAPICreditBalanceSnapshot(
|
|
totalGranted: 100,
|
|
totalUsed: 25,
|
|
totalAvailable: 75,
|
|
nextGrantExpiry: nil,
|
|
updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
|
|
})
|
|
|
|
let result = try await strategy.fetch(Self.makeContext(env: env))
|
|
|
|
#expect(result.sourceLabel == "admin-api:project")
|
|
#expect(result.usage.identity?.loginMethod == "Admin API: proj_config")
|
|
#expect(result.usage.identity?.accountOrganization == "Project: proj_config")
|
|
}
|
|
|
|
@Test
|
|
func `legacy API key environment can scope admin usage by project`() async throws {
|
|
let strategy = OpenAIAPIBalanceFetchStrategy(
|
|
usageFetcher: { credential, historyDays in
|
|
#expect(credential.apiKey == "sk-admin-legacy")
|
|
#expect(credential.projectID == "proj_legacy")
|
|
#expect(credential.usesAdminKey == false)
|
|
#expect(historyDays == 30)
|
|
return OpenAIAPIUsageSnapshot(
|
|
daily: [],
|
|
updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
|
projectID: credential.projectID)
|
|
},
|
|
balanceFetcher: { _ in
|
|
Issue.record("Legacy OPENAI_API_KEY project usage should not fetch unscoped balance.")
|
|
return OpenAIAPICreditBalanceSnapshot(
|
|
totalGranted: 100,
|
|
totalUsed: 25,
|
|
totalAvailable: 75,
|
|
nextGrantExpiry: nil,
|
|
updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
|
|
})
|
|
|
|
let result = try await strategy.fetch(Self.makeContext(
|
|
env: [
|
|
OpenAIAPISettingsReader.apiKeyEnvironmentKey: "sk-admin-legacy",
|
|
OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_legacy",
|
|
]))
|
|
|
|
#expect(result.sourceLabel == "admin-api:project")
|
|
#expect(result.usage.identity?.loginMethod == "Admin API: proj_legacy")
|
|
#expect(result.usage.identity?.accountOrganization == "Project: proj_legacy")
|
|
}
|
|
|
|
@Test
|
|
func `ambient project with legacy API key preserves billing fallback`() async throws {
|
|
let strategy = OpenAIAPIBalanceFetchStrategy(
|
|
usageFetcher: { credential, historyDays in
|
|
#expect(credential.apiKey == "sk-ambient")
|
|
#expect(credential.projectID == "proj_ambient")
|
|
#expect(credential.usesAdminKey == false)
|
|
#expect(historyDays == 30)
|
|
throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403)
|
|
},
|
|
balanceFetcher: { apiKey in
|
|
#expect(apiKey == "sk-ambient")
|
|
return OpenAIAPICreditBalanceSnapshot(
|
|
totalGranted: 100,
|
|
totalUsed: 25,
|
|
totalAvailable: 75,
|
|
nextGrantExpiry: nil,
|
|
updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
|
|
})
|
|
|
|
let result = try await strategy.fetch(Self.makeContext(
|
|
env: [
|
|
OpenAIAPISettingsReader.apiKeyEnvironmentKey: "sk-ambient",
|
|
OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_ambient",
|
|
]))
|
|
|
|
#expect(result.sourceLabel == "billing-api")
|
|
#expect(result.usage.identity?.loginMethod == "API balance: $75.00")
|
|
#expect(result.usage.identity?.accountOrganization == nil)
|
|
}
|
|
|
|
@Test
|
|
func `project filtered admin usage does not fall back on service failure`() async {
|
|
let usageFailure = OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 500)
|
|
let strategy = OpenAIAPIBalanceFetchStrategy(
|
|
usageFetcher: { credential, historyDays in
|
|
#expect(credential.apiKey == "sk-test")
|
|
#expect(credential.projectID == "proj_abc")
|
|
#expect(credential.usesAdminKey == true)
|
|
#expect(historyDays == 30)
|
|
throw usageFailure
|
|
},
|
|
balanceFetcher: { _ in
|
|
Issue.record("Project-filtered usage must not fall back to organization balance.")
|
|
return OpenAIAPICreditBalanceSnapshot(
|
|
totalGranted: 100,
|
|
totalUsed: 25,
|
|
totalAvailable: 75,
|
|
nextGrantExpiry: nil,
|
|
updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
|
|
})
|
|
|
|
do {
|
|
_ = try await strategy.fetch(Self.makeContext(
|
|
env: [
|
|
OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test",
|
|
OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_abc",
|
|
]))
|
|
Issue.record("Expected project-filtered admin usage failure.")
|
|
} catch let error as OpenAIAPIUsageError {
|
|
#expect(error == usageFailure)
|
|
} catch {
|
|
Issue.record("Expected OpenAIAPIUsageError, got \(error)")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `project filtered admin usage does not fall back on credential rejection`() async {
|
|
let usageFailure = OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403)
|
|
let strategy = OpenAIAPIBalanceFetchStrategy(
|
|
usageFetcher: { credential, historyDays in
|
|
#expect(credential.apiKey == "sk-test")
|
|
#expect(credential.projectID == "proj_abc")
|
|
#expect(historyDays == 30)
|
|
throw usageFailure
|
|
},
|
|
balanceFetcher: { _ in
|
|
Issue.record("Project-filtered usage must fail closed instead of showing unscoped balance.")
|
|
return OpenAIAPICreditBalanceSnapshot(
|
|
totalGranted: 100,
|
|
totalUsed: 25,
|
|
totalAvailable: 75,
|
|
nextGrantExpiry: nil,
|
|
updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
|
|
})
|
|
|
|
do {
|
|
_ = try await strategy.fetch(Self.makeContext(
|
|
env: [
|
|
OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test",
|
|
OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_abc",
|
|
]))
|
|
Issue.record("Expected project-filtered admin credential failure.")
|
|
} catch let error as OpenAIAPIUsageError {
|
|
#expect(error == usageFailure)
|
|
} catch {
|
|
Issue.record("Expected OpenAIAPIUsageError, got \(error)")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `project filtered admin usage reports project source label`() async throws {
|
|
let strategy = OpenAIAPIBalanceFetchStrategy(
|
|
usageFetcher: { credential, _ in
|
|
#expect(credential.apiKey == "sk-test")
|
|
#expect(credential.projectID == "proj_abc")
|
|
return OpenAIAPIUsageSnapshot(
|
|
daily: [],
|
|
updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
|
projectID: credential.projectID)
|
|
},
|
|
balanceFetcher: { _ in
|
|
Issue.record("Project-filtered usage should not fetch legacy balance after admin usage succeeds.")
|
|
return OpenAIAPICreditBalanceSnapshot(
|
|
totalGranted: 100,
|
|
totalUsed: 25,
|
|
totalAvailable: 75,
|
|
nextGrantExpiry: nil,
|
|
updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
|
|
})
|
|
|
|
let result = try await strategy.fetch(Self.makeContext(
|
|
env: [
|
|
OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test",
|
|
OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_abc",
|
|
]))
|
|
|
|
#expect(result.sourceLabel == "admin-api:project")
|
|
#expect(result.usage.identity?.loginMethod == "Admin API: proj_abc")
|
|
#expect(result.usage.identity?.accountOrganization == "Project: proj_abc")
|
|
}
|
|
|
|
@Test
|
|
func `project scope follows final environment even when selected account flag is present`() async throws {
|
|
let accountID = UUID()
|
|
let strategy = OpenAIAPIBalanceFetchStrategy(
|
|
usageFetcher: { credential, historyDays in
|
|
#expect(credential.apiKey == "sk-test")
|
|
#expect(credential.projectID == "proj_env")
|
|
#expect(historyDays == 30)
|
|
return OpenAIAPIUsageSnapshot(
|
|
daily: [],
|
|
updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
|
projectID: credential.projectID)
|
|
},
|
|
balanceFetcher: { _ in
|
|
Issue.record("Final project-scoped environments should not fetch legacy balance.")
|
|
return OpenAIAPICreditBalanceSnapshot(
|
|
totalGranted: 100,
|
|
totalUsed: 25,
|
|
totalAvailable: 75,
|
|
nextGrantExpiry: nil,
|
|
updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
|
|
})
|
|
|
|
let result = try await strategy.fetch(Self.makeContext(
|
|
env: [
|
|
OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test",
|
|
OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_env",
|
|
],
|
|
selectedTokenAccountID: accountID))
|
|
|
|
#expect(result.sourceLabel == "admin-api:project")
|
|
#expect(result.usage.identity?.loginMethod == "Admin API: proj_env")
|
|
#expect(result.usage.identity?.accountOrganization == "Project: proj_env")
|
|
}
|
|
|
|
private static func makeContext(
|
|
env: [String: String],
|
|
selectedTokenAccountID: UUID? = nil,
|
|
historyDays: Int = 30) -> ProviderFetchContext
|
|
{
|
|
let browserDetection = BrowserDetection(cacheTTL: 0)
|
|
return ProviderFetchContext(
|
|
runtime: .app,
|
|
sourceMode: .api,
|
|
includeCredits: false,
|
|
webTimeout: 1,
|
|
webDebugDumpHTML: false,
|
|
verbose: false,
|
|
env: env,
|
|
settings: nil,
|
|
fetcher: UsageFetcher(environment: env),
|
|
claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection),
|
|
browserDetection: browserDetection,
|
|
selectedTokenAccountID: selectedTokenAccountID,
|
|
costUsageHistoryDays: historyDays)
|
|
}
|
|
|
|
@MainActor
|
|
private static func makeSettingsStore(suite: String) -> SettingsStore {
|
|
let defaults = UserDefaults(suiteName: suite)!
|
|
defaults.removePersistentDomain(forName: suite)
|
|
|
|
return SettingsStore(
|
|
userDefaults: defaults,
|
|
configStore: testConfigStore(suiteName: suite),
|
|
zaiTokenStore: NoopZaiTokenStore(),
|
|
syntheticTokenStore: NoopSyntheticTokenStore(),
|
|
tokenAccountStore: InMemoryTokenAccountStore())
|
|
}
|
|
}
|