Skip to content

Commit ad41ee7

Browse files
committed
Mock keychain access during tests
1 parent f6dc847 commit ad41ee7

File tree

3 files changed

+96
-1
lines changed

3 files changed

+96
-1
lines changed

TaskMenu/Services/KeychainService.swift

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,65 @@ protocol KeychainServiceProtocol: Sendable {
1717
func deleteAll() throws
1818
}
1919

20+
private final class TestKeychainStore: @unchecked Sendable {
21+
static let shared = TestKeychainStore()
22+
23+
private let lock = NSLock()
24+
private var storage: [String: [String: Data]] = [:]
25+
26+
func save(service: String, key: String, data: Data) {
27+
lock.lock()
28+
defer { lock.unlock() }
29+
30+
var serviceStorage = storage[service] ?? [:]
31+
serviceStorage[key] = data
32+
storage[service] = serviceStorage
33+
}
34+
35+
func read(service: String, key: String) -> Data? {
36+
lock.lock()
37+
defer { lock.unlock() }
38+
39+
return storage[service]?[key]
40+
}
41+
42+
func delete(service: String, key: String) {
43+
lock.lock()
44+
defer { lock.unlock() }
45+
46+
storage[service]?[key] = nil
47+
if storage[service]?.isEmpty == true {
48+
storage[service] = nil
49+
}
50+
}
51+
52+
func deleteAll(service: String) {
53+
lock.lock()
54+
defer { lock.unlock() }
55+
56+
storage[service] = nil
57+
}
58+
}
59+
2060
struct KeychainService: KeychainServiceProtocol, Sendable {
2161
let service: String
62+
private let testStore: TestKeychainStore?
2263

23-
init(service: String = Constants.Keychain.service) {
64+
init(
65+
service: String = Constants.Keychain.service,
66+
environment: [String: String] = ProcessInfo.processInfo.environment
67+
) {
2468
self.service = service
69+
// Hosted unit tests launch the app target, so avoid touching the login keychain entirely.
70+
self.testStore = environment["XCTestConfigurationFilePath"] == nil ? nil : .shared
2571
}
2672

2773
func save(key: String, data: Data) throws {
74+
if let testStore {
75+
testStore.save(service: service, key: key, data: data)
76+
return
77+
}
78+
2879
// Delete existing item first
2980
let deleteQuery: [String: Any] = [
3081
kSecClass as String: kSecClassGenericPassword,
@@ -55,6 +106,10 @@ struct KeychainService: KeychainServiceProtocol, Sendable {
55106
}
56107

57108
func read(key: String) throws -> Data? {
109+
if let testStore {
110+
return testStore.read(service: service, key: key)
111+
}
112+
58113
let query: [String: Any] = [
59114
kSecClass as String: kSecClassGenericPassword,
60115
kSecAttrService as String: service,
@@ -83,6 +138,11 @@ struct KeychainService: KeychainServiceProtocol, Sendable {
83138
}
84139

85140
func delete(key: String) throws {
141+
if let testStore {
142+
testStore.delete(service: service, key: key)
143+
return
144+
}
145+
86146
let query: [String: Any] = [
87147
kSecClass as String: kSecClassGenericPassword,
88148
kSecAttrService as String: service,
@@ -96,6 +156,11 @@ struct KeychainService: KeychainServiceProtocol, Sendable {
96156
}
97157

98158
func deleteAll() throws {
159+
if let testStore {
160+
testStore.deleteAll(service: service)
161+
return
162+
}
163+
99164
// Delete known keys individually for reliability across macOS versions
100165
for key in [Constants.Keychain.accessTokenKey, Constants.Keychain.refreshTokenKey, Constants.Keychain.expirationKey] {
101166
try delete(key: key)

TaskMenuTests/KeychainServiceTests.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import XCTest
55
/// Avoids real macOS Keychain access (and password prompts) during tests.
66
final class KeychainServiceTests: XCTestCase {
77
private var keychain: InMemoryKeychainService!
8+
private let testEnvironment = ["XCTestConfigurationFilePath": "/tmp/TaskMenuTests.xctestconfiguration"]
89

910
override func setUp() {
1011
super.setUp()
@@ -56,4 +57,31 @@ final class KeychainServiceTests: XCTestCase {
5657
XCTAssertNil(try keychain.readString(key: Constants.Keychain.refreshTokenKey))
5758
XCTAssertNil(try keychain.readString(key: Constants.Keychain.expirationKey))
5859
}
60+
61+
func testProductionKeychainUsesInMemoryStoreUnderXCTest() throws {
62+
let keychain = KeychainService(
63+
service: "com.taskmenu.test.production.\(UUID().uuidString)",
64+
environment: testEnvironment
65+
)
66+
67+
try keychain.save(key: "token", string: "abc123")
68+
69+
XCTAssertEqual(try keychain.readString(key: "token"), "abc123")
70+
}
71+
72+
func testProductionKeychainInMemoryStoreIsScopedByService() throws {
73+
let keychainA = KeychainService(
74+
service: "com.taskmenu.test.production.a.\(UUID().uuidString)",
75+
environment: testEnvironment
76+
)
77+
let keychainB = KeychainService(
78+
service: "com.taskmenu.test.production.b.\(UUID().uuidString)",
79+
environment: testEnvironment
80+
)
81+
82+
try keychainA.save(key: "token", string: "value-a")
83+
84+
XCTAssertEqual(try keychainA.readString(key: "token"), "value-a")
85+
XCTAssertNil(try keychainB.readString(key: "token"))
86+
}
5987
}

project.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ targets:
4747
base:
4848
PRODUCT_BUNDLE_IDENTIFIER: com.taskmenu.TaskMenuTests
4949
GENERATE_INFOPLIST_FILE: YES
50+
BUNDLE_LOADER: "$(TEST_HOST)"
51+
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/TaskMenu.app/Contents/MacOS/TaskMenu"
5052
CODE_SIGN_IDENTITY: "47081BEFF0F575643E99369B44CBAC87BBCC85E6" # Apple Development: Jia Tan
5153
CODE_SIGN_STYLE: Manual
5254
DEVELOPMENT_TEAM: ZW5U6862Q8

0 commit comments

Comments
 (0)