9f308fadd2
Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
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.
|
|
|
|
import XCTest
|
|
@testable import OSGKeyboardShared
|
|
|
|
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.
|
|
}
|
|
}
|