498f407585
Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
292 lines
12 KiB
Swift
292 lines
12 KiB
Swift
// KeychainTests.swift
|
|
// OSGKeyboard · Tests
|
|
//
|
|
// Unit tests for the Keychain helper and the one-time migration from
|
|
// the legacy UserDefaults slot. The Keychain is process-global in the
|
|
// simulator, so every test cleans up after itself.
|
|
|
|
@testable import OSGKeyboardShared
|
|
import XCTest
|
|
|
|
final class KeychainTests: XCTestCase {
|
|
|
|
override func setUpWithError() throws {
|
|
Keychain.resetTestMemoryStore()
|
|
try? Keychain.deleteAPIKey()
|
|
try? Keychain.deleteLegacyAPIKey()
|
|
try? Keychain.deleteAPIKey(for: "qwen")
|
|
try? Keychain.deleteAPIKey(for: "openai")
|
|
try? Keychain.deleteAPIKey(for: "deepseek")
|
|
try? Keychain.deleteAPIKey(for: "qwen", useICloudSync: true)
|
|
try? Keychain.deleteAPIKey(for: "openai", useICloudSync: true)
|
|
try? Keychain.deleteAPIKey(for: "deepseek", useICloudSync: true)
|
|
try? Keychain.deleteASRAPIKey(for: "openai", useICloudSync: true)
|
|
}
|
|
|
|
override func tearDownWithError() throws {
|
|
try? Keychain.deleteAPIKey()
|
|
try? Keychain.deleteLegacyAPIKey()
|
|
try? Keychain.deleteAPIKey(for: "qwen")
|
|
try? Keychain.deleteAPIKey(for: "openai")
|
|
try? Keychain.deleteAPIKey(for: "deepseek")
|
|
try? Keychain.deleteAPIKey(for: "qwen", useICloudSync: true)
|
|
try? Keychain.deleteAPIKey(for: "openai", useICloudSync: true)
|
|
try? Keychain.deleteAPIKey(for: "deepseek", useICloudSync: true)
|
|
try? Keychain.deleteASRAPIKey(for: "openai", useICloudSync: true)
|
|
Keychain.resetTestMemoryStore()
|
|
}
|
|
|
|
// MARK: - Round-trip
|
|
|
|
func testRoundTripWriteReadDelete() throws {
|
|
// Nothing stored yet.
|
|
XCTAssertNil(Keychain.apiKey(), "Keychain should start empty after cleanup")
|
|
|
|
// Write → read.
|
|
try Keychain.setAPIKey("sk-roundtrip-1")
|
|
XCTAssertEqual(Keychain.apiKey(), "sk-roundtrip-1")
|
|
|
|
// Overwrite → read new value (no orphan entries).
|
|
try Keychain.setAPIKey("sk-roundtrip-2")
|
|
XCTAssertEqual(Keychain.apiKey(), "sk-roundtrip-2")
|
|
|
|
// Delete → read nil.
|
|
try Keychain.deleteAPIKey()
|
|
XCTAssertNil(Keychain.apiKey())
|
|
}
|
|
|
|
/// Empty string must DELETE the entry, not store an empty placeholder.
|
|
/// Otherwise `Keychain.apiKey() ?? ""` would always return "" for any
|
|
/// missing item, and the LLM client couldn't tell "stored but empty"
|
|
/// (user error) from "not stored" (onboarding state).
|
|
func testEmptyStringDeletes() throws {
|
|
try Keychain.setAPIKey("sk-temp")
|
|
XCTAssertEqual(Keychain.apiKey(), "sk-temp")
|
|
|
|
try Keychain.setAPIKey("")
|
|
XCTAssertNil(Keychain.apiKey(), "Empty write must delete, not store empty string")
|
|
}
|
|
|
|
func testProviderScopedKeysDoNotMix() throws {
|
|
try Keychain.setAPIKey("sk-openai", for: "openai")
|
|
try Keychain.setAPIKey("sk-qwen", for: "qwen")
|
|
XCTAssertEqual(Keychain.apiKey(for: "openai"), "sk-openai")
|
|
XCTAssertEqual(Keychain.apiKey(for: "qwen"), "sk-qwen")
|
|
}
|
|
|
|
func testSynchronizedWritesVerifyBeforeRemovingLocalCopies() throws {
|
|
try Keychain.setAPIKey("llm-local", for: "openai")
|
|
try Keychain.setASRAPIKey("asr-local", for: "openai")
|
|
|
|
try Keychain.setAPIKey("llm-synced", for: "openai", useICloudSync: true)
|
|
try Keychain.setASRAPIKey("asr-synced", for: "openai", useICloudSync: true)
|
|
|
|
XCTAssertEqual(Keychain.apiKey(for: "openai", preferICloudSync: false), "llm-synced")
|
|
XCTAssertEqual(Keychain.asrApiKey(for: "openai", preferICloudSync: false), "asr-synced")
|
|
XCTAssertEqual(Keychain.apiKey(for: "openai", preferICloudSync: true), "llm-synced")
|
|
XCTAssertEqual(Keychain.asrApiKey(for: "openai", preferICloudSync: true), "asr-synced")
|
|
}
|
|
|
|
/// Deleting a non-existent entry must be a no-op (idempotent), not an
|
|
/// error — callers like `ProviderConfig.reset()` invoke it
|
|
/// unconditionally.
|
|
func testDeleteIsIdempotent() throws {
|
|
// No prior write — should not throw.
|
|
XCTAssertNoThrow(try Keychain.deleteAPIKey())
|
|
XCTAssertNoThrow(try Keychain.deleteAPIKey())
|
|
}
|
|
|
|
// MARK: - Safe credential migration
|
|
|
|
func testMirroredWriteFailureRestoresBothPreviousValues() {
|
|
enum TestFailure: Error { case unavailable }
|
|
var local: String? = "old-local"
|
|
var synchronizable: String? = "old-sync"
|
|
|
|
XCTAssertThrowsError(
|
|
try Keychain.writeMirroredCredential(
|
|
"new-value",
|
|
readLocal: { local },
|
|
readSynchronizable: { synchronizable },
|
|
writeLocal: { local = $0 },
|
|
writeSynchronizable: {
|
|
synchronizable = $0
|
|
throw TestFailure.unavailable
|
|
},
|
|
deleteLocal: { local = nil },
|
|
deleteSynchronizable: { synchronizable = nil }
|
|
)
|
|
) { error in
|
|
XCTAssertEqual(error as? Keychain.CredentialMigrationError, .unavailable)
|
|
}
|
|
XCTAssertEqual(local, "old-local")
|
|
XCTAssertEqual(synchronizable, "old-sync")
|
|
}
|
|
|
|
func testCopyTransactionWriteFailureKeepsSource() {
|
|
enum TestFailure: Error { case unavailable }
|
|
var source: String? = "credential"
|
|
var destination: String?
|
|
|
|
XCTAssertThrowsError(
|
|
try Keychain.copyCredentialTransaction(
|
|
source: { source },
|
|
destination: { destination },
|
|
writeDestination: { _ in throw TestFailure.unavailable },
|
|
readbackDestination: { destination },
|
|
deleteSource: { source = nil }
|
|
)
|
|
) { error in
|
|
XCTAssertEqual(error as? Keychain.CredentialMigrationError, .unavailable)
|
|
}
|
|
XCTAssertNotNil(source)
|
|
XCTAssertNil(destination)
|
|
}
|
|
|
|
func testCopyTransactionRejectsDifferentDestinationWithoutDeletingSource() {
|
|
var source: String? = "source-credential"
|
|
var destination: String? = "destination-credential"
|
|
var didWrite = false
|
|
|
|
XCTAssertThrowsError(
|
|
try Keychain.copyCredentialTransaction(
|
|
source: { source },
|
|
destination: { destination },
|
|
writeDestination: { value in
|
|
didWrite = true
|
|
destination = value
|
|
},
|
|
readbackDestination: { destination },
|
|
deleteSource: { source = nil }
|
|
)
|
|
) { error in
|
|
XCTAssertEqual(error as? Keychain.CredentialMigrationError, .conflict)
|
|
}
|
|
XCTAssertFalse(didWrite)
|
|
XCTAssertNotNil(source)
|
|
XCTAssertNotNil(destination)
|
|
}
|
|
|
|
func testCopyTransactionVerificationFailureKeepsSource() {
|
|
var source: String? = "credential"
|
|
var destination: String?
|
|
|
|
XCTAssertThrowsError(
|
|
try Keychain.copyCredentialTransaction(
|
|
source: { source },
|
|
destination: { destination },
|
|
writeDestination: { destination = $0 },
|
|
readbackDestination: { "different-readback" },
|
|
deleteSource: { source = nil }
|
|
)
|
|
) { error in
|
|
XCTAssertEqual(error as? Keychain.CredentialMigrationError, .verificationFailed)
|
|
}
|
|
XCTAssertNotNil(source)
|
|
}
|
|
|
|
func testCopyTransactionDeletesSourceOnlyAfterVerifiedReadback() throws {
|
|
var source: String? = "credential"
|
|
var destination: String?
|
|
|
|
try Keychain.copyCredentialTransaction(
|
|
source: { source },
|
|
destination: { destination },
|
|
writeDestination: { destination = $0 },
|
|
readbackDestination: { destination },
|
|
deleteSource: { source = nil }
|
|
)
|
|
|
|
XCTAssertNil(source)
|
|
XCTAssertNotNil(destination)
|
|
}
|
|
|
|
func testLocalToICloudMigrationCoversLLMAndASRKeys() throws {
|
|
try Keychain.setAPIKey("llm-credential", for: "openai", useICloudSync: false)
|
|
try Keychain.setASRAPIKey("asr-credential", for: "openai", useICloudSync: false)
|
|
|
|
try Keychain.migrateLocalKeysToICloud()
|
|
|
|
XCTAssertEqual(Keychain.apiKey(for: "openai", preferICloudSync: false), "llm-credential")
|
|
XCTAssertEqual(Keychain.asrApiKey(for: "openai", preferICloudSync: false), "asr-credential")
|
|
XCTAssertNotNil(Keychain.apiKey(for: "openai", preferICloudSync: true))
|
|
XCTAssertNotNil(Keychain.asrApiKey(for: "openai", preferICloudSync: true))
|
|
}
|
|
|
|
// MARK: - AppGroupStore reading
|
|
|
|
/// `AppGroupStore.apiKey` must consult the Keychain (not UserDefaults),
|
|
/// otherwise the keyboard extension never sees the key set in the
|
|
/// host app's Settings UI.
|
|
func testAppGroupStoreReadsFromKeychain() throws {
|
|
let suiteName = "group.com.osgkeyboard.shared.tests.kc.\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: suiteName)!
|
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
|
|
|
try Keychain.setAPIKey("sk-from-store")
|
|
let store = AppGroupStore(defaults: defaults)
|
|
XCTAssertEqual(store.apiKey, "sk-from-store")
|
|
}
|
|
|
|
// MARK: - Legacy migration
|
|
|
|
/// Pre-Keychain versions of the app stored the API key in
|
|
/// `config.apiKey` (UserDefaults). On first init after upgrade, that
|
|
/// value must be moved to the Keychain and removed from UserDefaults
|
|
/// — otherwise a fresh install in a clean simulator would inherit the
|
|
/// stale plaintext value.
|
|
func testLegacyUserDefaultsKeyIsMigratedToKeychain() {
|
|
let suiteName = "group.com.osgkeyboard.shared.tests.migration.\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: suiteName)!
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
|
|
|
// Pre-upgrade state: apiKey lives in UserDefaults.
|
|
defaults.set("sk-legacy-plaintext", forKey: "config.apiKey")
|
|
|
|
// First init after upgrade: triggers the one-shot migration.
|
|
let config = ProviderConfig(defaults: defaults)
|
|
|
|
XCTAssertEqual(config.apiKey, "sk-legacy-plaintext",
|
|
"Migrated key must surface through ProviderConfig")
|
|
// Migration follows `settingsICloudSyncEnabled` (default on) into the
|
|
// synchronizable Keychain slot — read with the same preference.
|
|
XCTAssertEqual(
|
|
Keychain.apiKey(
|
|
for: AppGroupConfiguration.defaultPolishProviderId,
|
|
preferICloudSync: true
|
|
),
|
|
"sk-legacy-plaintext",
|
|
"Legacy value must land in Keychain after migration"
|
|
)
|
|
XCTAssertNil(defaults.string(forKey: "config.apiKey"),
|
|
"Legacy UserDefaults entry must be cleared after migration")
|
|
|
|
// Second init (no legacy value left) reads from Keychain only.
|
|
let config2 = ProviderConfig(defaults: defaults)
|
|
XCTAssertEqual(config2.apiKey, "sk-legacy-plaintext")
|
|
}
|
|
|
|
/// If both the Keychain and the legacy UserDefaults slot have a value
|
|
/// — possible on a downgrade or a torn update — Keychain wins. We
|
|
/// don't delete the UserDefaults value, but the running app reads from
|
|
/// the Keychain only.
|
|
func testKeychainWinsOverLegacyWhenBothPresent() {
|
|
let suiteName = "group.com.osgkeyboard.shared.tests.migration2.\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: suiteName)!
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
|
|
|
// Set both.
|
|
try? Keychain.setAPIKey("sk-new")
|
|
defaults.set("sk-old", forKey: "config.apiKey")
|
|
|
|
let config = ProviderConfig(defaults: defaults)
|
|
XCTAssertEqual(config.apiKey, "sk-new", "Keychain value must take precedence")
|
|
// Migration only fires when Keychain was nil — we did NOT delete
|
|
// the legacy entry here. That's fine because Keychain is the
|
|
// source of truth from now on; the legacy value is dead weight
|
|
// but not incorrect.
|
|
}
|
|
}
|