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

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")
}
}