458 lines
20 KiB
Swift
458 lines
20 KiB
Swift
import CodexBarCore
|
|
import Foundation
|
|
import Testing
|
|
@testable import CodexBar
|
|
|
|
@MainActor
|
|
struct CodexAccountsSettingsSectionTests {
|
|
@Test
|
|
func `codex accounts section shows live badge only for live only multi account row`() throws {
|
|
let settings = Self.makeSettingsStore(suite: "CodexAccountsSettingsSectionTests-live-badge")
|
|
let store = Self.makeUsageStore(settings: settings)
|
|
let managedStoreURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: managedStoreURL) }
|
|
|
|
let managedAccount = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "managed@example.com",
|
|
managedHomePath: "/tmp/managed",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 2)
|
|
let managedStore = FileManagedCodexAccountStore(fileURL: managedStoreURL)
|
|
try managedStore.storeAccounts(ManagedCodexAccountSet(
|
|
version: FileManagedCodexAccountStore.currentVersion,
|
|
accounts: [managedAccount]))
|
|
|
|
settings._test_managedCodexAccountStoreURL = managedStoreURL
|
|
settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount(
|
|
email: "live@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
|
|
let pane = ProvidersPane(settings: settings, store: store)
|
|
let state = try #require(pane._test_codexAccountsSectionState())
|
|
let liveAccount = try #require(state.visibleAccounts.first { $0.email == "live@example.com" })
|
|
let managedVisibleAccount = try #require(state.visibleAccounts.first { $0.email == "managed@example.com" })
|
|
|
|
#expect(state.showsLiveBadge(for: liveAccount))
|
|
#expect(state.showsLiveBadge(for: managedVisibleAccount) == false)
|
|
}
|
|
|
|
@Test
|
|
func `single account codex settings uses simple account view instead of picker`() throws {
|
|
let settings = Self.makeSettingsStore(suite: "CodexAccountsSettingsSectionTests-single-account")
|
|
let store = Self.makeUsageStore(settings: settings)
|
|
settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount(
|
|
email: "solo@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
|
|
let pane = ProvidersPane(settings: settings, store: store)
|
|
let state = try #require(pane._test_codexAccountsSectionState())
|
|
|
|
#expect(state.visibleAccounts.count == 1)
|
|
#expect(state.showsActivePicker == false)
|
|
#expect(state.singleVisibleAccount?.email == "solo@example.com")
|
|
}
|
|
|
|
@Test
|
|
func `single account codex settings state includes workspace display name`() throws {
|
|
let settings = Self.makeSettingsStore(suite: "CodexAccountsSettingsSectionTests-single-workspace")
|
|
let store = Self.makeUsageStore(settings: settings)
|
|
settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount(
|
|
email: "solo@example.com",
|
|
workspaceLabel: "Team Alpha",
|
|
workspaceAccountID: "account-live",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date(),
|
|
identity: .providerAccount(id: "account-live"))
|
|
|
|
let pane = ProvidersPane(settings: settings, store: store)
|
|
let state = try #require(pane._test_codexAccountsSectionState())
|
|
|
|
#expect(state.singleVisibleAccount?.displayName == "solo@example.com — Team Alpha")
|
|
}
|
|
|
|
@Test
|
|
func `codex accounts section disables managed mutations when store is unreadable`() throws {
|
|
let settings = Self.makeSettingsStore(suite: "CodexAccountsSettingsSectionTests-unreadable")
|
|
let store = Self.makeUsageStore(settings: settings)
|
|
settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount(
|
|
email: "live@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
settings._test_unreadableManagedCodexAccountStore = true
|
|
defer { settings._test_unreadableManagedCodexAccountStore = false }
|
|
|
|
let pane = ProvidersPane(settings: settings, store: store)
|
|
let state = try #require(pane._test_codexAccountsSectionState())
|
|
let liveAccount = try #require(state.visibleAccounts.first)
|
|
|
|
#expect(state.hasUnreadableManagedAccountStore)
|
|
#expect(state.canAddAccount == false)
|
|
#expect(state.notice?.tone == .warning)
|
|
#expect(state.canReauthenticate(liveAccount))
|
|
}
|
|
|
|
@Test
|
|
func `selecting merged visible account from settings keeps live system source`() async throws {
|
|
let settings = Self.makeSettingsStore(suite: "CodexAccountsSettingsSectionTests-select-merged")
|
|
let store = Self.makeUsageStore(settings: settings)
|
|
let managedStoreURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: managedStoreURL) }
|
|
|
|
let managedAccount = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "same@example.com",
|
|
managedHomePath: "/tmp/managed",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 2)
|
|
let managedStore = FileManagedCodexAccountStore(fileURL: managedStoreURL)
|
|
try managedStore.storeAccounts(ManagedCodexAccountSet(
|
|
version: FileManagedCodexAccountStore.currentVersion,
|
|
accounts: [managedAccount]))
|
|
|
|
settings._test_managedCodexAccountStoreURL = managedStoreURL
|
|
settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount(
|
|
email: "SAME@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
settings.codexActiveSource = .managedAccount(id: managedAccount.id)
|
|
|
|
let pane = ProvidersPane(settings: settings, store: store)
|
|
await pane._test_selectCodexVisibleAccount(id: "same@example.com")
|
|
|
|
#expect(settings.codexActiveSource == .liveSystem)
|
|
let state = try #require(pane._test_codexAccountsSectionState())
|
|
#expect(state.activeVisibleAccountID == "same@example.com")
|
|
}
|
|
|
|
@Test
|
|
func `settings account selection can target the managed row when same email rows split by identity`() async throws {
|
|
let settings = Self.makeSettingsStore(suite: "CodexAccountsSettingsSectionTests-select-split")
|
|
let store = Self.makeUsageStore(settings: settings)
|
|
let managedStoreURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
let managedHome = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
UUID().uuidString,
|
|
isDirectory: true)
|
|
defer {
|
|
settings._test_managedCodexAccountStoreURL = nil
|
|
settings._test_liveSystemCodexAccount = nil
|
|
try? FileManager.default.removeItem(at: managedStoreURL)
|
|
try? FileManager.default.removeItem(at: managedHome)
|
|
}
|
|
|
|
try Self.writeCodexAuthFile(
|
|
homeURL: managedHome,
|
|
email: "same@example.com",
|
|
plan: "pro",
|
|
accountID: "account-managed")
|
|
let managedAccount = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "same@example.com",
|
|
managedHomePath: managedHome.path,
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 2)
|
|
let managedStore = FileManagedCodexAccountStore(fileURL: managedStoreURL)
|
|
try managedStore.storeAccounts(ManagedCodexAccountSet(
|
|
version: FileManagedCodexAccountStore.currentVersion,
|
|
accounts: [managedAccount]))
|
|
|
|
settings._test_managedCodexAccountStoreURL = managedStoreURL
|
|
settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount(
|
|
email: "SAME@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date(),
|
|
identity: .emailOnly(normalizedEmail: "same@example.com"))
|
|
settings.codexActiveSource = .liveSystem
|
|
|
|
let pane = ProvidersPane(settings: settings, store: store)
|
|
let initialState = try #require(pane._test_codexAccountsSectionState())
|
|
let managedVisibleAccount = try #require(initialState.visibleAccounts
|
|
.first { $0.storedAccountID == managedAccount.id })
|
|
|
|
await pane._test_selectCodexVisibleAccount(id: managedVisibleAccount.id)
|
|
|
|
#expect(settings.codexActiveSource == .managedAccount(id: managedAccount.id))
|
|
let updatedState = try #require(pane._test_codexAccountsSectionState())
|
|
#expect(updatedState.activeVisibleAccountID == managedVisibleAccount.id)
|
|
}
|
|
|
|
@Test
|
|
func `codex accounts section disables add and reauth while managed authentication is in flight`() async throws {
|
|
let settings = Self.makeSettingsStore(suite: "CodexAccountsSettingsSectionTests-in-flight")
|
|
let store = Self.makeUsageStore(settings: settings)
|
|
let managedStoreURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: managedStoreURL) }
|
|
|
|
let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111"))
|
|
let managedAccount = ManagedCodexAccount(
|
|
id: managedAccountID,
|
|
email: "managed@example.com",
|
|
managedHomePath: "/tmp/managed",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 2)
|
|
let managedStore = FileManagedCodexAccountStore(fileURL: managedStoreURL)
|
|
try managedStore.storeAccounts(ManagedCodexAccountSet(
|
|
version: FileManagedCodexAccountStore.currentVersion,
|
|
accounts: [managedAccount]))
|
|
settings._test_managedCodexAccountStoreURL = managedStoreURL
|
|
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let runner = BlockingManagedCodexLoginRunnerForSettingsSectionTests()
|
|
let service = ManagedCodexAccountService(
|
|
store: managedStore,
|
|
homeFactory: TestManagedCodexHomeFactoryForSettingsSectionTests(root: root),
|
|
loginRunner: runner,
|
|
identityReader: StubManagedCodexIdentityReaderForSettingsSectionTests(emails: ["managed@example.com"]))
|
|
let coordinator = ManagedCodexAccountCoordinator(service: service)
|
|
let authTask = Task { try await coordinator.authenticateManagedAccount() }
|
|
await runner.waitUntilStarted()
|
|
|
|
let pane = ProvidersPane(
|
|
settings: settings,
|
|
store: store,
|
|
managedCodexAccountCoordinator: coordinator)
|
|
let state = try #require(pane._test_codexAccountsSectionState())
|
|
let visibleAccount = try #require(state.visibleAccounts.first { $0.email == "managed@example.com" })
|
|
|
|
#expect(state.canAddAccount == false)
|
|
#expect(state.addAccountTitle == "Adding Account…")
|
|
#expect(state.canReauthenticate(visibleAccount) == false)
|
|
|
|
await runner.resume()
|
|
_ = try await authTask.value
|
|
}
|
|
|
|
@Test
|
|
func `adding managed codex account auto selects the merged live row`() async throws {
|
|
let settings = Self.makeSettingsStore(suite: "CodexAccountsSettingsSectionTests-add-merged")
|
|
let store = Self.makeUsageStore(settings: settings)
|
|
settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount(
|
|
email: "same@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
|
|
let coordinator = Self.makeManagedCoordinator(settings: settings, email: "same@example.com")
|
|
let pane = ProvidersPane(
|
|
settings: settings,
|
|
store: store,
|
|
managedCodexAccountCoordinator: coordinator)
|
|
|
|
await pane._test_addManagedCodexAccount()
|
|
|
|
#expect(settings.codexActiveSource == .liveSystem)
|
|
let state = try #require(pane._test_codexAccountsSectionState())
|
|
#expect(state.activeVisibleAccountID == "same@example.com")
|
|
}
|
|
|
|
@Test
|
|
func `adding managed codex account selects the new managed account when email differs`() async throws {
|
|
let settings = Self.makeSettingsStore(suite: "CodexAccountsSettingsSectionTests-add-managed")
|
|
let store = Self.makeUsageStore(settings: settings)
|
|
settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount(
|
|
email: "live@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
|
|
let coordinator = Self.makeManagedCoordinator(settings: settings, email: "managed@example.com")
|
|
let pane = ProvidersPane(
|
|
settings: settings,
|
|
store: store,
|
|
managedCodexAccountCoordinator: coordinator)
|
|
|
|
await pane._test_addManagedCodexAccount()
|
|
|
|
guard case .managedAccount = settings.codexActiveSource else {
|
|
Issue.record("Expected the new managed account to become active")
|
|
return
|
|
}
|
|
let state = try #require(pane._test_codexAccountsSectionState())
|
|
#expect(state.activeVisibleAccountID == "managed@example.com")
|
|
}
|
|
|
|
@Test
|
|
func `managed codex login failure message includes codex login output`() {
|
|
let error = ManagedCodexAccountServiceError.loginFailed(CodexLoginRunner.Result(
|
|
outcome: .failed(status: 2),
|
|
output: "Browser selected the existing ChatGPT account"))
|
|
|
|
#expect(error.userFacingMessage.contains("codex --version"))
|
|
#expect(error.userFacingMessage.contains("codex login output:"))
|
|
#expect(error.userFacingMessage.contains("Browser selected the existing ChatGPT account"))
|
|
}
|
|
|
|
private static func makeManagedCoordinator(
|
|
settings: SettingsStore,
|
|
email: String)
|
|
-> ManagedCodexAccountCoordinator
|
|
{
|
|
let storeURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
let store = FileManagedCodexAccountStore(fileURL: storeURL)
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
settings._test_managedCodexAccountStoreURL = storeURL
|
|
let service = ManagedCodexAccountService(
|
|
store: store,
|
|
homeFactory: TestManagedCodexHomeFactoryForSettingsSectionTests(root: root),
|
|
loginRunner: StubManagedCodexLoginRunnerForSettingsSectionTests.success,
|
|
identityReader: StubManagedCodexIdentityReaderForSettingsSectionTests(emails: [email]))
|
|
return ManagedCodexAccountCoordinator(service: service)
|
|
}
|
|
|
|
private static func makeSettingsStore(suite: String) -> SettingsStore {
|
|
let defaults = UserDefaults(suiteName: suite)!
|
|
defaults.removePersistentDomain(forName: suite)
|
|
let configStore = testConfigStore(suiteName: suite)
|
|
|
|
return SettingsStore(
|
|
userDefaults: defaults,
|
|
configStore: configStore,
|
|
zaiTokenStore: NoopZaiTokenStore(),
|
|
syntheticTokenStore: NoopSyntheticTokenStore(),
|
|
codexCookieStore: InMemoryCookieHeaderStore(),
|
|
claudeCookieStore: InMemoryCookieHeaderStore(),
|
|
cursorCookieStore: InMemoryCookieHeaderStore(),
|
|
opencodeCookieStore: InMemoryCookieHeaderStore(),
|
|
factoryCookieStore: InMemoryCookieHeaderStore(),
|
|
minimaxCookieStore: InMemoryMiniMaxCookieStore(),
|
|
minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(),
|
|
kimiTokenStore: InMemoryKimiTokenStore(),
|
|
kimiK2TokenStore: InMemoryKimiK2TokenStore(),
|
|
augmentCookieStore: InMemoryCookieHeaderStore(),
|
|
ampCookieStore: InMemoryCookieHeaderStore(),
|
|
copilotTokenStore: InMemoryCopilotTokenStore(),
|
|
tokenAccountStore: InMemoryTokenAccountStore())
|
|
}
|
|
|
|
private static func makeUsageStore(settings: SettingsStore) -> UsageStore {
|
|
let store = UsageStore(
|
|
fetcher: UsageFetcher(environment: [:]),
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
settings: settings,
|
|
startupBehavior: .testing)
|
|
// Account-selection tests must never trigger a real provider refresh.
|
|
store._test_providerRefreshOverride = { _ in }
|
|
store._test_codexCreditsLoaderOverride = { throw UsageError.noRateLimitsFound }
|
|
return store
|
|
}
|
|
}
|
|
|
|
extension CodexAccountsSettingsSectionTests {
|
|
private static func writeCodexAuthFile(
|
|
homeURL: URL,
|
|
email: String,
|
|
plan: String,
|
|
accountID: String? = nil) throws
|
|
{
|
|
try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true)
|
|
var tokens: [String: Any] = [
|
|
"accessToken": "access-token",
|
|
"refreshToken": "refresh-token",
|
|
"idToken": Self.fakeJWT(email: email, plan: plan, accountID: accountID),
|
|
]
|
|
if let accountID {
|
|
tokens["account_id"] = accountID
|
|
}
|
|
let auth = ["tokens": tokens]
|
|
let data = try JSONSerialization.data(withJSONObject: auth)
|
|
try data.write(to: homeURL.appendingPathComponent("auth.json"))
|
|
}
|
|
|
|
private static func fakeJWT(email: String, plan: String, accountID: String? = nil) -> String {
|
|
let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data()
|
|
var payloadObject: [String: Any] = [
|
|
"email": email,
|
|
"chatgpt_plan_type": plan,
|
|
]
|
|
if let accountID {
|
|
payloadObject["https://api.openai.com/auth"] = [
|
|
"chatgpt_account_id": accountID,
|
|
]
|
|
}
|
|
let payload = (try? JSONSerialization.data(withJSONObject: payloadObject)) ?? Data()
|
|
|
|
func base64URL(_ data: Data) -> String {
|
|
data.base64EncodedString()
|
|
.replacingOccurrences(of: "=", with: "")
|
|
.replacingOccurrences(of: "+", with: "-")
|
|
.replacingOccurrences(of: "/", with: "_")
|
|
}
|
|
|
|
return "\(base64URL(header)).\(base64URL(payload))."
|
|
}
|
|
}
|
|
|
|
private struct TestManagedCodexHomeFactoryForSettingsSectionTests: ManagedCodexHomeProducing {
|
|
let root: URL
|
|
private let nextID = UUID().uuidString
|
|
|
|
func makeHomeURL() -> URL {
|
|
self.root.appendingPathComponent(self.nextID, isDirectory: true)
|
|
}
|
|
|
|
func validateManagedHomeForDeletion(_ url: URL) throws {
|
|
try ManagedCodexHomeFactory(root: self.root).validateManagedHomeForDeletion(url)
|
|
}
|
|
}
|
|
|
|
private struct StubManagedCodexLoginRunnerForSettingsSectionTests: ManagedCodexLoginRunning {
|
|
let result: CodexLoginRunner.Result
|
|
|
|
func run(homePath _: String, timeout _: TimeInterval) async -> CodexLoginRunner.Result {
|
|
self.result
|
|
}
|
|
|
|
static let success = StubManagedCodexLoginRunnerForSettingsSectionTests(
|
|
result: CodexLoginRunner.Result(outcome: .success, output: "ok"))
|
|
}
|
|
|
|
private actor BlockingManagedCodexLoginRunnerForSettingsSectionTests: ManagedCodexLoginRunning {
|
|
private var waiters: [CheckedContinuation<CodexLoginRunner.Result, Never>] = []
|
|
private var startedWaiters: [CheckedContinuation<Void, Never>] = []
|
|
private var didStart = false
|
|
|
|
func run(homePath _: String, timeout _: TimeInterval) async -> CodexLoginRunner.Result {
|
|
self.didStart = true
|
|
self.startedWaiters.forEach { $0.resume() }
|
|
self.startedWaiters.removeAll()
|
|
return await withCheckedContinuation { continuation in
|
|
self.waiters.append(continuation)
|
|
}
|
|
}
|
|
|
|
func waitUntilStarted() async {
|
|
if self.didStart { return }
|
|
await withCheckedContinuation { continuation in
|
|
self.startedWaiters.append(continuation)
|
|
}
|
|
}
|
|
|
|
func resume() {
|
|
let result = CodexLoginRunner.Result(outcome: .success, output: "ok")
|
|
self.waiters.forEach { $0.resume(returning: result) }
|
|
self.waiters.removeAll()
|
|
}
|
|
}
|
|
|
|
private final class StubManagedCodexIdentityReaderForSettingsSectionTests: ManagedCodexIdentityReading,
|
|
@unchecked Sendable {
|
|
private var emails: [String]
|
|
|
|
init(emails: [String]) {
|
|
self.emails = emails
|
|
}
|
|
|
|
func loadAccountIdentity(homePath _: String) throws -> CodexAuthBackedAccount {
|
|
let email = self.emails.isEmpty ? nil : self.emails.removeFirst()
|
|
return CodexAuthBackedAccount(
|
|
identity: CodexIdentityResolver.resolve(accountId: nil, email: email),
|
|
email: email,
|
|
plan: "Pro")
|
|
}
|
|
}
|