735 lines
36 KiB
Swift
735 lines
36 KiB
Swift
import CodexBarCore
|
|
import Foundation
|
|
import Testing
|
|
@testable import CodexBar
|
|
|
|
@Suite(.serialized)
|
|
@MainActor
|
|
struct CodexAccountPromotionServiceTests {
|
|
@Test
|
|
func `happy path promotion swaps target auth into live home`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-happy-path")
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "beta@example.com",
|
|
authAccountID: "acct-beta")
|
|
try container.persistAccounts([target])
|
|
container.settings.codexActiveSource = .managedAccount(id: target.id)
|
|
try container.removeLiveAuthFile()
|
|
|
|
let targetAuthData = try container.managedAuthData(for: target)
|
|
let result = try await container.makeService().promoteManagedAccount(id: target.id)
|
|
let accounts = try container.loadAccounts().accounts
|
|
|
|
#expect(result.targetManagedAccountID == target.id)
|
|
#expect(result.outcome == .promoted)
|
|
#expect(result.displacedLiveDisposition == .none)
|
|
#expect(result.didMutateLiveAuth)
|
|
#expect(result.resultingActiveSource == .liveSystem)
|
|
#expect(try container.liveAuthData() == targetAuthData)
|
|
#expect(accounts.count == 1)
|
|
#expect(accounts.first?.id == target.id)
|
|
#expect(container.settings.codexActiveSource == .liveSystem)
|
|
#expect(container.usageStore.snapshots[.codex]?.accountEmail(for: .codex) == "beta@example.com")
|
|
}
|
|
|
|
@Test
|
|
func `displaced live oauth is imported before target auth is promoted`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-displaced-import",
|
|
workspaceIdentities: [
|
|
"acct-alpha": CodexOpenAIWorkspaceIdentity(
|
|
workspaceAccountID: "acct-alpha",
|
|
workspaceLabel: "Personal"),
|
|
])
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "beta@example.com",
|
|
authAccountID: "acct-beta")
|
|
try container.persistAccounts([target])
|
|
container.settings.codexActiveSource = .managedAccount(id: target.id)
|
|
let displacedLiveAuthData = try container.writeLiveOAuthAuthFile(
|
|
email: "alpha@example.com",
|
|
accountID: "acct-alpha")
|
|
|
|
let result = try await container.makeService().promoteManagedAccount(id: target.id)
|
|
let accounts = try container.loadAccounts().accounts
|
|
let importedID: UUID
|
|
switch result.displacedLiveDisposition {
|
|
case let .imported(managedAccountID):
|
|
importedID = managedAccountID
|
|
case .none, .alreadyManaged:
|
|
Issue.record("Expected displaced live account import")
|
|
throw PromotionTestError.unexpectedDisposition
|
|
}
|
|
let imported = try #require(accounts.first(where: { $0.id == importedID }))
|
|
|
|
#expect(accounts.count == 2)
|
|
#expect(imported.email == "alpha@example.com")
|
|
#expect(imported.providerAccountID == "acct-alpha")
|
|
#expect(imported.workspaceLabel == "Personal")
|
|
#expect(imported.workspaceAccountID == "acct-alpha")
|
|
#expect(imported.authFingerprint == CodexAuthFingerprint.fingerprint(data: displacedLiveAuthData))
|
|
#expect(try container.managedAuthData(for: imported) == displacedLiveAuthData)
|
|
#expect(try container.liveAuthData() == container.managedAuthData(for: target))
|
|
}
|
|
|
|
@Test
|
|
func `displaced live already managed uses reconciliation identity dedupe`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-already-managed")
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "beta@example.com",
|
|
authAccountID: "acct-beta")
|
|
let existingManagedLive = try container.createManagedAccount(
|
|
persistedEmail: "legacy@example.com",
|
|
authEmail: "alpha@example.com",
|
|
authAccountID: "acct-alpha",
|
|
persistedProviderAccountID: nil)
|
|
try container.persistAccounts([target, existingManagedLive])
|
|
let liveAuthData = try container.writeLiveOAuthAuthFile(
|
|
email: "alpha@example.com",
|
|
accountID: "acct-alpha",
|
|
apiKey: "sk-fresh-live")
|
|
|
|
let result = try await container.makeService().promoteManagedAccount(id: target.id)
|
|
let accounts = try container.loadAccounts().accounts
|
|
let refreshedManagedLive = try #require(accounts.first(where: { $0.id == existingManagedLive.id }))
|
|
|
|
#expect(result.displacedLiveDisposition == .alreadyManaged(managedAccountID: existingManagedLive.id))
|
|
#expect(accounts.count == 2)
|
|
#expect(accounts.contains(where: { $0.id == existingManagedLive.id }))
|
|
#expect(refreshedManagedLive.authFingerprint == CodexAuthFingerprint.fingerprint(data: liveAuthData))
|
|
#expect(try container.managedAuthData(for: refreshedManagedLive) == liveAuthData)
|
|
#expect(try container.liveAuthData() == container.managedAuthData(for: target))
|
|
}
|
|
|
|
@Test
|
|
func `provider only live auth refreshes existing managed account using persisted email`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-already-managed-no-email")
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "beta@example.com",
|
|
authAccountID: "acct-beta")
|
|
let existingManagedLive = try container.createManagedAccount(
|
|
persistedEmail: "alpha@example.com",
|
|
authAccountID: "acct-alpha")
|
|
try container.persistAccounts([target, existingManagedLive])
|
|
let liveAuthData = try container.writeLiveOAuthAuthFileWithoutEmail(accountID: "acct-alpha")
|
|
|
|
let result = try await container.makeService().promoteManagedAccount(id: target.id)
|
|
let accounts = try container.loadAccounts().accounts
|
|
let refreshedManagedLive = try #require(accounts.first(where: { $0.id == existingManagedLive.id }))
|
|
|
|
#expect(result.displacedLiveDisposition == .alreadyManaged(managedAccountID: existingManagedLive.id))
|
|
#expect(refreshedManagedLive.email == "alpha@example.com")
|
|
#expect(refreshedManagedLive.providerAccountID == "acct-alpha")
|
|
#expect(refreshedManagedLive.authFingerprint == CodexAuthFingerprint.fingerprint(data: liveAuthData))
|
|
#expect(try container.managedAuthData(for: refreshedManagedLive) == liveAuthData)
|
|
}
|
|
|
|
@Test
|
|
func `provider only live auth matching target converges and keeps managed active source`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-provider-only-convergence")
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "alpha@example.com",
|
|
authAccountID: "acct-alpha")
|
|
try container.persistAccounts([target])
|
|
let liveAuthData = try container.writeLiveOAuthAuthFileWithoutEmail(accountID: "acct-alpha")
|
|
container.settings.codexActiveSource = .managedAccount(id: target.id)
|
|
|
|
let result = try await container.makeService().promoteManagedAccount(id: target.id)
|
|
|
|
#expect(result.outcome == .convergedNoOp)
|
|
#expect(result.displacedLiveDisposition == .none)
|
|
#expect(result.didMutateLiveAuth == false)
|
|
#expect(result.resultingActiveSource == .managedAccount(id: target.id))
|
|
#expect(try container.liveAuthData() == liveAuthData)
|
|
#expect(try container.loadAccounts().accounts.count == 1)
|
|
#expect(container.settings.codexActiveSource == .managedAccount(id: target.id))
|
|
}
|
|
|
|
@Test
|
|
func `provider only live auth matching target converges when target managed auth is missing`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-provider-only-convergence-missing-target-auth")
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "alpha@example.com",
|
|
authAccountID: "acct-alpha")
|
|
try container.persistAccounts([target])
|
|
container.settings.codexActiveSource = .managedAccount(id: target.id)
|
|
try FileManager.default.removeItem(
|
|
at: URL(fileURLWithPath: target.managedHomePath, isDirectory: true)
|
|
.appendingPathComponent("auth.json", isDirectory: false))
|
|
let liveAuthData = try container.writeLiveOAuthAuthFileWithoutEmail(accountID: "acct-alpha")
|
|
|
|
let result = try await container.makeService().promoteManagedAccount(id: target.id)
|
|
|
|
#expect(result.outcome == .convergedNoOp)
|
|
#expect(result.displacedLiveDisposition == .none)
|
|
#expect(result.didMutateLiveAuth == false)
|
|
#expect(result.resultingActiveSource == .managedAccount(id: target.id))
|
|
#expect(try container.liveAuthData() == liveAuthData)
|
|
#expect(container.settings.codexActiveSource == .managedAccount(id: target.id))
|
|
}
|
|
|
|
@Test
|
|
func `snapshot convergence no op does not require target managed auth file`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-snapshot-convergence-with-missing-target-auth")
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "alpha@example.com",
|
|
authAccountID: "acct-alpha")
|
|
try container.persistAccounts([target])
|
|
try FileManager.default.removeItem(
|
|
at: URL(fileURLWithPath: target.managedHomePath, isDirectory: true)
|
|
.appendingPathComponent("auth.json", isDirectory: false))
|
|
container.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount(
|
|
email: "alpha@example.com",
|
|
codexHomePath: container.liveHomeURL.path,
|
|
observedAt: Date(),
|
|
identity: .emailOnly(normalizedEmail: "alpha@example.com"))
|
|
let liveAuthData = try container.writeLiveOAuthAuthFile(
|
|
email: "alpha@example.com",
|
|
accountID: "acct-alpha")
|
|
|
|
let result = try await container.makeService().promoteManagedAccount(id: target.id)
|
|
|
|
#expect(result.outcome == .convergedNoOp)
|
|
#expect(result.displacedLiveDisposition == .none)
|
|
#expect(result.didMutateLiveAuth == false)
|
|
#expect(try container.liveAuthData() == liveAuthData)
|
|
}
|
|
|
|
@Test
|
|
func `convergence no-op does not rewrite live auth or import displaced live`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-convergence")
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "alpha@example.com",
|
|
authAccountID: "acct-alpha")
|
|
try container.persistAccounts([target])
|
|
let liveAuthData = try container.writeLiveOAuthAuthFile(
|
|
email: "alpha@example.com",
|
|
accountID: "acct-alpha")
|
|
|
|
let result = try await container.makeService().promoteManagedAccount(id: target.id)
|
|
|
|
#expect(result.outcome == .convergedNoOp)
|
|
#expect(result.displacedLiveDisposition == .none)
|
|
#expect(result.didMutateLiveAuth == false)
|
|
#expect(try container.liveAuthData() == liveAuthData)
|
|
#expect(try container.loadAccounts().accounts.count == 1)
|
|
#expect(container.settings.codexActiveSource == .liveSystem)
|
|
}
|
|
|
|
@Test
|
|
func `same email different workspace imports displaced live as a distinct managed account`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-same-email-different-workspace",
|
|
workspaceIdentities: [
|
|
"acct-personal": CodexOpenAIWorkspaceIdentity(
|
|
workspaceAccountID: "acct-personal",
|
|
workspaceLabel: "Personal"),
|
|
])
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "alice@example.com",
|
|
authAccountID: "acct-team",
|
|
workspaceLabel: "Team",
|
|
workspaceAccountID: "acct-team")
|
|
try container.persistAccounts([target])
|
|
_ = try container.writeLiveOAuthAuthFile(email: "alice@example.com", accountID: "acct-personal")
|
|
|
|
let result = try await container.makeService().promoteManagedAccount(id: target.id)
|
|
let importedID: UUID
|
|
switch result.displacedLiveDisposition {
|
|
case let .imported(managedAccountID):
|
|
importedID = managedAccountID
|
|
case .none, .alreadyManaged:
|
|
Issue.record("Expected same-email different-workspace import")
|
|
throw PromotionTestError.unexpectedDisposition
|
|
}
|
|
let accounts = try container.loadAccounts().accounts
|
|
let imported = try #require(accounts.first(where: { $0.id == importedID }))
|
|
|
|
#expect(accounts.count == 2)
|
|
#expect(imported.id != target.id)
|
|
#expect(imported.email == "alice@example.com")
|
|
#expect(imported.providerAccountID == "acct-personal")
|
|
#expect(imported.workspaceLabel == "Personal")
|
|
#expect(imported.workspaceAccountID == "acct-personal")
|
|
}
|
|
|
|
@Test
|
|
func `mixed api key and oauth live auth still preserves displaced live identity`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-mixed-auth-preservation",
|
|
workspaceIdentities: [
|
|
"acct-alpha": CodexOpenAIWorkspaceIdentity(
|
|
workspaceAccountID: "acct-alpha",
|
|
workspaceLabel: "Personal"),
|
|
])
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "beta@example.com",
|
|
authAccountID: "acct-beta")
|
|
try container.persistAccounts([target])
|
|
_ = try container.writeLiveOAuthAuthFile(
|
|
email: "alpha@example.com",
|
|
accountID: "acct-alpha",
|
|
apiKey: "sk-mixed-live")
|
|
|
|
let result = try await container.makeService().promoteManagedAccount(id: target.id)
|
|
let importedID: UUID
|
|
switch result.displacedLiveDisposition {
|
|
case let .imported(managedAccountID):
|
|
importedID = managedAccountID
|
|
case .none, .alreadyManaged:
|
|
Issue.record("Expected displaced live import for mixed auth material")
|
|
throw PromotionTestError.unexpectedDisposition
|
|
}
|
|
let imported = try #require(try container.loadAccounts().accounts.first(where: { $0.id == importedID }))
|
|
|
|
#expect(imported.email == "alpha@example.com")
|
|
#expect(imported.providerAccountID == "acct-alpha")
|
|
#expect(imported.workspaceLabel == "Personal")
|
|
}
|
|
|
|
@Test
|
|
func `store commit failure leaves live auth untouched because promotion preserves before mutating`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-store-failure")
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "beta@example.com",
|
|
authAccountID: "acct-beta")
|
|
try container.persistAccounts([target])
|
|
let liveAuthData = try container.writeLiveOAuthAuthFile(
|
|
email: "alpha@example.com",
|
|
accountID: "acct-alpha")
|
|
let swapper = RecordingCodexLiveAuthSwapper()
|
|
let store = RecordingManagedCodexAccountStore(base: container.fileStore) { _ in
|
|
throw PromotionTestError.storeWriteFailed
|
|
}
|
|
|
|
await #expect(throws: CodexAccountPromotionError.managedStoreCommitFailed) {
|
|
try await container.makeService(
|
|
store: store,
|
|
liveAuthSwapper: swapper).promoteManagedAccount(id: target.id)
|
|
}
|
|
|
|
#expect(swapper.swapCallCount == 0)
|
|
#expect(try container.liveAuthData() == liveAuthData)
|
|
#expect(try container.loadAccounts().accounts.count == 1)
|
|
#expect(try container.loadAccounts().accounts.first?.id == target.id)
|
|
#expect(try container.managedHomeURLs().count == 1)
|
|
}
|
|
|
|
@Test
|
|
func `refresh store failure leaves existing managed metadata stale after auth copy`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-refresh-store-failure")
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "beta@example.com",
|
|
authAccountID: "acct-beta")
|
|
let existingManagedLive = try container.createManagedAccount(
|
|
persistedEmail: "alpha@example.com",
|
|
authAccountID: "acct-alpha")
|
|
try container.persistAccounts([target, existingManagedLive])
|
|
let originalManagedAuthData = try container.managedAuthData(for: existingManagedLive)
|
|
let liveAuthData = try container.writeLiveOAuthAuthFile(
|
|
email: "alpha@example.com",
|
|
accountID: "acct-alpha",
|
|
apiKey: "sk-refreshed-live")
|
|
let swapper = RecordingCodexLiveAuthSwapper()
|
|
let store = RecordingManagedCodexAccountStore(base: container.fileStore) { accounts in
|
|
if accounts.account(id: existingManagedLive.id)?
|
|
.lastAuthenticatedAt != existingManagedLive.lastAuthenticatedAt
|
|
{
|
|
throw PromotionTestError.storeWriteFailed
|
|
}
|
|
}
|
|
|
|
await #expect(throws: CodexAccountPromotionError.managedStoreCommitFailed) {
|
|
try await container.makeService(
|
|
store: store,
|
|
liveAuthSwapper: swapper).promoteManagedAccount(id: target.id)
|
|
}
|
|
|
|
let accounts = try container.loadAccounts().accounts
|
|
let persistedManagedLive = try #require(accounts.first(where: { $0.id == existingManagedLive.id }))
|
|
#expect(swapper.swapCallCount == 0)
|
|
#expect(try container.managedAuthData(for: persistedManagedLive) != originalManagedAuthData)
|
|
#expect(try container.managedAuthData(for: persistedManagedLive) == liveAuthData)
|
|
#expect(persistedManagedLive.lastAuthenticatedAt == existingManagedLive.lastAuthenticatedAt)
|
|
}
|
|
|
|
@Test
|
|
func `already managed refresh resolves workspace metadata from live auth home`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-already-managed-workspace-refresh")
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "beta@example.com",
|
|
authAccountID: "acct-beta")
|
|
let existingManagedLive = try container.createManagedAccount(
|
|
persistedEmail: "alpha@example.com",
|
|
authAccountID: "acct-alpha",
|
|
workspaceLabel: "Stale",
|
|
workspaceAccountID: "acct-alpha")
|
|
try container.persistAccounts([target, existingManagedLive])
|
|
_ = try container.writeLiveOAuthAuthFile(email: "alpha@example.com", accountID: "acct-alpha")
|
|
|
|
let service = CodexAccountPromotionService(
|
|
store: container.fileStore,
|
|
homeFactory: container.homeFactory,
|
|
identityReader: container.identityReader,
|
|
workspaceResolver: HomePathWorkspaceResolver(
|
|
byHomePath: [
|
|
container.liveHomeURL.path: CodexOpenAIWorkspaceIdentity(
|
|
workspaceAccountID: "acct-alpha",
|
|
workspaceLabel: "Fresh"),
|
|
existingManagedLive.managedHomePath: CodexOpenAIWorkspaceIdentity(
|
|
workspaceAccountID: "acct-alpha",
|
|
workspaceLabel: "Stale"),
|
|
]),
|
|
snapshotLoader: SettingsStoreCodexAccountReconciliationSnapshotLoader(settingsStore: container.settings),
|
|
authMaterialReader: DefaultCodexAuthMaterialReader(),
|
|
liveAuthSwapper: DefaultCodexLiveAuthSwapper(),
|
|
activeSourceWriter: SettingsStoreCodexActiveSourceWriter(settingsStore: container.settings),
|
|
accountScopedRefresher: UsageStoreCodexAccountScopedRefresher(usageStore: container.usageStore),
|
|
baseEnvironment: container.baseEnvironment,
|
|
fileManager: .default)
|
|
|
|
let result = try await service.promoteManagedAccount(id: target.id)
|
|
let accounts = try container.loadAccounts().accounts
|
|
let refreshedManagedLive = try #require(accounts.first(where: { $0.id == existingManagedLive.id }))
|
|
|
|
#expect(result.displacedLiveDisposition == .alreadyManaged(managedAccountID: existingManagedLive.id))
|
|
#expect(refreshedManagedLive.workspaceLabel == "Fresh")
|
|
#expect(refreshedManagedLive.workspaceAccountID == "acct-alpha")
|
|
}
|
|
|
|
@Test
|
|
func `live swap failure keeps preserved displaced live import in the managed store`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-swap-failure")
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "beta@example.com",
|
|
authAccountID: "acct-beta")
|
|
try container.persistAccounts([target])
|
|
container.settings.codexActiveSource = .managedAccount(id: target.id)
|
|
let liveAuthData = try container.writeLiveOAuthAuthFile(
|
|
email: "alpha@example.com",
|
|
accountID: "acct-alpha")
|
|
let swapper = RecordingCodexLiveAuthSwapper { _, _ in
|
|
throw PromotionTestError.swapFailed
|
|
}
|
|
|
|
await #expect(throws: CodexAccountPromotionError.liveAuthSwapFailed) {
|
|
try await container.makeService(liveAuthSwapper: swapper).promoteManagedAccount(id: target.id)
|
|
}
|
|
|
|
let accounts = try container.loadAccounts().accounts
|
|
let imported = try #require(accounts.first(where: { $0.id != target.id }))
|
|
#expect(accounts.count == 2)
|
|
#expect(imported.authFingerprint == CodexAuthFingerprint.fingerprint(data: liveAuthData))
|
|
#expect(try container.managedAuthData(for: imported) == liveAuthData)
|
|
#expect(try container.liveAuthData() == liveAuthData)
|
|
#expect(container.settings.codexActiveSource == .managedAccount(id: target.id))
|
|
}
|
|
|
|
@Test
|
|
func `target managed auth without email is rejected before swap`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-target-auth-missing-email")
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "alpha@example.com",
|
|
authAccountID: "acct-alpha")
|
|
try container.persistAccounts([target])
|
|
container.settings.codexActiveSource = .managedAccount(id: target.id)
|
|
_ = try container.writeManagedOAuthAuthFileWithoutEmail(for: target, accountID: "acct-alpha")
|
|
let swapper = RecordingCodexLiveAuthSwapper()
|
|
|
|
await #expect(throws: CodexAccountPromotionError.targetManagedAccountAuthUnreadable) {
|
|
try await container.makeService(liveAuthSwapper: swapper).promoteManagedAccount(id: target.id)
|
|
}
|
|
|
|
#expect(swapper.swapCallCount == 0)
|
|
#expect(try container.liveAuthData() == nil)
|
|
#expect(try container.loadAccounts().accounts.count == 1)
|
|
#expect(container.settings.codexActiveSource == .managedAccount(id: target.id))
|
|
}
|
|
|
|
@Test
|
|
func `provider account collision after stale managed home repairs existing record to preserved auth`()
|
|
async throws
|
|
{
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-stale-home-collision")
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "beta@example.com",
|
|
authAccountID: "acct-beta")
|
|
let existingID = UUID()
|
|
let staleHomeURL = container.managedHomesURL.appendingPathComponent(existingID.uuidString, isDirectory: true)
|
|
try FileManager.default.createDirectory(at: staleHomeURL, withIntermediateDirectories: true)
|
|
let staleManaged = ManagedCodexAccount(
|
|
id: existingID,
|
|
email: "alpha@example.com",
|
|
providerAccountID: "acct-alpha",
|
|
workspaceLabel: "Personal",
|
|
workspaceAccountID: "acct-alpha",
|
|
managedHomePath: staleHomeURL.path,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
lastAuthenticatedAt: 1)
|
|
try container.persistAccounts([target, staleManaged])
|
|
_ = try container.writeLiveOAuthAuthFile(email: "alpha@example.com", accountID: "acct-alpha")
|
|
let liveAuthData = try #require(try container.liveAuthData())
|
|
|
|
let result = try await container.makeService().promoteManagedAccount(id: target.id)
|
|
let accounts = try container.loadAccounts().accounts
|
|
let repairedManaged = try #require(accounts.first(where: { $0.id == staleManaged.id }))
|
|
|
|
#expect(result.displacedLiveDisposition == .alreadyManaged(managedAccountID: staleManaged.id))
|
|
#expect(accounts.count == 2)
|
|
#expect(repairedManaged.managedHomePath == staleHomeURL.path)
|
|
#expect(repairedManaged.authFingerprint == CodexAuthFingerprint.fingerprint(data: liveAuthData))
|
|
#expect(try container.managedAuthData(for: repairedManaged) == liveAuthData)
|
|
#expect(try container.managedHomeURLs().count == 2)
|
|
}
|
|
|
|
@Test
|
|
func `legacy email only managed account upgrades to provider identity during promotion`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-legacy-email-upgrade",
|
|
workspaceIdentities: [
|
|
"acct-alpha": CodexOpenAIWorkspaceIdentity(
|
|
workspaceAccountID: "acct-alpha",
|
|
workspaceLabel: "Personal"),
|
|
])
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "beta@example.com",
|
|
authAccountID: "acct-beta")
|
|
let legacyID = UUID()
|
|
let legacyHomeURL = container.managedHomesURL.appendingPathComponent(legacyID.uuidString, isDirectory: true)
|
|
try FileManager.default.createDirectory(at: legacyHomeURL, withIntermediateDirectories: true)
|
|
let legacyManaged = ManagedCodexAccount(
|
|
id: legacyID,
|
|
email: "alpha@example.com",
|
|
providerAccountID: nil,
|
|
workspaceLabel: nil,
|
|
workspaceAccountID: nil,
|
|
managedHomePath: legacyHomeURL.path,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
lastAuthenticatedAt: nil)
|
|
try container.persistAccounts([target, legacyManaged])
|
|
let liveAuthData = try container.writeLiveOAuthAuthFile(email: "alpha@example.com", accountID: "acct-alpha")
|
|
|
|
let result = try await container.makeService().promoteManagedAccount(id: target.id)
|
|
let accounts = try container.loadAccounts().accounts
|
|
let upgradedManaged = try #require(accounts.first(where: { $0.id == legacyManaged.id }))
|
|
|
|
#expect(result.displacedLiveDisposition == .alreadyManaged(managedAccountID: legacyManaged.id))
|
|
#expect(accounts.count == 2)
|
|
#expect(upgradedManaged.providerAccountID == "acct-alpha")
|
|
#expect(upgradedManaged.workspaceLabel == "Personal")
|
|
#expect(upgradedManaged.workspaceAccountID == "acct-alpha")
|
|
#expect(try container.managedAuthData(for: upgradedManaged) == liveAuthData)
|
|
}
|
|
|
|
@Test
|
|
func `unsafe managed home refresh is rejected before auth rewrite`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-unsafe-refresh-home")
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "beta@example.com",
|
|
authAccountID: "acct-beta")
|
|
let unsafeManagedLive = ManagedCodexAccount(
|
|
id: UUID(),
|
|
email: "alpha@example.com",
|
|
providerAccountID: "acct-alpha",
|
|
workspaceLabel: nil,
|
|
workspaceAccountID: "acct-alpha",
|
|
managedHomePath: container.rootURL.appendingPathComponent("unsafe-home", isDirectory: true).path,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
lastAuthenticatedAt: 1)
|
|
try container.persistAccounts([target, unsafeManagedLive])
|
|
let liveAuthData = try container.writeLiveOAuthAuthFile(
|
|
email: "alpha@example.com",
|
|
accountID: "acct-alpha")
|
|
|
|
await #expect(throws: CodexAccountPromotionError.displacedLiveImportFailed) {
|
|
try await container.makeService().promoteManagedAccount(id: target.id)
|
|
}
|
|
|
|
let accounts = try container.loadAccounts().accounts
|
|
#expect(try container.liveAuthData() == liveAuthData)
|
|
#expect(FileManager.default.fileExists(atPath: unsafeManagedLive.managedHomePath) == false)
|
|
#expect(accounts.count == 2)
|
|
#expect(accounts.contains(where: { $0.id == target.id }))
|
|
#expect(accounts.contains(where: { $0.id == unsafeManagedLive.id }))
|
|
}
|
|
|
|
@Test
|
|
func `promotion rejects conflicting readable managed home before overwriting auth`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-conflicting-readable-home")
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "beta@example.com",
|
|
authAccountID: "acct-beta")
|
|
let conflictingManaged = try container.createManagedAccount(
|
|
persistedEmail: "alpha@example.com",
|
|
authEmail: "gamma@example.com",
|
|
authAccountID: "acct-gamma",
|
|
persistedProviderAccountID: "acct-alpha",
|
|
useAuthAccountIDAsPersistedProviderAccountID: false)
|
|
try container.persistAccounts([target, conflictingManaged])
|
|
let liveAuthData = try container.writeLiveOAuthAuthFile(email: "alpha@example.com", accountID: "acct-alpha")
|
|
let conflictingAuthData = try container.managedAuthData(for: conflictingManaged)
|
|
|
|
await #expect(throws: CodexAccountPromotionError.displacedLiveManagedAccountConflict) {
|
|
try await container.makeService().promoteManagedAccount(id: target.id)
|
|
}
|
|
|
|
let accounts = try container.loadAccounts().accounts
|
|
let persistedConflict = try #require(accounts.first(where: { $0.id == conflictingManaged.id }))
|
|
#expect(try container.liveAuthData() == liveAuthData)
|
|
#expect(try container.managedAuthData(for: persistedConflict) == conflictingAuthData)
|
|
#expect(accounts.count == 2)
|
|
}
|
|
|
|
@Test
|
|
func `api key only live auth is rejected fail closed`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-api-key-only")
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "beta@example.com",
|
|
authAccountID: "acct-beta")
|
|
try container.persistAccounts([target])
|
|
let liveAuthData = try container.writeLiveAPIKeyAuthFile()
|
|
let snapshotLoader =
|
|
StaticCodexAccountReconciliationSnapshotLoader(snapshot: CodexAccountReconciliationSnapshot(
|
|
storedAccounts: [target],
|
|
activeStoredAccount: nil,
|
|
liveSystemAccount: nil,
|
|
matchingStoredAccountForLiveSystemAccount: nil,
|
|
activeSource: .liveSystem,
|
|
hasUnreadableAddedAccountStore: false))
|
|
|
|
await #expect(throws: CodexAccountPromotionError.liveAccountAPIKeyOnlyUnsupported) {
|
|
try await container.makeService(snapshotLoader: snapshotLoader).promoteManagedAccount(id: target.id)
|
|
}
|
|
|
|
#expect(try container.liveAuthData() == liveAuthData)
|
|
#expect(try container.loadAccounts().accounts.count == 1)
|
|
#expect(try container.loadAccounts().accounts.first?.id == target.id)
|
|
}
|
|
|
|
@Test
|
|
func `post promotion refresh re-resolves codex state from the new live auth`() async throws {
|
|
let container = try CodexAccountPromotionTestContainer(
|
|
suiteName: "CodexAccountPromotionServiceTests-state-reresolution")
|
|
defer { container.tearDown() }
|
|
|
|
let target = try container.createManagedAccount(
|
|
persistedEmail: "beta@example.com",
|
|
authAccountID: "acct-beta")
|
|
try container.persistAccounts([target])
|
|
let snapshotLoader =
|
|
StaticCodexAccountReconciliationSnapshotLoader(snapshot: CodexAccountReconciliationSnapshot(
|
|
storedAccounts: [target],
|
|
activeStoredAccount: nil,
|
|
liveSystemAccount: nil,
|
|
matchingStoredAccountForLiveSystemAccount: nil,
|
|
activeSource: .liveSystem,
|
|
hasUnreadableAddedAccountStore: false))
|
|
_ = try container.writeLiveOAuthAuthFile(email: "alpha@example.com", accountID: "acct-alpha")
|
|
container.seedScopedRefreshState(
|
|
email: "alpha@example.com",
|
|
identity: .providerAccount(id: "acct-alpha"))
|
|
let refresher = ClosureCodexAccountScopedRefresher { _ in
|
|
let snapshot = UsageSnapshot(
|
|
primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
|
|
secondary: nil,
|
|
updatedAt: Date(),
|
|
identity: ProviderIdentitySnapshot(
|
|
providerID: .codex,
|
|
accountEmail: "beta@example.com",
|
|
accountOrganization: nil,
|
|
loginMethod: "Pro"))
|
|
let credits = CreditsSnapshot(remaining: 17, events: [], updatedAt: Date())
|
|
container.usageStore._setSnapshotForTesting(snapshot, provider: .codex)
|
|
container.usageStore.credits = credits
|
|
container.usageStore.lastCreditsSnapshot = credits
|
|
container.usageStore.lastCreditsSnapshotAccountKey = "beta@example.com"
|
|
container.usageStore.lastCreditsSource = .api
|
|
container.usageStore.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard(
|
|
source: .liveSystem,
|
|
identity: .providerAccount(id: "acct-beta"),
|
|
accountKey: "beta@example.com")
|
|
}
|
|
|
|
let result = try await container.makeService(
|
|
snapshotLoader: snapshotLoader,
|
|
accountScopedRefresher: refresher)
|
|
.promoteManagedAccount(id: target.id)
|
|
|
|
#expect(result.outcome == .promoted)
|
|
#expect(container.usageStore.snapshots[.codex]?.accountEmail(for: .codex) == "beta@example.com")
|
|
#expect(container.usageStore.lastCreditsSnapshotAccountKey == "beta@example.com")
|
|
#expect(container.usageStore.lastCodexAccountScopedRefreshGuard?.identity == .providerAccount(id: "acct-beta"))
|
|
#expect(container.usageStore.lastCodexAccountScopedRefreshGuard?.accountKey == "beta@example.com")
|
|
}
|
|
}
|
|
|
|
private struct HomePathWorkspaceResolver: ManagedCodexWorkspaceResolving {
|
|
let byHomePath: [String: CodexOpenAIWorkspaceIdentity]
|
|
|
|
func resolveWorkspaceIdentity(
|
|
homePath: String,
|
|
providerAccountID _: String) async -> CodexOpenAIWorkspaceIdentity?
|
|
{
|
|
self.byHomePath[homePath]
|
|
}
|
|
}
|