| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254 |
- import XCTest
- @testable import MixBoard
- // Protocols (KeyStoreProtocol, DefaultsStoreProtocol) are defined in
- // Sources/Services/ChadMusicCredentials.swift — imported via @testable import MixBoard.
- // 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")
- }
- }
|