Sfoglia il codice sorgente

test: add KeychainMigrationTests for ChadMusicCredentials migration contract

Defines the test-first contract for the Keychain migration feature:
- KeyStoreProtocol and DefaultsStoreProtocol for DI
- InMemoryKeyStore and InMemoryDefaultsStore mocks
- 13 tests covering migration, save/load/delete, edge cases
- Tests will fail until ChadMusicCredentials is implemented

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
aldiss 2 mesi fa
parent
commit
6578e9e943
1 ha cambiato i file con 270 aggiunte e 0 eliminazioni
  1. 270 0
      Tests/Unit/KeychainMigrationTests.swift

+ 270 - 0
Tests/Unit/KeychainMigrationTests.swift

@@ -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")
+    }
+}