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

493 lines
18 KiB
Swift

import CodexBarCore
import Foundation
@testable import CodexBar
@MainActor
final class CodexAccountPromotionTestContainer {
let suiteName: String
let rootURL: URL
let liveHomeURL: URL
let managedHomesURL: URL
let managedStoreURL: URL
let settings: SettingsStore
let usageStore: UsageStore
let fileStore: FileManagedCodexAccountStore
let homeFactory: ManagedCodexHomeFactory
let identityReader: DefaultManagedCodexIdentityReader
let workspaceResolver: any ManagedCodexWorkspaceResolving
let baseEnvironment: [String: String]
init(
suiteName: String,
workspaceIdentities: [String: CodexOpenAIWorkspaceIdentity] = [:]) throws
{
self.suiteName = suiteName
self.rootURL = FileManager.default.temporaryDirectory
.appendingPathComponent("codex-account-promotion-tests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
self.liveHomeURL = self.rootURL.appendingPathComponent("liveHome", isDirectory: true)
self.managedHomesURL = self.rootURL.appendingPathComponent("managed-codex-homes", isDirectory: true)
self.managedStoreURL = self.rootURL.appendingPathComponent("managed-codex-accounts.json", isDirectory: false)
self.baseEnvironment = ["CODEX_HOME": self.liveHomeURL.path]
self.fileStore = FileManagedCodexAccountStore(fileURL: self.managedStoreURL, fileManager: .default)
self.homeFactory = ManagedCodexHomeFactory(root: self.managedHomesURL, fileManager: .default)
self.identityReader = DefaultManagedCodexIdentityReader()
self.workspaceResolver = StubManagedCodexWorkspaceResolver(identities: workspaceIdentities)
try FileManager.default.createDirectory(at: self.liveHomeURL, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: self.managedHomesURL, withIntermediateDirectories: true)
_ = try self.fileStore.ensureFileExists()
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
defaults.set(true, forKey: "providerDetectionCompleted")
self.settings = SettingsStore(
userDefaults: defaults,
configStore: testConfigStore(suiteName: suiteName),
zaiTokenStore: NoopZaiTokenStore(),
syntheticTokenStore: NoopSyntheticTokenStore())
self.settings._test_activeManagedCodexAccount = nil
self.settings._test_activeManagedCodexRemoteHomePath = nil
self.settings._test_unreadableManagedCodexAccountStore = false
self.settings._test_managedCodexAccountStoreURL = self.managedStoreURL
self.settings._test_liveSystemCodexAccount = nil
self.settings._test_codexReconciliationEnvironment = self.baseEnvironment
self.settings.providerDetectionCompleted = true
self.settings.refreshFrequency = .manual
self.settings.codexCookieSource = .off
self.usageStore = UsageStore(
fetcher: UsageFetcher(environment: [:]),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: self.settings,
startupBehavior: .testing)
self.installDynamicCodexUsageLoader()
self.usageStore._test_codexCreditsLoaderOverride = {
CreditsSnapshot(remaining: 17, events: [], updatedAt: Date())
}
}
func tearDown() {
self.usageStore._test_codexCreditsLoaderOverride = nil
self.settings._test_activeManagedCodexAccount = nil
self.settings._test_activeManagedCodexRemoteHomePath = nil
self.settings._test_unreadableManagedCodexAccountStore = false
self.settings._test_managedCodexAccountStoreURL = nil
self.settings._test_liveSystemCodexAccount = nil
self.settings._test_codexReconciliationEnvironment = nil
self.settings.userDefaults.removePersistentDomain(forName: self.suiteName)
try? FileManager.default.removeItem(at: self.rootURL)
}
func makeService(
store: (any ManagedCodexAccountStoring)? = nil,
liveAuthSwapper: (any CodexLiveAuthSwapping)? = nil,
activeSourceWriter: (any CodexActiveSourceWriting)? = nil,
snapshotLoader: (any CodexAccountReconciliationSnapshotLoading)? = nil,
accountScopedRefresher: (any CodexAccountScopedRefreshing)? = nil)
-> CodexAccountPromotionService
{
CodexAccountPromotionService(
store: store ?? self.fileStore,
homeFactory: self.homeFactory,
identityReader: self.identityReader,
workspaceResolver: self.workspaceResolver,
snapshotLoader: snapshotLoader
?? SettingsStoreCodexAccountReconciliationSnapshotLoader(settingsStore: self.settings),
authMaterialReader: DefaultCodexAuthMaterialReader(),
liveAuthSwapper: liveAuthSwapper ?? DefaultCodexLiveAuthSwapper(),
activeSourceWriter: activeSourceWriter
?? SettingsStoreCodexActiveSourceWriter(settingsStore: self.settings),
accountScopedRefresher: accountScopedRefresher
?? UsageStoreCodexAccountScopedRefresher(usageStore: self.usageStore),
baseEnvironment: self.baseEnvironment,
fileManager: .default)
}
func installDynamicCodexUsageLoader(usedPercent: Double = 12) {
let baseSpec = self.usageStore.providerSpecs[.codex]!
let liveHomePath = self.liveHomeURL.path
let identityReader = self.identityReader
self.usageStore
.providerSpecs[.codex] = makeCodexProviderSpec(baseSpec: baseSpec) {
let liveEmail = (try? identityReader.loadAccountIdentity(homePath: liveHomePath).email)
?? "unknown@example.com"
return UsageSnapshot(
primary: RateWindow(
usedPercent: usedPercent,
windowMinutes: 300,
resetsAt: nil,
resetDescription: nil),
secondary: nil,
updatedAt: Date(),
identity: ProviderIdentitySnapshot(
providerID: .codex,
accountEmail: liveEmail,
accountOrganization: nil,
loginMethod: "Pro"))
}
}
@discardableResult
func createManagedAccount(
id: UUID = UUID(),
persistedEmail: String,
authEmail: String? = nil,
authAccountID: String? = nil,
persistedProviderAccountID: String? = nil,
useAuthAccountIDAsPersistedProviderAccountID: Bool = true,
workspaceLabel: String? = nil,
workspaceAccountID: String? = nil,
plan: String = "Pro") throws -> ManagedCodexAccount
{
let homeURL = self.managedHomesURL.appendingPathComponent(id.uuidString, isDirectory: true)
let createdAt = Date().timeIntervalSince1970
let authData = try self.writeOAuthAuthFile(
homeURL: homeURL,
email: authEmail ?? persistedEmail,
plan: plan,
accountID: authAccountID)
let persistedProviderAccountIDValue: String? =
if useAuthAccountIDAsPersistedProviderAccountID {
persistedProviderAccountID ?? authAccountID
} else {
persistedProviderAccountID
}
return ManagedCodexAccount(
id: id,
email: persistedEmail,
providerAccountID: persistedProviderAccountIDValue,
workspaceLabel: workspaceLabel,
workspaceAccountID: workspaceAccountID ?? authAccountID,
authFingerprint: CodexAuthFingerprint.fingerprint(data: authData),
managedHomePath: homeURL.path,
createdAt: createdAt,
updatedAt: createdAt,
lastAuthenticatedAt: createdAt)
}
func persistAccounts(_ accounts: [ManagedCodexAccount]) throws {
try self.fileStore.storeAccounts(ManagedCodexAccountSet(
version: FileManagedCodexAccountStore.currentVersion,
accounts: accounts))
}
func loadAccounts() throws -> ManagedCodexAccountSet {
try self.fileStore.loadAccounts()
}
@discardableResult
func writeLiveOAuthAuthFile(
email: String,
plan: String = "Pro",
accountID: String? = nil,
apiKey: String? = nil) throws -> Data
{
try self.writeOAuthAuthFile(
homeURL: self.liveHomeURL,
email: email,
plan: plan,
accountID: accountID,
apiKey: apiKey)
}
@discardableResult
func writeLiveAPIKeyAuthFile(apiKey: String = "sk-live-only") throws -> Data {
let data = try JSONSerialization.data(
withJSONObject: ["OPENAI_API_KEY": apiKey],
options: [.sortedKeys])
try FileManager.default.createDirectory(at: self.liveHomeURL, withIntermediateDirectories: true)
try data.write(to: Self.authFileURL(for: self.liveHomeURL), options: .atomic)
return data
}
@discardableResult
func writeLiveOAuthAuthFileWithoutEmail(accountID: String, apiKey: String? = nil) throws -> Data {
try self.writeOAuthAuthFileWithoutEmail(
homeURL: self.liveHomeURL,
accountID: accountID,
apiKey: apiKey)
}
@discardableResult
func writeManagedOAuthAuthFileWithoutEmail(
for account: ManagedCodexAccount,
accountID: String,
apiKey: String? = nil) throws -> Data
{
try self.writeOAuthAuthFileWithoutEmail(
homeURL: URL(fileURLWithPath: account.managedHomePath, isDirectory: true),
accountID: accountID,
apiKey: apiKey)
}
@discardableResult
private func writeOAuthAuthFileWithoutEmail(
homeURL: URL,
accountID: String,
apiKey: String? = nil) throws -> Data
{
try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true)
let tokens: [String: Any] = [
"accessToken": "access-\(accountID)",
"refreshToken": "refresh-\(accountID)",
"accountId": accountID,
]
var json: [String: Any] = [
"tokens": tokens,
"last_refresh": "2026-04-05T00:00:00Z",
]
if let apiKey {
json["OPENAI_API_KEY"] = apiKey
}
let data = try JSONSerialization.data(withJSONObject: json, options: [.sortedKeys])
try data.write(to: Self.authFileURL(for: homeURL), options: .atomic)
return data
}
func removeLiveAuthFile() throws {
let authFileURL = Self.authFileURL(for: self.liveHomeURL)
if FileManager.default.fileExists(atPath: authFileURL.path) {
try FileManager.default.removeItem(at: authFileURL)
}
}
func liveAuthData() throws -> Data? {
let authFileURL = Self.authFileURL(for: self.liveHomeURL)
guard FileManager.default.fileExists(atPath: authFileURL.path) else { return nil }
return try Data(contentsOf: authFileURL)
}
func managedAuthData(for account: ManagedCodexAccount) throws -> Data {
try Data(contentsOf: Self.authFileURL(for: URL(fileURLWithPath: account.managedHomePath, isDirectory: true)))
}
func managedHomeURLs() throws -> [URL] {
let urls = try FileManager.default.contentsOfDirectory(
at: self.managedHomesURL,
includingPropertiesForKeys: nil)
return urls.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
func seedScopedRefreshState(email: String, identity: CodexIdentity) {
let snapshot = UsageSnapshot(
primary: RateWindow(usedPercent: 4, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
secondary: nil,
updatedAt: Date(),
identity: ProviderIdentitySnapshot(
providerID: .codex,
accountEmail: email,
accountOrganization: nil,
loginMethod: "Pro"))
let credits = CreditsSnapshot(remaining: 3, events: [], updatedAt: Date())
self.usageStore._setSnapshotForTesting(snapshot, provider: .codex)
self.usageStore.credits = credits
self.usageStore.lastCreditsSnapshot = credits
self.usageStore.lastCreditsSnapshotAccountKey = email
self.usageStore.lastCreditsSource = .api
self.usageStore.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard(
source: .liveSystem,
identity: identity,
accountKey: email)
}
@discardableResult
private func writeOAuthAuthFile(
homeURL: URL,
email: String,
plan: String,
accountID: String?,
apiKey: String? = nil) throws -> Data
{
try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true)
var tokens: [String: Any] = [
"accessToken": "access-\(email)",
"refreshToken": "refresh-\(email)",
"idToken": Self.fakeJWT(email: email, plan: plan, accountID: accountID),
]
if let accountID {
tokens["accountId"] = accountID
}
var json: [String: Any] = [
"tokens": tokens,
"last_refresh": "2026-04-05T00:00:00Z",
]
if let apiKey {
json["OPENAI_API_KEY"] = apiKey
}
let data = try JSONSerialization.data(withJSONObject: json, options: [.sortedKeys])
try data.write(to: Self.authFileURL(for: homeURL), options: .atomic)
return data
}
private static func fakeJWT(email: String, plan: String, accountID: String?) -> String {
let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data()
var authClaims: [String: Any] = [
"chatgpt_plan_type": plan,
]
if let accountID {
authClaims["chatgpt_account_id"] = accountID
}
let payload = (try? JSONSerialization.data(withJSONObject: [
"email": email,
"chatgpt_plan_type": plan,
"https://api.openai.com/auth": authClaims,
])) ?? Data()
func base64URL(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "=", with: "")
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
}
return "\(base64URL(header)).\(base64URL(payload))."
}
private static func authFileURL(for homeURL: URL) -> URL {
homeURL.appendingPathComponent("auth.json", isDirectory: false)
}
}
private func makeCodexProviderSpec(
baseSpec: ProviderSpec,
loader: @escaping @Sendable () async throws -> UsageSnapshot) -> ProviderSpec
{
let baseDescriptor = baseSpec.descriptor
let strategy = TestPromotionCodexFetchStrategy(loader: loader)
let descriptor = ProviderDescriptor(
id: .codex,
metadata: baseDescriptor.metadata,
branding: baseDescriptor.branding,
tokenCost: baseDescriptor.tokenCost,
fetchPlan: ProviderFetchPlan(
sourceModes: [.auto, .cli, .oauth],
pipeline: ProviderFetchPipeline { _ in [strategy] }),
cli: baseDescriptor.cli)
return ProviderSpec(
style: baseSpec.style,
isEnabled: baseSpec.isEnabled,
descriptor: descriptor,
makeFetchContext: baseSpec.makeFetchContext)
}
private struct TestPromotionCodexFetchStrategy: ProviderFetchStrategy {
let loader: @Sendable () async throws -> UsageSnapshot
var id: String {
"test-promotion-codex"
}
var kind: ProviderFetchKind {
.cli
}
func isAvailable(_: ProviderFetchContext) async -> Bool {
true
}
func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult {
let snapshot = try await self.loader()
return self.makeResult(usage: snapshot, sourceLabel: "test-promotion-codex")
}
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
false
}
}
final class RecordingManagedCodexAccountStore: ManagedCodexAccountStoring, @unchecked Sendable {
let base: any ManagedCodexAccountStoring
var storedSnapshots: [ManagedCodexAccountSet] = []
var onStore: (@Sendable (ManagedCodexAccountSet) throws -> Void)?
init(
base: any ManagedCodexAccountStoring,
onStore: (@Sendable (ManagedCodexAccountSet) throws -> Void)? = nil)
{
self.base = base
self.onStore = onStore
}
func loadAccounts() throws -> ManagedCodexAccountSet {
try self.base.loadAccounts()
}
func storeAccounts(_ accounts: ManagedCodexAccountSet) throws {
self.storedSnapshots.append(accounts)
try self.onStore?(accounts)
try self.base.storeAccounts(accounts)
}
func ensureFileExists() throws -> URL {
try self.base.ensureFileExists()
}
}
final class RecordingCodexLiveAuthSwapper: CodexLiveAuthSwapping, @unchecked Sendable {
let base: any CodexLiveAuthSwapping
var swapCallCount = 0
var swappedData: [Data] = []
var onSwap: (@Sendable (Data, URL) throws -> Void)?
init(
base: any CodexLiveAuthSwapping = DefaultCodexLiveAuthSwapper(),
onSwap: (@Sendable (Data, URL) throws -> Void)? = nil)
{
self.base = base
self.onSwap = onSwap
}
func swapLiveAuthData(_ data: Data, liveHomeURL: URL) throws {
self.swapCallCount += 1
self.swappedData.append(data)
try self.onSwap?(data, liveHomeURL)
try self.base.swapLiveAuthData(data, liveHomeURL: liveHomeURL)
}
}
enum PromotionTestError: Error, Equatable {
case storeWriteFailed
case swapFailed
case unexpectedDisposition
}
private struct StubManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving {
let identities: [String: CodexOpenAIWorkspaceIdentity]
func resolveWorkspaceIdentity(
homePath _: String,
providerAccountID: String) async -> CodexOpenAIWorkspaceIdentity?
{
self.identities[providerAccountID]
}
}
@MainActor
struct StaticCodexAccountReconciliationSnapshotLoader: CodexAccountReconciliationSnapshotLoading {
let snapshot: CodexAccountReconciliationSnapshot
func loadSnapshot() -> CodexAccountReconciliationSnapshot {
self.snapshot
}
}
@MainActor
final class ClosureCodexAccountScopedRefresher: CodexAccountScopedRefreshing {
private let onRefresh: @MainActor (Bool) async -> Void
init(onRefresh: @escaping @MainActor (Bool) async -> Void) {
self.onRefresh = onRefresh
}
func refreshCodexAccountScopedState(allowDisabled: Bool) async {
await self.onRefresh(allowDisabled)
}
}