399 lines
18 KiB
Swift
399 lines
18 KiB
Swift
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))."
|
|
}
|
|
}
|