947 lines
42 KiB
Swift
947 lines
42 KiB
Swift
import CodexBarCore
|
|
import Foundation
|
|
import Testing
|
|
@testable import CodexBar
|
|
|
|
@Suite(.serialized)
|
|
struct CodexAccountReconciliationTests {
|
|
@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 exposes codex reconciliation accessors using managed and live overrides`() throws {
|
|
let suite = "CodexAccountReconciliationTests-settings-store"
|
|
let settings = try Self.makeSettings(suite: suite)
|
|
let managed = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "managed@example.com",
|
|
managedHomePath: "/tmp/managed-home",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 3)
|
|
let live = ObservedSystemCodexAccount(
|
|
email: "system@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
settings._test_activeManagedCodexAccount = managed
|
|
settings._test_liveSystemCodexAccount = live
|
|
settings.codexActiveSource = .managedAccount(id: managed.id)
|
|
defer {
|
|
settings._test_activeManagedCodexAccount = nil
|
|
settings._test_liveSystemCodexAccount = nil
|
|
}
|
|
|
|
let snapshot = settings.codexAccountReconciliationSnapshot
|
|
let projection = settings.codexVisibleAccountProjection
|
|
|
|
#expect(settings.codexActiveSource == .managedAccount(id: managed.id))
|
|
#expect(snapshot.storedAccounts.map(\.id) == [managed.id])
|
|
#expect(snapshot.storedAccounts.map(\.email) == [managed.email])
|
|
#expect(snapshot.activeStoredAccount?.id == managed.id)
|
|
#expect(snapshot.activeStoredAccount?.email == managed.email)
|
|
#expect(snapshot.liveSystemAccount?.email == live.email)
|
|
#expect(snapshot.liveSystemAccount?.codexHomePath == live.codexHomePath)
|
|
#expect(snapshot.liveSystemAccount?.observedAt == live.observedAt)
|
|
#expect(snapshot.liveSystemAccount?.identity == .emailOnly(normalizedEmail: "system@example.com"))
|
|
#expect(snapshot.matchingStoredAccountForLiveSystemAccount == nil)
|
|
#expect(snapshot.activeSource == .managedAccount(id: managed.id))
|
|
#expect(snapshot.hasUnreadableAddedAccountStore == false)
|
|
#expect(Set(projection.visibleAccounts.map(\.email)) == ["managed@example.com", "system@example.com"])
|
|
#expect(settings.codexVisibleAccounts == projection.visibleAccounts)
|
|
#expect(projection.activeVisibleAccountID == "managed@example.com")
|
|
#expect(projection.liveVisibleAccountID == "system@example.com")
|
|
}
|
|
|
|
@Test
|
|
@MainActor
|
|
func `settings store managed override does not leak ambient live system account`() throws {
|
|
let suite = "CodexAccountReconciliationTests-managed-only"
|
|
let settings = try Self.makeSettings(suite: suite)
|
|
let managed = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "managed@example.com",
|
|
managedHomePath: "/tmp/managed-home",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 3)
|
|
settings._test_activeManagedCodexAccount = managed
|
|
settings.codexActiveSource = .managedAccount(id: managed.id)
|
|
defer {
|
|
settings._test_activeManagedCodexAccount = nil
|
|
}
|
|
|
|
let snapshot = settings.codexAccountReconciliationSnapshot
|
|
let projection = settings.codexVisibleAccountProjection
|
|
|
|
#expect(settings.codexActiveSource == .managedAccount(id: managed.id))
|
|
#expect(snapshot.liveSystemAccount == nil)
|
|
#expect(snapshot.matchingStoredAccountForLiveSystemAccount == nil)
|
|
#expect(snapshot.activeSource == .managedAccount(id: managed.id))
|
|
#expect(projection.visibleAccounts.map(\.email) == ["managed@example.com"])
|
|
#expect(projection.activeVisibleAccountID == "managed@example.com")
|
|
#expect(projection.liveVisibleAccountID == nil)
|
|
}
|
|
|
|
@Test
|
|
@MainActor
|
|
func `settings store reconciliation environment override drives live observation with synthetic store`() throws {
|
|
let suite = "CodexAccountReconciliationTests-environment-only"
|
|
let settings = try Self.makeSettings(suite: suite)
|
|
let ambientHome = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
UUID().uuidString,
|
|
isDirectory: true)
|
|
try Self.writeCodexAuthFile(homeURL: ambientHome, email: "ambient@example.com", plan: "pro")
|
|
settings._test_codexReconciliationEnvironment = ["CODEX_HOME": ambientHome.path]
|
|
defer {
|
|
settings._test_codexReconciliationEnvironment = nil
|
|
try? FileManager.default.removeItem(at: ambientHome)
|
|
}
|
|
|
|
let snapshot = settings.codexAccountReconciliationSnapshot
|
|
let projection = settings.codexVisibleAccountProjection
|
|
|
|
#expect(settings.codexActiveSource == .liveSystem)
|
|
#expect(snapshot.storedAccounts.isEmpty)
|
|
#expect(snapshot.activeStoredAccount == nil)
|
|
#expect(snapshot.liveSystemAccount?.email == "ambient@example.com")
|
|
#expect(snapshot.liveSystemAccount?.codexHomePath == ambientHome.path)
|
|
#expect(snapshot.matchingStoredAccountForLiveSystemAccount == nil)
|
|
#expect(snapshot.activeSource == .liveSystem)
|
|
#expect(projection.visibleAccounts.map(\.email) == ["ambient@example.com"])
|
|
#expect(projection.activeVisibleAccountID == "ambient@example.com")
|
|
#expect(projection.liveVisibleAccountID == "ambient@example.com")
|
|
}
|
|
|
|
@Test
|
|
@MainActor
|
|
func `settings store can reuse short lived codex reconciliation snapshot`() throws {
|
|
let suite = "CodexAccountReconciliationTests-short-lived-cache"
|
|
let settings = try Self.makeSettings(suite: suite)
|
|
let ambientHome = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
UUID().uuidString,
|
|
isDirectory: true)
|
|
try Self.writeCodexAuthFile(homeURL: ambientHome, email: "cached@example.com", plan: "pro")
|
|
settings._test_codexReconciliationEnvironment = ["CODEX_HOME": ambientHome.path]
|
|
SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60
|
|
defer {
|
|
SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil
|
|
settings._test_codexReconciliationEnvironment = nil
|
|
try? FileManager.default.removeItem(at: ambientHome)
|
|
}
|
|
|
|
let first = settings.codexAccountReconciliationSnapshot
|
|
try FileManager.default.removeItem(at: ambientHome)
|
|
let cached = settings.codexAccountReconciliationSnapshot
|
|
settings.invalidateCodexAccountReconciliationSnapshotCache()
|
|
let refreshed = settings.codexAccountReconciliationSnapshot
|
|
|
|
#expect(first.liveSystemAccount?.email == "cached@example.com")
|
|
#expect(cached.liveSystemAccount?.email == "cached@example.com")
|
|
#expect(refreshed.liveSystemAccount == nil)
|
|
}
|
|
|
|
@Test
|
|
@MainActor
|
|
func `codex active source write invalidates short lived reconciliation snapshot`() throws {
|
|
let suite = "CodexAccountReconciliationTests-active-source-cache-invalidation"
|
|
let settings = try Self.makeSettings(suite: suite)
|
|
let ambientHome = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
UUID().uuidString,
|
|
isDirectory: true)
|
|
try Self.writeCodexAuthFile(homeURL: ambientHome, email: "before@example.com", plan: "pro")
|
|
settings._test_codexReconciliationEnvironment = ["CODEX_HOME": ambientHome.path]
|
|
SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60
|
|
defer {
|
|
SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil
|
|
settings._test_codexReconciliationEnvironment = nil
|
|
try? FileManager.default.removeItem(at: ambientHome)
|
|
}
|
|
|
|
#expect(settings.codexAccountReconciliationSnapshot.liveSystemAccount?.email == "before@example.com")
|
|
try Self.writeCodexAuthFile(homeURL: ambientHome, email: "after@example.com", plan: "pro")
|
|
settings.codexActiveSource = .liveSystem
|
|
|
|
#expect(settings.codexAccountReconciliationSnapshot.liveSystemAccount?.email == "after@example.com")
|
|
}
|
|
|
|
@Test
|
|
@MainActor
|
|
func `managed account changes invalidate short lived reconciliation snapshot`() throws {
|
|
let suite = "CodexAccountReconciliationTests-managed-change-cache-invalidation"
|
|
let settings = try Self.makeSettings(suite: suite)
|
|
let storeURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codex-managed-store-\(UUID().uuidString).json")
|
|
try Self.writeManagedCodexStore(
|
|
ManagedCodexAccountSet(version: FileManagedCodexAccountStore.currentVersion, accounts: []),
|
|
to: storeURL)
|
|
let stored = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "stored@example.com",
|
|
managedHomePath: "/tmp/stored-managed-home",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 3)
|
|
settings._test_managedCodexAccountStoreURL = storeURL
|
|
settings.codexActiveSource = .managedAccount(id: stored.id)
|
|
SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60
|
|
defer {
|
|
SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil
|
|
settings._test_managedCodexAccountStoreURL = nil
|
|
try? FileManager.default.removeItem(at: storeURL)
|
|
}
|
|
|
|
#expect(settings.codexAccountReconciliationSnapshot.storedAccounts.isEmpty)
|
|
try Self.writeManagedCodexStore(
|
|
ManagedCodexAccountSet(version: FileManagedCodexAccountStore.currentVersion, accounts: [stored]),
|
|
to: storeURL)
|
|
settings.refreshCodexAccountReconciliationAfterManagedAccountsDidChange()
|
|
|
|
#expect(settings.codexAccountReconciliationSnapshot.storedAccounts.map(\.id) == [stored.id])
|
|
}
|
|
|
|
@Test
|
|
@MainActor
|
|
func `settings store home path override also keeps reconciliation hermetic`() throws {
|
|
let suite = "CodexAccountReconciliationTests-home-path-only"
|
|
let settings = try Self.makeSettings(suite: suite)
|
|
settings._test_activeManagedCodexRemoteHomePath = "/tmp/managed-route-home"
|
|
settings._test_liveSystemCodexAccount = nil
|
|
settings._test_codexReconciliationEnvironment = nil
|
|
defer {
|
|
settings._test_activeManagedCodexRemoteHomePath = nil
|
|
settings._test_liveSystemCodexAccount = nil
|
|
settings._test_codexReconciliationEnvironment = nil
|
|
}
|
|
|
|
let snapshot = settings.codexAccountReconciliationSnapshot
|
|
let projection = settings.codexVisibleAccountProjection
|
|
|
|
#expect(snapshot.storedAccounts.isEmpty)
|
|
#expect(snapshot.activeStoredAccount == nil)
|
|
#expect(snapshot.liveSystemAccount == nil)
|
|
#expect(snapshot.matchingStoredAccountForLiveSystemAccount == nil)
|
|
#expect(projection.visibleAccounts.isEmpty)
|
|
#expect(projection.activeVisibleAccountID == nil)
|
|
#expect(projection.liveVisibleAccountID == nil)
|
|
}
|
|
|
|
@Test
|
|
@MainActor
|
|
func `settings store home path override keeps active source hermetic without persisted source`() throws {
|
|
let suite = "CodexAccountReconciliationTests-home-path-hermetic-source"
|
|
let settings = try Self.makeSettings(suite: suite)
|
|
let ambient = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "ambient-managed@example.com",
|
|
managedHomePath: "/tmp/ambient-managed-home",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 3)
|
|
let accounts = ManagedCodexAccountSet(
|
|
version: FileManagedCodexAccountStore.currentVersion,
|
|
accounts: [ambient])
|
|
let storeURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codex-managed-store-\(UUID().uuidString).json")
|
|
try Self.writeManagedCodexStore(accounts, to: storeURL)
|
|
|
|
settings._test_managedCodexAccountStoreURL = storeURL
|
|
settings._test_activeManagedCodexRemoteHomePath = "/tmp/managed-route-home"
|
|
defer {
|
|
settings._test_managedCodexAccountStoreURL = nil
|
|
settings._test_activeManagedCodexRemoteHomePath = nil
|
|
try? FileManager.default.removeItem(at: storeURL)
|
|
}
|
|
|
|
let snapshot = settings.codexAccountReconciliationSnapshot
|
|
|
|
#expect(settings.codexActiveSource == .liveSystem)
|
|
#expect(settings.providerConfig(for: .codex)?.codexActiveSource == nil)
|
|
#expect(snapshot.storedAccounts.map(\.id) == [ambient.id])
|
|
#expect(snapshot.storedAccounts.map(\.email) == [ambient.email])
|
|
#expect(snapshot.activeStoredAccount == nil)
|
|
#expect(snapshot.activeSource == .liveSystem)
|
|
}
|
|
|
|
@Test
|
|
@MainActor
|
|
func `settings store normal reconciliation path honors persisted active source`() throws {
|
|
let suite = "CodexAccountReconciliationTests-normal-path-active-source"
|
|
let settings = try Self.makeSettings(suite: suite)
|
|
let persistedSource = CodexActiveSource.managedAccount(id: UUID())
|
|
settings.codexActiveSource = persistedSource
|
|
|
|
let snapshot = settings.codexAccountReconciliationSnapshot
|
|
|
|
#expect(snapshot.activeSource == persistedSource)
|
|
}
|
|
|
|
@Test
|
|
@MainActor
|
|
func `settings store debug managed store U R L override loads on disk accounts`() throws {
|
|
let suite = "CodexAccountReconciliationTests-debug-store-url"
|
|
let settings = try Self.makeSettings(suite: suite)
|
|
let stored = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "stored@example.com",
|
|
managedHomePath: "/tmp/stored-managed-home",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 3)
|
|
let accounts = ManagedCodexAccountSet(
|
|
version: FileManagedCodexAccountStore.currentVersion,
|
|
accounts: [stored])
|
|
let storeURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codex-managed-store-\(UUID().uuidString).json")
|
|
try Self.writeManagedCodexStore(accounts, to: storeURL)
|
|
|
|
settings._test_managedCodexAccountStoreURL = storeURL
|
|
settings.codexActiveSource = .managedAccount(id: stored.id)
|
|
defer {
|
|
settings._test_managedCodexAccountStoreURL = nil
|
|
try? FileManager.default.removeItem(at: storeURL)
|
|
}
|
|
|
|
let snapshot = settings.codexAccountReconciliationSnapshot
|
|
|
|
#expect(snapshot.storedAccounts.map(\.id) == [stored.id])
|
|
#expect(snapshot.storedAccounts.map(\.email) == [stored.email])
|
|
#expect(snapshot.activeStoredAccount?.id == stored.id)
|
|
#expect(snapshot.activeStoredAccount?.email == stored.email)
|
|
#expect(snapshot.activeSource == .managedAccount(id: stored.id))
|
|
}
|
|
|
|
@Test
|
|
func `live only visible account is active when active source is live system`() {
|
|
let live = ObservedSystemCodexAccount(
|
|
email: "live@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
let projection = CodexVisibleAccountProjection.make(from: CodexAccountReconciliationSnapshot(
|
|
storedAccounts: [],
|
|
activeStoredAccount: nil,
|
|
liveSystemAccount: live,
|
|
matchingStoredAccountForLiveSystemAccount: nil,
|
|
activeSource: .liveSystem,
|
|
hasUnreadableAddedAccountStore: false))
|
|
|
|
#expect(projection.visibleAccounts.map(\.email) == ["live@example.com"])
|
|
#expect(projection.activeVisibleAccountID == "live@example.com")
|
|
#expect(projection.liveVisibleAccountID == "live@example.com")
|
|
}
|
|
|
|
@Test
|
|
func `workspace hydration changes snapshot equality and visible display state`() {
|
|
let accountID = UUID()
|
|
let baseAccount = ManagedCodexAccount(
|
|
id: accountID,
|
|
email: "user@example.com",
|
|
providerAccountID: "account-live",
|
|
managedHomePath: "/tmp/managed-a",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 3)
|
|
let hydratedAccount = ManagedCodexAccount(
|
|
id: accountID,
|
|
email: "user@example.com",
|
|
providerAccountID: "account-live",
|
|
workspaceLabel: "Team Alpha",
|
|
workspaceAccountID: "account-live",
|
|
managedHomePath: "/tmp/managed-a",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 3)
|
|
let baseSnapshot = CodexAccountReconciliationSnapshot(
|
|
storedAccounts: [baseAccount],
|
|
activeStoredAccount: baseAccount,
|
|
liveSystemAccount: nil,
|
|
matchingStoredAccountForLiveSystemAccount: nil,
|
|
activeSource: .managedAccount(id: accountID),
|
|
hasUnreadableAddedAccountStore: false)
|
|
let hydratedSnapshot = CodexAccountReconciliationSnapshot(
|
|
storedAccounts: [hydratedAccount],
|
|
activeStoredAccount: hydratedAccount,
|
|
liveSystemAccount: nil,
|
|
matchingStoredAccountForLiveSystemAccount: nil,
|
|
activeSource: .managedAccount(id: accountID),
|
|
hasUnreadableAddedAccountStore: false)
|
|
|
|
let baseProjection = CodexVisibleAccountProjection.make(from: baseSnapshot)
|
|
let hydratedProjection = CodexVisibleAccountProjection.make(from: hydratedSnapshot)
|
|
|
|
#expect(baseSnapshot != hydratedSnapshot)
|
|
#expect(baseProjection.visibleAccounts.first?.displayName == "user@example.com")
|
|
#expect(hydratedProjection.visibleAccounts.first?.displayName == "user@example.com — Team Alpha")
|
|
}
|
|
|
|
@Test
|
|
func `matching live system account does not duplicate stored identity`() {
|
|
let stored = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "user@example.com",
|
|
managedHomePath: "/tmp/managed-a",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 3)
|
|
let accounts = ManagedCodexAccountSet(version: 1, accounts: [stored])
|
|
let live = ObservedSystemCodexAccount(
|
|
email: "USER@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
let reconciler = DefaultCodexAccountReconciler(
|
|
storeLoader: { accounts },
|
|
systemObserver: StubSystemObserver(account: live),
|
|
activeSource: .managedAccount(id: stored.id),
|
|
baseEnvironment: [:])
|
|
|
|
let projection = reconciler.loadVisibleAccounts()
|
|
|
|
#expect(projection.visibleAccounts.count == 1)
|
|
#expect(projection.activeVisibleAccountID == "user@example.com")
|
|
#expect(projection.liveVisibleAccountID == "user@example.com")
|
|
}
|
|
|
|
@Test
|
|
func `matching live system account prefers live workspace label and keeps stored fallback`() {
|
|
let stored = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "user@example.com",
|
|
providerAccountID: "account-live",
|
|
workspaceLabel: "Saved Team",
|
|
workspaceAccountID: "account-live",
|
|
managedHomePath: "/tmp/managed-a",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 3)
|
|
let liveWithLabel = ObservedSystemCodexAccount(
|
|
email: "USER@example.com",
|
|
workspaceLabel: "Live Team",
|
|
workspaceAccountID: "account-live",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date(),
|
|
identity: .providerAccount(id: "account-live"))
|
|
let labeledSnapshot = CodexAccountReconciliationSnapshot(
|
|
storedAccounts: [stored],
|
|
activeStoredAccount: stored,
|
|
liveSystemAccount: liveWithLabel,
|
|
matchingStoredAccountForLiveSystemAccount: stored,
|
|
activeSource: .managedAccount(id: stored.id),
|
|
hasUnreadableAddedAccountStore: false,
|
|
storedAccountRuntimeIdentities: [stored.id: .providerAccount(id: "account-live")],
|
|
storedAccountRuntimeEmails: [stored.id: "user@example.com"])
|
|
let liveWithoutLabel = ObservedSystemCodexAccount(
|
|
email: "USER@example.com",
|
|
workspaceLabel: nil,
|
|
workspaceAccountID: "account-live",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date(),
|
|
identity: .providerAccount(id: "account-live"))
|
|
let fallbackSnapshot = CodexAccountReconciliationSnapshot(
|
|
storedAccounts: [stored],
|
|
activeStoredAccount: stored,
|
|
liveSystemAccount: liveWithoutLabel,
|
|
matchingStoredAccountForLiveSystemAccount: stored,
|
|
activeSource: .managedAccount(id: stored.id),
|
|
hasUnreadableAddedAccountStore: false,
|
|
storedAccountRuntimeIdentities: [stored.id: .providerAccount(id: "account-live")],
|
|
storedAccountRuntimeEmails: [stored.id: "user@example.com"])
|
|
let liveWithEmptyLabel = ObservedSystemCodexAccount(
|
|
email: "USER@example.com",
|
|
workspaceLabel: " \n\t ",
|
|
workspaceAccountID: "account-live",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date(),
|
|
identity: .providerAccount(id: "account-live"))
|
|
let emptyLabelSnapshot = CodexAccountReconciliationSnapshot(
|
|
storedAccounts: [stored],
|
|
activeStoredAccount: stored,
|
|
liveSystemAccount: liveWithEmptyLabel,
|
|
matchingStoredAccountForLiveSystemAccount: stored,
|
|
activeSource: .managedAccount(id: stored.id),
|
|
hasUnreadableAddedAccountStore: false,
|
|
storedAccountRuntimeIdentities: [stored.id: .providerAccount(id: "account-live")],
|
|
storedAccountRuntimeEmails: [stored.id: "user@example.com"])
|
|
|
|
let labeledProjection = CodexVisibleAccountProjection.make(from: labeledSnapshot)
|
|
let fallbackProjection = CodexVisibleAccountProjection.make(from: fallbackSnapshot)
|
|
let emptyLabelProjection = CodexVisibleAccountProjection.make(from: emptyLabelSnapshot)
|
|
|
|
#expect(labeledProjection.visibleAccounts.count == 1)
|
|
#expect(labeledProjection.visibleAccounts.first?.workspaceLabel == "Live Team")
|
|
#expect(labeledProjection.visibleAccounts.first?.displayName == "user@example.com — Live Team")
|
|
#expect(fallbackProjection.visibleAccounts.count == 1)
|
|
#expect(fallbackProjection.visibleAccounts.first?.workspaceLabel == "Saved Team")
|
|
#expect(fallbackProjection.visibleAccounts.first?.displayName == "user@example.com — Saved Team")
|
|
#expect(emptyLabelProjection.visibleAccounts.count == 1)
|
|
#expect(emptyLabelProjection.visibleAccounts.first?.workspaceLabel == "Saved Team")
|
|
#expect(emptyLabelProjection.visibleAccounts.first?.displayName == "user@example.com — Saved Team")
|
|
}
|
|
|
|
@Test
|
|
func `matching live system account resolves merged row selection to live system`() {
|
|
let stored = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "user@example.com",
|
|
managedHomePath: "/tmp/managed-a",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 3)
|
|
let live = ObservedSystemCodexAccount(
|
|
email: "USER@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
let snapshot = CodexAccountReconciliationSnapshot(
|
|
storedAccounts: [stored],
|
|
activeStoredAccount: stored,
|
|
liveSystemAccount: live,
|
|
matchingStoredAccountForLiveSystemAccount: stored,
|
|
activeSource: .managedAccount(id: stored.id),
|
|
hasUnreadableAddedAccountStore: false)
|
|
|
|
let resolution = CodexActiveSourceResolver.resolve(from: snapshot)
|
|
let projection = CodexVisibleAccountProjection.make(from: snapshot)
|
|
|
|
#expect(resolution.persistedSource == .managedAccount(id: stored.id))
|
|
#expect(resolution.resolvedSource == .liveSystem)
|
|
#expect(resolution.requiresPersistenceCorrection)
|
|
#expect(projection.activeVisibleAccountID == "user@example.com")
|
|
#expect(projection.source(forVisibleAccountID: "user@example.com") == .liveSystem)
|
|
}
|
|
|
|
@Test
|
|
func `provider account does not collapse with email only live account on same email`() throws {
|
|
let managedHome = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
UUID().uuidString,
|
|
isDirectory: true)
|
|
defer { try? FileManager.default.removeItem(at: managedHome) }
|
|
try Self.writeCodexAuthFile(
|
|
homeURL: managedHome,
|
|
email: "user@example.com",
|
|
plan: "pro",
|
|
accountID: "account-managed")
|
|
|
|
let stored = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "user@example.com",
|
|
managedHomePath: managedHome.path,
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 3)
|
|
let accounts = ManagedCodexAccountSet(version: 1, accounts: [stored])
|
|
let live = ObservedSystemCodexAccount(
|
|
email: "USER@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date(),
|
|
identity: .emailOnly(normalizedEmail: "user@example.com"))
|
|
let reconciler = DefaultCodexAccountReconciler(
|
|
storeLoader: { accounts },
|
|
systemObserver: StubSystemObserver(account: live),
|
|
activeSource: .managedAccount(id: stored.id),
|
|
baseEnvironment: [:])
|
|
|
|
let snapshot = reconciler.loadSnapshot()
|
|
let resolution = CodexActiveSourceResolver.resolve(from: snapshot)
|
|
let projection = CodexVisibleAccountProjection.make(from: snapshot)
|
|
|
|
#expect(snapshot.matchingStoredAccountForLiveSystemAccount == nil)
|
|
#expect(resolution.resolvedSource == .managedAccount(id: stored.id))
|
|
#expect(projection.visibleAccounts.count == 2)
|
|
#expect(Set(projection.visibleAccounts.map(\.email)) == Set(["user@example.com"]))
|
|
#expect(Set(projection.visibleAccounts.map(\.id)).count == 2)
|
|
#expect(projection.activeVisibleAccountID == projection.visibleAccounts
|
|
.first { $0.selectionSource == .managedAccount(id: stored.id) }?.id)
|
|
#expect(projection.liveVisibleAccountID == projection.visibleAccounts
|
|
.first { $0.selectionSource == .liveSystem }?.id)
|
|
}
|
|
|
|
@Test
|
|
func `missing managed source resolves to live system when live account exists`() {
|
|
let live = ObservedSystemCodexAccount(
|
|
email: "live@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
let missingID = UUID()
|
|
let snapshot = CodexAccountReconciliationSnapshot(
|
|
storedAccounts: [],
|
|
activeStoredAccount: nil,
|
|
liveSystemAccount: live,
|
|
matchingStoredAccountForLiveSystemAccount: nil,
|
|
activeSource: .managedAccount(id: missingID),
|
|
hasUnreadableAddedAccountStore: false)
|
|
|
|
let resolution = CodexActiveSourceResolver.resolve(from: snapshot)
|
|
|
|
#expect(resolution.persistedSource == .managedAccount(id: missingID))
|
|
#expect(resolution.resolvedSource == .liveSystem)
|
|
#expect(resolution.requiresPersistenceCorrection)
|
|
}
|
|
|
|
@Test
|
|
func `unreadable managed source resolves to live system when live account exists`() {
|
|
let live = ObservedSystemCodexAccount(
|
|
email: "live@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
let unreadableID = UUID()
|
|
let snapshot = CodexAccountReconciliationSnapshot(
|
|
storedAccounts: [],
|
|
activeStoredAccount: nil,
|
|
liveSystemAccount: live,
|
|
matchingStoredAccountForLiveSystemAccount: nil,
|
|
activeSource: .managedAccount(id: unreadableID),
|
|
hasUnreadableAddedAccountStore: true)
|
|
|
|
let resolution = CodexActiveSourceResolver.resolve(from: snapshot)
|
|
|
|
#expect(resolution.persistedSource == .managedAccount(id: unreadableID))
|
|
#expect(resolution.resolvedSource == .liveSystem)
|
|
#expect(resolution.requiresPersistenceCorrection)
|
|
}
|
|
|
|
@Test
|
|
func `managed account remains active when active source stays managed while live account changes`() {
|
|
let managed = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "managed@example.com",
|
|
managedHomePath: "/tmp/managed-home",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 3)
|
|
let live = ObservedSystemCodexAccount(
|
|
email: "system@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
let projection = CodexVisibleAccountProjection.make(from: CodexAccountReconciliationSnapshot(
|
|
storedAccounts: [managed],
|
|
activeStoredAccount: managed,
|
|
liveSystemAccount: live,
|
|
matchingStoredAccountForLiveSystemAccount: nil,
|
|
activeSource: .managedAccount(id: managed.id),
|
|
hasUnreadableAddedAccountStore: false))
|
|
|
|
#expect(Set(projection.visibleAccounts.map(\.email)) == [
|
|
"managed@example.com",
|
|
"system@example.com",
|
|
])
|
|
#expect(projection.activeVisibleAccountID == "managed@example.com")
|
|
#expect(projection.liveVisibleAccountID == "system@example.com")
|
|
}
|
|
|
|
@Test
|
|
func `live system account that differs from active stored account remains visible`() {
|
|
let active = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "managed@example.com",
|
|
managedHomePath: "/tmp/managed-a",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 3)
|
|
let accounts = ManagedCodexAccountSet(version: 1, accounts: [active])
|
|
let live = ObservedSystemCodexAccount(
|
|
email: "system@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
let reconciler = DefaultCodexAccountReconciler(
|
|
storeLoader: { accounts },
|
|
systemObserver: StubSystemObserver(account: live),
|
|
activeSource: .managedAccount(id: active.id),
|
|
baseEnvironment: [:])
|
|
|
|
let projection = reconciler.loadVisibleAccounts()
|
|
|
|
#expect(Set(projection.visibleAccounts.map(\.email)) == ["managed@example.com", "system@example.com"])
|
|
#expect(projection.activeVisibleAccountID == "managed@example.com")
|
|
#expect(projection.liveVisibleAccountID == "system@example.com")
|
|
}
|
|
|
|
@Test
|
|
func `inactive stored account still appears as visible`() {
|
|
let active = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "active@example.com",
|
|
managedHomePath: "/tmp/managed-a",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 3)
|
|
let inactive = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "inactive@example.com",
|
|
managedHomePath: "/tmp/managed-b",
|
|
createdAt: 4,
|
|
updatedAt: 5,
|
|
lastAuthenticatedAt: 6)
|
|
let accounts = ManagedCodexAccountSet(
|
|
version: 1,
|
|
accounts: [active, inactive])
|
|
let live = ObservedSystemCodexAccount(
|
|
email: "system@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
let reconciler = DefaultCodexAccountReconciler(
|
|
storeLoader: { accounts },
|
|
systemObserver: StubSystemObserver(account: live),
|
|
activeSource: .managedAccount(id: active.id),
|
|
baseEnvironment: [:])
|
|
|
|
let projection = reconciler.loadVisibleAccounts()
|
|
|
|
#expect(Set(projection.visibleAccounts.map(\.email)) == [
|
|
"active@example.com",
|
|
"inactive@example.com",
|
|
"system@example.com",
|
|
])
|
|
#expect(projection.activeVisibleAccountID == "active@example.com")
|
|
#expect(projection.liveVisibleAccountID == "system@example.com")
|
|
}
|
|
|
|
@Test
|
|
func `unreadable account store still exposes live system account and degraded flag`() {
|
|
let live = ObservedSystemCodexAccount(
|
|
email: "live@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
let reconciler = DefaultCodexAccountReconciler(
|
|
storeLoader: { throw FileManagedCodexAccountStoreError.unsupportedVersion(999) },
|
|
systemObserver: StubSystemObserver(account: live),
|
|
baseEnvironment: [:])
|
|
|
|
let projection = reconciler.loadVisibleAccounts()
|
|
|
|
#expect(projection.visibleAccounts.map(\.email) == ["live@example.com"])
|
|
#expect(projection.activeVisibleAccountID == "live@example.com")
|
|
#expect(projection.liveVisibleAccountID == "live@example.com")
|
|
#expect(projection.hasUnreadableAddedAccountStore)
|
|
}
|
|
|
|
@Test
|
|
func `whitespace only live email is ignored`() {
|
|
let accounts = ManagedCodexAccountSet(version: 1, accounts: [])
|
|
let live = ObservedSystemCodexAccount(
|
|
email: " \n\t ",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
let reconciler = DefaultCodexAccountReconciler(
|
|
storeLoader: { accounts },
|
|
systemObserver: StubSystemObserver(account: live),
|
|
baseEnvironment: [:])
|
|
|
|
let projection = reconciler.loadVisibleAccounts()
|
|
|
|
#expect(projection.visibleAccounts.isEmpty)
|
|
#expect(projection.activeVisibleAccountID == nil)
|
|
#expect(projection.liveVisibleAccountID == nil)
|
|
}
|
|
|
|
@Test
|
|
@MainActor
|
|
func `settings store can override active source to live system`() throws {
|
|
let suite = "CodexAccountReconciliationTests-live-source-override"
|
|
let settings = try Self.makeSettings(suite: suite)
|
|
let managed = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "managed@example.com",
|
|
managedHomePath: "/tmp/managed-home",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 3)
|
|
let live = ObservedSystemCodexAccount(
|
|
email: "system@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
settings._test_activeManagedCodexAccount = managed
|
|
settings._test_liveSystemCodexAccount = live
|
|
settings.codexActiveSource = .liveSystem
|
|
defer {
|
|
settings._test_activeManagedCodexAccount = nil
|
|
settings._test_liveSystemCodexAccount = nil
|
|
}
|
|
|
|
let snapshot = settings.codexAccountReconciliationSnapshot
|
|
let projection = settings.codexVisibleAccountProjection
|
|
|
|
#expect(settings.codexActiveSource == .liveSystem)
|
|
#expect(snapshot.activeSource == .liveSystem)
|
|
#expect(projection.activeVisibleAccountID == "system@example.com")
|
|
#expect(projection.liveVisibleAccountID == "system@example.com")
|
|
}
|
|
|
|
@Test
|
|
@MainActor
|
|
func `selecting merged visible account persists live system source`() throws {
|
|
let suite = "CodexAccountReconciliationTests-select-merged-visible-account"
|
|
let settings = try Self.makeSettings(suite: suite)
|
|
let managed = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "same@example.com",
|
|
managedHomePath: "/tmp/managed-home",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 3)
|
|
let live = ObservedSystemCodexAccount(
|
|
email: "SAME@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
settings._test_activeManagedCodexAccount = managed
|
|
settings._test_liveSystemCodexAccount = live
|
|
settings.codexActiveSource = .managedAccount(id: managed.id)
|
|
defer {
|
|
settings._test_activeManagedCodexAccount = nil
|
|
settings._test_liveSystemCodexAccount = nil
|
|
}
|
|
|
|
let didSelect = settings.selectCodexVisibleAccount(id: "same@example.com")
|
|
|
|
#expect(didSelect)
|
|
#expect(settings.codexActiveSource == .liveSystem)
|
|
#expect(settings.codexResolvedActiveSource == .liveSystem)
|
|
}
|
|
|
|
@Test
|
|
@MainActor
|
|
func `selecting authenticated managed account prefers live system when visible row is merged`() throws {
|
|
let suite = "CodexAccountReconciliationTests-select-authenticated-managed-merged"
|
|
let settings = try Self.makeSettings(suite: suite)
|
|
let managed = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "same@example.com",
|
|
managedHomePath: "/tmp/managed-home",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 3)
|
|
let live = ObservedSystemCodexAccount(
|
|
email: "SAME@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date())
|
|
settings._test_activeManagedCodexAccount = managed
|
|
settings._test_liveSystemCodexAccount = live
|
|
settings.codexActiveSource = .managedAccount(id: UUID())
|
|
defer {
|
|
settings._test_activeManagedCodexAccount = nil
|
|
settings._test_liveSystemCodexAccount = nil
|
|
}
|
|
|
|
settings.selectAuthenticatedManagedCodexAccount(managed)
|
|
|
|
#expect(settings.codexActiveSource == .liveSystem)
|
|
#expect(settings.codexResolvedActiveSource == .liveSystem)
|
|
}
|
|
|
|
@Test
|
|
@MainActor
|
|
func `selecting authenticated managed account keeps managed source for split identity rows`() throws {
|
|
let suite = "CodexAccountReconciliationTests-select-authenticated-managed-split"
|
|
let settings = try Self.makeSettings(suite: suite)
|
|
let managedHome = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
UUID().uuidString,
|
|
isDirectory: true)
|
|
let storeURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer {
|
|
settings._test_managedCodexAccountStoreURL = nil
|
|
settings._test_liveSystemCodexAccount = nil
|
|
try? FileManager.default.removeItem(at: managedHome)
|
|
try? FileManager.default.removeItem(at: storeURL)
|
|
}
|
|
|
|
try Self.writeCodexAuthFile(
|
|
homeURL: managedHome,
|
|
email: "same@example.com",
|
|
plan: "pro",
|
|
accountID: "account-managed")
|
|
let managed = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "same@example.com",
|
|
managedHomePath: managedHome.path,
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastAuthenticatedAt: 3)
|
|
try Self.writeManagedCodexStore(
|
|
ManagedCodexAccountSet(version: FileManagedCodexAccountStore.currentVersion, accounts: [managed]),
|
|
to: storeURL)
|
|
|
|
settings._test_managedCodexAccountStoreURL = storeURL
|
|
settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount(
|
|
email: "SAME@example.com",
|
|
codexHomePath: "/Users/test/.codex",
|
|
observedAt: Date(),
|
|
identity: .emailOnly(normalizedEmail: "same@example.com"))
|
|
settings.codexActiveSource = .liveSystem
|
|
|
|
let projection = settings.codexVisibleAccountProjection
|
|
#expect(projection.visibleAccounts.count == 2)
|
|
|
|
settings.selectAuthenticatedManagedCodexAccount(managed)
|
|
|
|
#expect(settings.codexActiveSource == .managedAccount(id: managed.id))
|
|
#expect(settings.codexResolvedActiveSource == .managedAccount(id: managed.id))
|
|
}
|
|
}
|
|
|
|
private struct StubSystemObserver: CodexSystemAccountObserving {
|
|
let account: ObservedSystemCodexAccount?
|
|
|
|
func loadSystemAccount(environment _: [String: String]) throws -> ObservedSystemCodexAccount? {
|
|
self.account
|
|
}
|
|
}
|
|
|
|
extension CodexAccountReconciliationTests {
|
|
private static func writeManagedCodexStore(_ accounts: ManagedCodexAccountSet, to storeURL: URL) throws {
|
|
let store = FileManagedCodexAccountStore(fileURL: storeURL)
|
|
try store.storeAccounts(accounts)
|
|
}
|
|
|
|
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))."
|
|
}
|
|
}
|