KeychainMigrationTests.swift 10 KB

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