KeychainMigrationTests.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. import XCTest
  2. @testable import MixBoard
  3. // MARK: - Protocols for Dependency Injection
  4. //
  5. // ChadMusicCredentials must accept these protocols for testability.
  6. // Production code uses KeychainService (conforming to KeyStoreProtocol)
  7. // and UserDefaults (conforming to DefaultsStoreProtocol).
  8. /// Abstraction over Keychain storage for the API key.
  9. protocol KeyStoreProtocol {
  10. func save(_ key: String) throws
  11. func load() -> String?
  12. func delete()
  13. }
  14. /// Abstraction over UserDefaults for reading/removing the legacy API key.
  15. protocol DefaultsStoreProtocol {
  16. func string(forKey key: String) -> String?
  17. func removeObject(forKey key: String)
  18. }
  19. // MARK: - In-Memory Mocks
  20. final class InMemoryKeyStore: KeyStoreProtocol {
  21. var storedKey: String?
  22. var shouldThrowOnSave = false
  23. var saveCallCount = 0
  24. var deleteCallCount = 0
  25. func save(_ key: String) throws {
  26. saveCallCount += 1
  27. if shouldThrowOnSave {
  28. throw NSError(domain: "KeyStoreTest", code: -25293,
  29. userInfo: [NSLocalizedDescriptionKey: "Keychain access denied (simulated)"])
  30. }
  31. storedKey = key
  32. }
  33. func load() -> String? {
  34. storedKey
  35. }
  36. func delete() {
  37. deleteCallCount += 1
  38. storedKey = nil
  39. }
  40. }
  41. final class InMemoryDefaultsStore: DefaultsStoreProtocol {
  42. var storage: [String: String] = [:]
  43. var removedKeys: [String] = []
  44. func string(forKey key: String) -> String? {
  45. storage[key]
  46. }
  47. func removeObject(forKey key: String) {
  48. removedKeys.append(key)
  49. storage.removeValue(forKey: key)
  50. }
  51. }
  52. // MARK: - ChadMusicCredentials Migration Tests
  53. //
  54. // These tests define the contract for ChadMusicCredentials.
  55. // The implementation (fix-api-key-security task) must make all tests pass.
  56. //
  57. // ChadMusicCredentials must support initialization with injected stores:
  58. // ChadMusicCredentials(keyStore:defaultsStore:)
  59. // so that tests can use in-memory mocks instead of real Keychain/UserDefaults.
  60. @MainActor
  61. final class KeychainMigrationTests: XCTestCase {
  62. private var keyStore: InMemoryKeyStore!
  63. private var defaultsStore: InMemoryDefaultsStore!
  64. override func setUp() {
  65. super.setUp()
  66. keyStore = InMemoryKeyStore()
  67. defaultsStore = InMemoryDefaultsStore()
  68. }
  69. // MARK: - Migration Logic
  70. /// Spec edge case: "Existing UserDefaults value, no Keychain → Migrate to Keychain, delete from UserDefaults"
  71. func testMigratesFromDefaultsToKeychain() {
  72. defaultsStore.storage["chadMusic.apiKey"] = "abc"
  73. // Keychain is empty (keyStore.storedKey == nil)
  74. let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore)
  75. let result = credentials.apiKey
  76. // Key migrated to Keychain
  77. XCTAssertEqual(result, "abc", "apiKey should return the migrated value")
  78. XCTAssertEqual(keyStore.storedKey, "abc", "Key should be saved to Keychain")
  79. // Plaintext removed from UserDefaults
  80. XCTAssertNil(defaultsStore.storage["chadMusic.apiKey"],
  81. "UserDefaults key should be removed after migration")
  82. XCTAssertTrue(defaultsStore.removedKeys.contains("chadMusic.apiKey"),
  83. "removeObject should have been called for the legacy key")
  84. }
  85. /// Spec edge case: "Both UserDefaults and Keychain have values → Keep Keychain value, delete UserDefaults copy"
  86. func testSkipsMigrationIfKeychainPopulated() {
  87. defaultsStore.storage["chadMusic.apiKey"] = "old-from-defaults"
  88. keyStore.storedKey = "new-from-keychain"
  89. let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore)
  90. let result = credentials.apiKey
  91. // Keychain value preserved, not overwritten
  92. XCTAssertEqual(result, "new-from-keychain",
  93. "Should return existing Keychain value, not overwrite with UserDefaults")
  94. XCTAssertEqual(keyStore.storedKey, "new-from-keychain",
  95. "Keychain value must not be overwritten")
  96. // UserDefaults plaintext still cleaned up
  97. XCTAssertNil(defaultsStore.storage["chadMusic.apiKey"],
  98. "UserDefaults copy should be deleted even when Keychain already has a value")
  99. XCTAssertEqual(keyStore.saveCallCount, 0,
  100. "Keychain save should NOT be called when Keychain already populated")
  101. }
  102. /// Spec edge case: "Fresh install (no UserDefaults, no Keychain) → apiKey returns nil"
  103. func testSkipsMigrationIfDefaultsEmpty() {
  104. // Both stores empty
  105. let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore)
  106. let result = credentials.apiKey
  107. XCTAssertNil(result, "apiKey should be nil when both stores are empty")
  108. XCTAssertNil(keyStore.storedKey, "Keychain should remain empty")
  109. XCTAssertEqual(keyStore.saveCallCount, 0, "No save should occur")
  110. }
  111. /// Spec edge case: "Empty string in UserDefaults → Treated as 'not set' — no migration"
  112. func testSkipsMigrationIfDefaultsHasEmptyString() {
  113. defaultsStore.storage["chadMusic.apiKey"] = ""
  114. let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore)
  115. let result = credentials.apiKey
  116. XCTAssertNil(result, "Empty string should be treated as nil")
  117. XCTAssertNil(keyStore.storedKey, "Empty string should not be migrated to Keychain")
  118. XCTAssertEqual(keyStore.saveCallCount, 0, "No save should occur for empty string")
  119. }
  120. /// Spec edge case: "Keychain access denied → Leave UserDefaults in place, no crash"
  121. func testFallbackWhenKeychainDenied() {
  122. defaultsStore.storage["chadMusic.apiKey"] = "abc"
  123. keyStore.shouldThrowOnSave = true
  124. let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore)
  125. // Should not crash — graceful fallback
  126. let result = credentials.apiKey
  127. // UserDefaults value preserved (migration failed)
  128. XCTAssertEqual(defaultsStore.storage["chadMusic.apiKey"], "abc",
  129. "UserDefaults should keep the key when Keychain save fails")
  130. // apiKey should still be accessible (either from failed Keychain or fallback)
  131. // The exact behavior depends on implementation: it may return nil from Keychain
  132. // or fall back to UserDefaults. Either way, no crash.
  133. // The key point: the app doesn't crash and the plaintext isn't lost.
  134. XCTAssertFalse(defaultsStore.removedKeys.contains("chadMusic.apiKey"),
  135. "UserDefaults key must NOT be removed if Keychain save failed")
  136. }
  137. /// Migration runs only once per instance — second access doesn't re-migrate.
  138. func testMigrationRunsOnlyOnce() {
  139. defaultsStore.storage["chadMusic.apiKey"] = "abc"
  140. let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore)
  141. // First access triggers migration
  142. _ = credentials.apiKey
  143. XCTAssertEqual(keyStore.saveCallCount, 1, "First access should trigger one save")
  144. // Manually put something back in defaults (simulating external write)
  145. defaultsStore.storage["chadMusic.apiKey"] = "should-not-migrate"
  146. // Second access should NOT re-migrate
  147. _ = credentials.apiKey
  148. XCTAssertEqual(keyStore.saveCallCount, 1,
  149. "Second access should not trigger another migration")
  150. XCTAssertEqual(keyStore.storedKey, "abc",
  151. "Keychain should still have the original migrated value")
  152. }
  153. // MARK: - Save / Load / Delete
  154. /// Spec: "Call save('xyz') → load() returns 'xyz'"
  155. func testSaveWritesToKeychain() throws {
  156. let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore)
  157. try credentials.save("xyz")
  158. XCTAssertEqual(keyStore.storedKey, "xyz", "save() should write to the key store")
  159. XCTAssertEqual(credentials.apiKey, "xyz", "apiKey should return the saved value")
  160. }
  161. /// Spec: "Save then delete → load() returns nil"
  162. func testDeleteRemovesKey() throws {
  163. let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore)
  164. try credentials.save("to-delete")
  165. XCTAssertNotNil(credentials.apiKey)
  166. credentials.delete()
  167. XCTAssertNil(keyStore.storedKey, "delete() should remove the key from the store")
  168. XCTAssertNil(credentials.apiKey, "apiKey should return nil after deletion")
  169. }
  170. /// Spec: "Keychain has '' → apiKey returns nil (empty treated as nil)"
  171. func testApiKeyNilWhenKeychainHasEmptyString() {
  172. keyStore.storedKey = ""
  173. let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore)
  174. let result = credentials.apiKey
  175. XCTAssertNil(result, "Empty string in Keychain should be treated as nil")
  176. }
  177. /// Save overwrites existing value.
  178. func testSaveOverwritesExistingKey() throws {
  179. let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore)
  180. try credentials.save("first")
  181. XCTAssertEqual(credentials.apiKey, "first")
  182. try credentials.save("second")
  183. XCTAssertEqual(credentials.apiKey, "second")
  184. XCTAssertEqual(keyStore.storedKey, "second")
  185. }
  186. /// Save propagates Keychain errors.
  187. func testSaveThrowsOnKeychainError() {
  188. keyStore.shouldThrowOnSave = true
  189. let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore)
  190. XCTAssertThrowsError(try credentials.save("fail")) { error in
  191. XCTAssertNotNil(error.localizedDescription)
  192. }
  193. }
  194. /// Unicode API keys are handled correctly.
  195. func testSaveAndLoadUnicodeKey() throws {
  196. let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore)
  197. let key = "api-key-with-ünîcödé-чëрт"
  198. try credentials.save(key)
  199. XCTAssertEqual(credentials.apiKey, key)
  200. }
  201. // MARK: - isConfigured helper (if exposed)
  202. /// ChadMusicCredentials should expose whether a key is set.
  203. func testHasKeyWhenSet() throws {
  204. let credentials = ChadMusicCredentials(keyStore: keyStore, defaultsStore: defaultsStore)
  205. XCTAssertFalse(credentials.hasKey, "hasKey should be false when no key is stored")
  206. try credentials.save("some-key")
  207. XCTAssertTrue(credentials.hasKey, "hasKey should be true after saving a key")
  208. credentials.delete()
  209. XCTAssertFalse(credentials.hasKey, "hasKey should be false after deletion")
  210. }
  211. }