import CodexBarCore import Foundation import Testing @testable import CodexBar @Suite(.serialized) struct CodexProfileHomeAccountTests { @MainActor private static func makeSettings(suite: String) throws -> SettingsStore { let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defaults.set(true, forKey: "providerDetectionCompleted") let settings = SettingsStore( userDefaults: defaults, configStore: testConfigStore(suiteName: suite), zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) settings.providerDetectionCompleted = true return settings } @Test @MainActor func `settings store discovers configured codex profile homes`() throws { let suite = "CodexProfileHomeAccountTests-discovery" let settings = try Self.makeSettings(suite: suite) let missingLiveHome = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true) let profileHome = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true) try Self.writeCodexAuthFile( homeURL: profileHome, email: "Profile@Example.com", plan: "pro", accountID: "acct_profile") settings._test_codexReconciliationEnvironment = ["CODEX_HOME": missingLiveHome.path] settings.updateProviderConfig(provider: .codex) { entry in entry.codexProfileHomePaths = [profileHome.path, profileHome.path] } settings.codexActiveSource = .profileHome(path: profileHome.path) defer { settings._test_codexReconciliationEnvironment = nil try? FileManager.default.removeItem(at: missingLiveHome) try? FileManager.default.removeItem(at: profileHome) } let normalizedProfilePath = try #require(CodexHomeScope.normalizedHomePath(profileHome.path)) let snapshot = settings.codexAccountReconciliationSnapshot let projection = settings.codexVisibleAccountProjection #expect(settings.codexResolvedActiveSource == .profileHome(path: normalizedProfilePath)) #expect(snapshot.liveSystemAccount == nil) #expect(snapshot.profileHomeAccounts.map(\.email) == ["profile@example.com"]) #expect(snapshot.profileHomeAccounts.map(\.codexHomePath) == [normalizedProfilePath]) #expect(snapshot.profileHomePaths == [normalizedProfilePath]) #expect(projection.visibleAccounts.map(\.email) == ["profile@example.com"]) #expect(projection.activeVisibleAccountID == "profile@example.com") #expect(projection.liveVisibleAccountID == nil) #expect(projection.visibleAccounts.first?.selectionSource == .profileHome(path: normalizedProfilePath)) #expect(projection.visibleAccounts.first?.isLive == false) #expect(projection.visibleAccounts.first?.canReauthenticate == false) #expect(projection.visibleAccounts.first?.canRemove == false) } @Test @MainActor func `provider registry scopes selected codex profile home`() throws { let suite = "CodexProfileHomeAccountTests-routing" let settings = try Self.makeSettings(suite: suite) let profileHome = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true) try Self.writeCodexAuthFile( homeURL: profileHome, email: "profile-route@example.com", plan: "pro") settings.updateProviderConfig(provider: .codex) { entry in entry.codexProfileHomePaths = [profileHome.path] entry.codexActiveSource = .profileHome(path: profileHome.path) } defer { try? FileManager.default.removeItem(at: profileHome) } let normalizedProfilePath = try #require(CodexHomeScope.normalizedHomePath(profileHome.path)) let environment = ProviderRegistry.makeEnvironment( base: ["CODEX_HOME": "/tmp/ambient-codex"], provider: .codex, settings: settings, tokenOverride: nil) #expect(environment["CODEX_HOME"] == normalizedProfilePath) } @Test @MainActor func `removed profile home falls back without routing stale path`() throws { let suite = "CodexProfileHomeAccountTests-stale-routing" let settings = try Self.makeSettings(suite: suite) let missingLiveHome = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true) let removedProfileHome = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true) settings._test_codexReconciliationEnvironment = ["CODEX_HOME": missingLiveHome.path] settings.updateProviderConfig(provider: .codex) { entry in entry.codexProfileHomePaths = [] entry.codexActiveSource = .profileHome(path: removedProfileHome.path) } defer { settings._test_codexReconciliationEnvironment = nil try? FileManager.default.removeItem(at: missingLiveHome) try? FileManager.default.removeItem(at: removedProfileHome) } let environment = ProviderRegistry.makeEnvironment( base: ["CODEX_HOME": "/tmp/ambient-codex"], provider: .codex, settings: settings, tokenOverride: nil) #expect(settings.codexResolvedActiveSource == .liveSystem) #expect(environment["CODEX_HOME"] == "/tmp/ambient-codex") let staleOverrideEnvironment = ProviderRegistry.makeEnvironment( base: ["CODEX_HOME": "/tmp/ambient-codex"], provider: .codex, settings: settings, tokenOverride: nil, codexActiveSourceOverride: .profileHome(path: removedProfileHome.path)) #expect(staleOverrideEnvironment["CODEX_HOME"] == "/tmp/ambient-codex") #expect(settings.persistResolvedCodexActiveSourceCorrectionIfNeeded()) #expect(settings.codexActiveSource == .liveSystem) } @Test @MainActor func `external config removal immediately invalidates profile routing caches`() throws { let suite = "CodexProfileHomeAccountTests-external-removal" let settings = try Self.makeSettings(suite: suite) let missingLiveHome = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true) let profileHome = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true) try Self.writeCodexAuthFile( homeURL: profileHome, email: "profile-cache@example.com", plan: "pro") let previousInterval = SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 settings._test_codexReconciliationEnvironment = ["CODEX_HOME": missingLiveHome.path] defer { SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = previousInterval settings._test_codexReconciliationEnvironment = nil try? FileManager.default.removeItem(at: missingLiveHome) try? FileManager.default.removeItem(at: profileHome) } settings.updateProviderConfig(provider: .codex) { entry in entry.codexProfileHomePaths = [profileHome.path] entry.codexActiveSource = .profileHome(path: profileHome.path) } _ = settings.codexAccountReconciliationSnapshot #expect(settings.cachedCodexAccountReconciliationSnapshot != nil) #expect(settings.cachedCodexAccountMenuProjection != nil) settings.applyExternalConfig( CodexBarConfig(providers: [ ProviderConfig( id: .codex, codexActiveSource: .profileHome(path: profileHome.path), codexProfileHomePaths: []), ]), reason: "profile-removed") #expect(settings.cachedCodexAccountReconciliationSnapshot == nil) #expect(settings.cachedCodexAccountMenuProjection == nil) let environment = ProviderRegistry.makeEnvironment( base: ["CODEX_HOME": "/tmp/ambient-codex"], provider: .codex, settings: settings, tokenOverride: nil) #expect(settings.codexResolvedActiveSource == .liveSystem) #expect(environment["CODEX_HOME"] == "/tmp/ambient-codex") } @Test @MainActor func `relative profile homes are ignored by app routing`() throws { let settings = try Self.makeSettings(suite: "CodexProfileHomeAccountTests-relative") settings.updateProviderConfig(provider: .codex) { entry in entry.codexProfileHomePaths = ["relative-codex-home", "~someone/.codex"] entry.codexActiveSource = .profileHome(path: "relative-codex-home") } #expect(CodexHomeScope.normalizedHomePath("relative-codex-home") == nil) #expect(CodexHomeScope.normalizedHomePath("~someone/.codex") == nil) #expect(settings.codexProfileHomePaths.isEmpty) let environment = ProviderRegistry.makeEnvironment( base: ["CODEX_HOME": "/tmp/ambient-codex"], provider: .codex, settings: settings, tokenOverride: nil, codexActiveSourceOverride: .profileHome(path: "relative-codex-home")) #expect(environment["CODEX_HOME"] == "/tmp/ambient-codex") } @Test @MainActor func `unreadable configured profile home remains selected and routed`() throws { let suite = "CodexProfileHomeAccountTests-unreadable-routing" let settings = try Self.makeSettings(suite: suite) let missingLiveHome = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true) let unreadableProfileHome = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true) settings._test_codexReconciliationEnvironment = ["CODEX_HOME": missingLiveHome.path] settings.updateProviderConfig(provider: .codex) { entry in entry.codexProfileHomePaths = [unreadableProfileHome.path] entry.codexActiveSource = .profileHome(path: unreadableProfileHome.path) } defer { settings._test_codexReconciliationEnvironment = nil try? FileManager.default.removeItem(at: missingLiveHome) try? FileManager.default.removeItem(at: unreadableProfileHome) } let normalizedProfilePath = try #require(CodexHomeScope.normalizedHomePath(unreadableProfileHome.path)) let environment = ProviderRegistry.makeEnvironment( base: ["CODEX_HOME": "/tmp/ambient-codex"], provider: .codex, settings: settings, tokenOverride: nil) #expect(settings.codexResolvedActiveSource == .profileHome(path: normalizedProfilePath)) #expect(settings.codexAccountReconciliationSnapshot.profileHomeAccounts.isEmpty) #expect(environment["CODEX_HOME"] == normalizedProfilePath) #expect(!settings.persistResolvedCodexActiveSourceCorrectionIfNeeded()) #expect(settings.codexActiveSource == .profileHome(path: normalizedProfilePath)) } @Test @MainActor func `profile without verified email refuses open A I cookie import`() async throws { let suite = "CodexProfileHomeAccountTests-missing-web-email" let settings = try Self.makeSettings(suite: suite) let missingLiveHome = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true) let unreadableProfileHome = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true) settings._test_codexReconciliationEnvironment = ["CODEX_HOME": missingLiveHome.path] settings.updateProviderConfig(provider: .codex) { entry in entry.codexProfileHomePaths = [unreadableProfileHome.path] entry.codexActiveSource = .profileHome(path: unreadableProfileHome.path) } defer { settings._test_codexReconciliationEnvironment = nil try? FileManager.default.removeItem(at: missingLiveHome) try? FileManager.default.removeItem(at: unreadableProfileHome) } let store = UsageStore( fetcher: UsageFetcher(environment: ["CODEX_HOME": missingLiveHome.path]), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings, startupBehavior: .testing) var importAttempts = 0 store._test_openAIDashboardCookieImportOverride = { _, _, _, _, _ in importAttempts += 1 return OpenAIDashboardBrowserCookieImporter.ImportResult( sourceLabel: "test", cookieCount: 1, signedInEmail: "other@example.com", matchesCodexEmail: false) } let imported = await store.importOpenAIDashboardCookiesIfNeeded(targetEmail: nil, force: true) #expect(imported == nil) #expect(importAttempts == 0) #expect(store.openAIDashboardRequiresLogin) #expect(store.openAIDashboardCookieImportStatus?.contains("no verified account email") == true) } @Test @MainActor func `profile home matching live home resolves to visible live account`() throws { let suite = "CodexProfileHomeAccountTests-live-duplicate" let settings = try Self.makeSettings(suite: suite) let liveHome = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true) let liveAccount = ObservedSystemCodexAccount( email: "live@example.com", codexHomePath: liveHome.path, observedAt: Date()) settings._test_liveSystemCodexAccount = liveAccount settings.updateProviderConfig(provider: .codex) { entry in entry.codexProfileHomePaths = [liveHome.path] entry.codexActiveSource = .profileHome(path: liveHome.path) } defer { settings._test_liveSystemCodexAccount = nil } let projection = settings.codexVisibleAccountProjection #expect(settings.codexResolvedActiveSource == .liveSystem) #expect(projection.visibleAccounts.map(\.email) == ["live@example.com"]) #expect(projection.activeVisibleAccountID == "live@example.com") #expect(settings.persistResolvedCodexActiveSourceCorrectionIfNeeded()) #expect(settings.codexActiveSource == .liveSystem) } @Test @MainActor func `profile home matching managed home resolves to visible managed account`() throws { let suite = "CodexProfileHomeAccountTests-managed-duplicate" let settings = try Self.makeSettings(suite: suite) let managedHome = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true) let managedAccount = ManagedCodexAccount( id: UUID(), email: "managed@example.com", managedHomePath: managedHome.path, createdAt: 1, updatedAt: 2, lastAuthenticatedAt: 3) settings._test_activeManagedCodexAccount = managedAccount settings.updateProviderConfig(provider: .codex) { entry in entry.codexProfileHomePaths = [managedHome.path] entry.codexActiveSource = .profileHome(path: managedHome.path) } defer { settings._test_activeManagedCodexAccount = nil } let projection = settings.codexVisibleAccountProjection #expect(settings.codexResolvedActiveSource == .managedAccount(id: managedAccount.id)) #expect(projection.visibleAccounts.map(\.email) == ["managed@example.com"]) #expect(projection.activeVisibleAccountID == "managed@example.com") #expect(settings.persistResolvedCodexActiveSourceCorrectionIfNeeded()) #expect(settings.codexActiveSource == .managedAccount(id: managedAccount.id)) } 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))." } }