import XCTest @testable import MixBoard // MARK: - Protocols for Dependency Injection // // ChadMusicCredentials must accept these protocols for testability. // Production code uses KeychainService (conforming to KeyStoreProtocol) // and UserDefaults (conforming to DefaultsStoreProtocol). /// Abstraction over Keychain storage for the API key. protocol KeyStoreProtocol { func save(_ key: String) throws func load() -> String? func delete() } /// Abstraction over UserDefaults for reading/removing the legacy API key. protocol DefaultsStoreProtocol { func string(forKey key: String) -> String? func removeObject(forKey key: String) } // MARK: - In-Memory Mocks final class InMemoryKeyStore: KeyStoreProtocol { var storedKey: String? var shouldThrowOnSave = false var saveCallCount = 0 var deleteCallCount = 0 func save(_ key: String) throws { saveCallCount += 1 if shouldThrowOnSave { throw NSError(domain: "KeyStoreTest", code: -25293, userInfo: [NSLocalizedDescriptionKey: "Keychain access denied (simulated)"]) } storedKey = key } func load() -> String? { storedKey } func delete() { deleteCallCount += 1 storedKey = nil } } final class InMemoryDefaultsStore: DefaultsStoreProtocol { var storage: [String: String] = [:] var removedKeys: [String] = [] func string(forKey key: String) -> String? { storage[key] } func removeObject(forKey key: String) { removedKeys.append(key) storage.removeValue(forKey: key) } } // MARK: - ChadMusicCredentials Migration Tests // // These tests define the contract for ChadMusicCredentials. // The implementation (fix-api-key-security task) must make all tests pass. // // ChadMusicCredentials must support initialization with injected stores: // ChadMusicCredentials(keyStore:defaultsStore:) // so that tests can use in-memory mocks instead of real Keychain/UserDefaults. @MainActor final class KeychainMigrationTests: XCTestCase { private var keyStore: InMemoryKeyStore! private var defaultsStore: InMemoryDefaultsStore! override func setUp() { super.setUp() keyStore = InMemoryKeyStore() defaultsStore = InMemoryDefaultsStore() } // MARK: - Migration Logic /// Spec edge case: "Existing UserDefaults value, no Keychain → Migrate to Keychain, delete from UserDefaults" func testMigratesFromDefaultsToKeychain() { defaultsStore.storage["chadMusic.apiKey"] = "abc" // Keychain is empty (keyStore.storedKey == nil) let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore) let result = credentials.apiKey // Key migrated to Keychain XCTAssertEqual(result, "abc", "apiKey should return the migrated value") XCTAssertEqual(keyStore.storedKey, "abc", "Key should be saved to Keychain") // Plaintext removed from UserDefaults XCTAssertNil(defaultsStore.storage["chadMusic.apiKey"], "UserDefaults key should be removed after migration") XCTAssertTrue(defaultsStore.removedKeys.contains("chadMusic.apiKey"), "removeObject should have been called for the legacy key") } /// Spec edge case: "Both UserDefaults and Keychain have values → Keep Keychain value, delete UserDefaults copy" func testSkipsMigrationIfKeychainPopulated() { defaultsStore.storage["chadMusic.apiKey"] = "old-from-defaults" keyStore.storedKey = "new-from-keychain" let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore) let result = credentials.apiKey // Keychain value preserved, not overwritten XCTAssertEqual(result, "new-from-keychain", "Should return existing Keychain value, not overwrite with UserDefaults") XCTAssertEqual(keyStore.storedKey, "new-from-keychain", "Keychain value must not be overwritten") // UserDefaults plaintext still cleaned up XCTAssertNil(defaultsStore.storage["chadMusic.apiKey"], "UserDefaults copy should be deleted even when Keychain already has a value") XCTAssertEqual(keyStore.saveCallCount, 0, "Keychain save should NOT be called when Keychain already populated") } /// Spec edge case: "Fresh install (no UserDefaults, no Keychain) → apiKey returns nil" func testSkipsMigrationIfDefaultsEmpty() { // Both stores empty let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore) let result = credentials.apiKey XCTAssertNil(result, "apiKey should be nil when both stores are empty") XCTAssertNil(keyStore.storedKey, "Keychain should remain empty") XCTAssertEqual(keyStore.saveCallCount, 0, "No save should occur") } /// Spec edge case: "Empty string in UserDefaults → Treated as 'not set' — no migration" func testSkipsMigrationIfDefaultsHasEmptyString() { defaultsStore.storage["chadMusic.apiKey"] = "" let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore) let result = credentials.apiKey XCTAssertNil(result, "Empty string should be treated as nil") XCTAssertNil(keyStore.storedKey, "Empty string should not be migrated to Keychain") XCTAssertEqual(keyStore.saveCallCount, 0, "No save should occur for empty string") } /// Spec edge case: "Keychain access denied → Leave UserDefaults in place, no crash" func testFallbackWhenKeychainDenied() { defaultsStore.storage["chadMusic.apiKey"] = "abc" keyStore.shouldThrowOnSave = true let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore) // Should not crash — graceful fallback let result = credentials.apiKey // UserDefaults value preserved (migration failed) XCTAssertEqual(defaultsStore.storage["chadMusic.apiKey"], "abc", "UserDefaults should keep the key when Keychain save fails") // apiKey should still be accessible (either from failed Keychain or fallback) // The exact behavior depends on implementation: it may return nil from Keychain // or fall back to UserDefaults. Either way, no crash. // The key point: the app doesn't crash and the plaintext isn't lost. XCTAssertFalse(defaultsStore.removedKeys.contains("chadMusic.apiKey"), "UserDefaults key must NOT be removed if Keychain save failed") } /// Migration runs only once per instance — second access doesn't re-migrate. func testMigrationRunsOnlyOnce() { defaultsStore.storage["chadMusic.apiKey"] = "abc" let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore) // First access triggers migration _ = credentials.apiKey XCTAssertEqual(keyStore.saveCallCount, 1, "First access should trigger one save") // Manually put something back in defaults (simulating external write) defaultsStore.storage["chadMusic.apiKey"] = "should-not-migrate" // Second access should NOT re-migrate _ = credentials.apiKey XCTAssertEqual(keyStore.saveCallCount, 1, "Second access should not trigger another migration") XCTAssertEqual(keyStore.storedKey, "abc", "Keychain should still have the original migrated value") } // MARK: - Save / Load / Delete /// Spec: "Call save('xyz') → load() returns 'xyz'" func testSaveWritesToKeychain() throws { let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore) try credentials.save("xyz") XCTAssertEqual(keyStore.storedKey, "xyz", "save() should write to the key store") XCTAssertEqual(credentials.apiKey, "xyz", "apiKey should return the saved value") } /// Spec: "Save then delete → load() returns nil" func testDeleteRemovesKey() throws { let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore) try credentials.save("to-delete") XCTAssertNotNil(credentials.apiKey) credentials.delete() XCTAssertNil(keyStore.storedKey, "delete() should remove the key from the store") XCTAssertNil(credentials.apiKey, "apiKey should return nil after deletion") } /// Spec: "Keychain has '' → apiKey returns nil (empty treated as nil)" func testApiKeyNilWhenKeychainHasEmptyString() { keyStore.storedKey = "" let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore) let result = credentials.apiKey XCTAssertNil(result, "Empty string in Keychain should be treated as nil") } /// Save overwrites existing value. func testSaveOverwritesExistingKey() throws { let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore) try credentials.save("first") XCTAssertEqual(credentials.apiKey, "first") try credentials.save("second") XCTAssertEqual(credentials.apiKey, "second") XCTAssertEqual(keyStore.storedKey, "second") } /// Save propagates Keychain errors. func testSaveThrowsOnKeychainError() { keyStore.shouldThrowOnSave = true let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore) XCTAssertThrowsError(try credentials.save("fail")) { error in XCTAssertNotNil(error.localizedDescription) } } /// Unicode API keys are handled correctly. func testSaveAndLoadUnicodeKey() throws { let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore) let key = "api-key-with-ünîcödé-чëрт" try credentials.save(key) XCTAssertEqual(credentials.apiKey, key) } // MARK: - isConfigured helper (if exposed) /// ChadMusicCredentials should expose whether a key is set. func testHasKeyWhenSet() throws { let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore) XCTAssertFalse(credentials.hasKey, "hasKey should be false when no key is stored") try credentials.save("some-key") XCTAssertTrue(credentials.hasKey, "hasKey should be true after saving a key") credentials.delete() XCTAssertFalse(credentials.hasKey, "hasKey should be false after deletion") } }