import CodexBarCore import Foundation import Testing @testable import CodexBar @Suite(.serialized) struct CodexBarConfigMigratorTests { @Test func `legacy secret migration completion flag skips repeated scans`() throws { let suite = "CodexBarConfigMigratorTests-skip-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defer { defaults.removePersistentDomain(forName: suite) } let secrets = CountingLegacySecretStore() let accountStore = CountingTokenAccountStore() let stores = Self.legacyStores(secrets: secrets, accountStore: accountStore) let configStore = testConfigStore(suiteName: suite) _ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores) let firstSecretLoads = secrets.loadCount let firstAccountLoads = accountStore.loadCount #expect(firstSecretLoads > 0) #expect(firstAccountLoads == 1) #expect(defaults.bool(forKey: Self.legacyMigrationCompletedKey) == true) _ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores) #expect(secrets.loadCount == firstSecretLoads) #expect(accountStore.loadCount == firstAccountLoads) } @Test func `legacy migration completion waits for successful cleanup`() throws { let suite = "CodexBarConfigMigratorTests-cleanup-failure-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defer { defaults.removePersistentDomain(forName: suite) } let secrets = CountingLegacySecretStore(token: "legacy-token", throwOnStore: true) let accountStore = CountingTokenAccountStore() let stores = Self.legacyStores(secrets: secrets, accountStore: accountStore) let configStore = testConfigStore(suiteName: suite) _ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores) let firstSecretLoads = secrets.loadCount #expect(firstSecretLoads > 0) #expect(secrets.clearAttempts > 0) #expect(defaults.bool(forKey: Self.legacyMigrationCompletedKey) == false) secrets.throwOnStore = false _ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores) #expect(secrets.loadCount > firstSecretLoads) #expect(defaults.bool(forKey: Self.legacyMigrationCompletedKey) == true) } @Test func `legacy stores are kept when migrated config save fails`() throws { let suite = "CodexBarConfigMigratorTests-save-failure-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defer { defaults.removePersistentDomain(forName: suite) } let base = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-tests", isDirectory: true) .appendingPathComponent(suite, isDirectory: true) try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: base) } let blockedDirectory = base.appendingPathComponent("blocked") try Data("not a directory".utf8).write(to: blockedDirectory) let secrets = CountingLegacySecretStore(token: "legacy-token") let accountStore = CountingTokenAccountStore() let stores = Self.legacyStores(secrets: secrets, accountStore: accountStore) let configStore = CodexBarConfigStore( fileURL: blockedDirectory.appendingPathComponent("config.json")) _ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores) #expect(secrets.clearAttempts == 0) #expect(try secrets.loadToken() == "legacy-token") #expect(defaults.bool(forKey: Self.legacyMigrationCompletedKey) == false) try FileManager.default.removeItem(at: blockedDirectory) _ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores) #expect(secrets.clearAttempts > 0) #expect(try secrets.loadToken() == nil) #expect(defaults.bool(forKey: Self.legacyMigrationCompletedKey) == true) } private static let legacyMigrationCompletedKey = "codexbar.legacySecretsMigrationCompleted" private static func legacyStores( secrets: CountingLegacySecretStore, accountStore: CountingTokenAccountStore) -> CodexBarConfigMigrator.LegacyStores { CodexBarConfigMigrator.LegacyStores( zaiTokenStore: secrets, syntheticTokenStore: secrets, codexCookieStore: secrets, claudeCookieStore: secrets, cursorCookieStore: secrets, opencodeCookieStore: secrets, factoryCookieStore: secrets, minimaxCookieStore: secrets, minimaxAPITokenStore: secrets, kimiTokenStore: secrets, kimiK2TokenStore: secrets, augmentCookieStore: secrets, ampCookieStore: secrets, copilotTokenStore: secrets, tokenAccountStore: accountStore) } } private final class CountingLegacySecretStore: ZaiTokenStoring, SyntheticTokenStoring, CookieHeaderStoring, MiniMaxCookieStoring, MiniMaxAPITokenStoring, KimiTokenStoring, KimiK2TokenStoring, CopilotTokenStoring, @unchecked Sendable { private let lock = NSLock() private var token: String? var throwOnStore: Bool private(set) var loadCount = 0 private(set) var clearAttempts = 0 init(token: String? = nil, throwOnStore: Bool = false) { self.token = token self.throwOnStore = throwOnStore } func loadToken() throws -> String? { self.lock.lock() defer { self.lock.unlock() } self.loadCount += 1 return self.token } func storeToken(_ token: String?) throws { try self.store(token) } func loadCookieHeader() throws -> String? { self.lock.lock() defer { self.lock.unlock() } self.loadCount += 1 return self.token } func storeCookieHeader(_ header: String?) throws { try self.store(header) } private func store(_ value: String?) throws { self.lock.lock() defer { self.lock.unlock() } self.clearAttempts += value == nil ? 1 : 0 if self.throwOnStore { throw TestStoreError.storeFailed } self.token = value } } private final class CountingTokenAccountStore: ProviderTokenAccountStoring, @unchecked Sendable { private let lock = NSLock() private(set) var loadCount = 0 func loadAccounts() throws -> [UsageProvider: ProviderTokenAccountData] { self.lock.lock() defer { self.lock.unlock() } self.loadCount += 1 return [:] } func storeAccounts(_: [UsageProvider: ProviderTokenAccountData]) throws {} func ensureFileExists() throws -> URL { FileManager.default.temporaryDirectory.appendingPathComponent("codexbar-empty-accounts.json") } } private enum TestStoreError: Error { case storeFailed }