|
@@ -0,0 +1,270 @@
|
|
|
|
|
+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")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|