ChadMusicCredentials.swift 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. import Foundation
  2. // MARK: - Protocols for Dependency Injection
  3. /// Abstraction over Keychain storage for the API key.
  4. /// Production: KeychainService conforms. Tests: InMemoryKeyStore.
  5. protocol KeyStoreProtocol {
  6. func save(_ key: String) throws
  7. func load() -> String?
  8. func delete()
  9. }
  10. /// Abstraction over UserDefaults for reading/removing the legacy API key.
  11. /// Production: UserDefaultsStore conforms. Tests: InMemoryDefaultsStore.
  12. protocol DefaultsStoreProtocol {
  13. func string(forKey key: String) -> String?
  14. func removeObject(forKey key: String)
  15. }
  16. // MARK: - Production Store Adapters
  17. /// Adapts KeychainService (static enum) to KeyStoreProtocol.
  18. struct KeychainKeyStore: KeyStoreProtocol {
  19. func save(_ key: String) throws {
  20. try KeychainService.saveAPIKey(key)
  21. }
  22. func load() -> String? {
  23. KeychainService.loadAPIKey()
  24. }
  25. func delete() {
  26. KeychainService.deleteAPIKey()
  27. }
  28. }
  29. /// Adapts UserDefaults to DefaultsStoreProtocol.
  30. struct UserDefaultsStore: DefaultsStoreProtocol {
  31. private let defaults: UserDefaults
  32. init(defaults: UserDefaults = .standard) {
  33. self.defaults = defaults
  34. }
  35. func string(forKey key: String) -> String? {
  36. defaults.string(forKey: key)
  37. }
  38. func removeObject(forKey key: String) {
  39. defaults.removeObject(forKey: key)
  40. }
  41. }
  42. // MARK: - ChadMusicCredentials
  43. /// Single source of truth for the Chad Music API key.
  44. /// Reads from Keychain. On first access, migrates any existing
  45. /// UserDefaults value to Keychain and deletes the plaintext copy.
  46. @MainActor
  47. final class ChadMusicCredentials {
  48. /// Shared singleton using real Keychain + UserDefaults for production.
  49. static let shared = ChadMusicCredentials()
  50. private static let userDefaultsKey = "chadMusic.apiKey"
  51. private let keyStore: any KeyStoreProtocol
  52. private let defaultsStore: any DefaultsStoreProtocol
  53. private var hasMigrated = false
  54. /// DI initializer — tests inject in-memory mocks.
  55. init(
  56. keyStore: any KeyStoreProtocol = KeychainKeyStore(),
  57. defaultsStore: any DefaultsStoreProtocol = UserDefaultsStore()
  58. ) {
  59. self.keyStore = keyStore
  60. self.defaultsStore = defaultsStore
  61. }
  62. /// The current API key, or nil if not set.
  63. /// First call triggers one-time migration from UserDefaults → Keychain.
  64. var apiKey: String? {
  65. migrateIfNeeded()
  66. let key = keyStore.load()
  67. return (key?.isEmpty ?? true) ? nil : key
  68. }
  69. /// Whether an API key is currently stored.
  70. var hasKey: Bool {
  71. apiKey != nil
  72. }
  73. /// Save a new API key to Keychain.
  74. func save(_ key: String) throws {
  75. try keyStore.save(key)
  76. }
  77. /// Delete the API key from Keychain.
  78. func delete() {
  79. keyStore.delete()
  80. }
  81. // MARK: - Migration
  82. /// One-time migration: if UserDefaults has a key, move it to Keychain.
  83. private func migrateIfNeeded() {
  84. guard !hasMigrated else { return }
  85. hasMigrated = true
  86. guard let existingKey = defaultsStore.string(forKey: Self.userDefaultsKey),
  87. !existingKey.isEmpty else { return }
  88. // Only migrate if Keychain is empty (don't overwrite a Keychain value)
  89. if keyStore.load() == nil {
  90. do {
  91. try keyStore.save(existingKey)
  92. defaultsStore.removeObject(forKey: Self.userDefaultsKey)
  93. } catch {
  94. // Keychain denied — leave UserDefaults in place as fallback.
  95. print("[ChadMusicCredentials] Migration failed: \(error). Leaving UserDefaults in place.")
  96. }
  97. } else {
  98. // Keychain already has a value — just delete the plaintext copy
  99. defaultsStore.removeObject(forKey: Self.userDefaultsKey)
  100. }
  101. }
  102. }