import Foundation import Security import Testing import HostRegistry // 按主机存访问令牌的 store 语义(B2):read / write / clear,以及 // "移除主机必须一起移除其令牌"(Keychain 里不留孤儿密文)。 // 令牌就存在既有的 host 列表 Keychain item 里 —— 计划 §5.3 明写 // "Keychain:host 列表(含未来 authMaterial 占位)",故 remove(id:) 天然连带清除。 /// 取出某次 keychain 写入的 JSON 载荷文本(用于"载荷里不得再出现该令牌"断言)。 /// /// 必须先把 `\/` 还原成 `/`:JSONEncoder 默认转义正斜杠,而令牌字符集里恰好含 `/` —— /// 不还原的话 `contains(token) == false` 这类"无残留密文"断言会因为转义而**假通过**。 /// 令牌字符集 `[A-Za-z0-9._~+/=-]` 中只有 `/` 会被 JSON 转义,故这一步足够。 private func payloadText(_ attributes: [String: any Sendable]?) -> String { guard let data = attributes?[kSecValueData as String] as? Data else { return "" } return String(decoding: data, as: UTF8.self).replacingOccurrences(of: "\\/", with: "/") } /// 第三方 conformer:只实现 §3.3 原有三个方法,不 override 令牌方法 —— /// 钉死协议默认实现(App 层已有的自定义 HostStore 替身走的就是这条路)。 private actor MinimalHostStore: HostStore { private var hosts: [Host] init(hosts: [Host]) { self.hosts = hosts } func loadAll() async throws -> [Host] { hosts } func upsert(_ host: Host) async throws -> [Host] { hosts = hosts.contains(where: { $0.id == host.id }) ? hosts.map { $0.id == host.id ? host : $0 } : hosts + [host] return hosts } func remove(id: UUID) async throws -> [Host] { hosts = hosts.filter { $0.id != id } return hosts } } // MARK: - 写入 / 读取 / 清除(Keychain 实现) @Test("setAccessToken 后 accessToken(host:) 读回同值,且 loadAll 的 Host 带上令牌") func setAccessTokenPersistsAndReadsBack() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) let host = Fixtures.makeHost() _ = try await store.upsert(host) let token = Fixtures.makeToken() // Act let updated = try await store.setAccessToken(token, host: host.id) // Assert #expect(updated.first?.accessToken == token) #expect(try await store.accessToken(host: host.id) == token) #expect(try await store.loadAll().first?.accessToken == token) } @Test("令牌跨 store 实例存活:同一 shim 上的新 store 读回令牌") func accessTokenSurvivesAcrossStoreInstances() async throws { // Arrange let shim = FakeSecItemShim() let host = Fixtures.makeHost() let writer = KeychainHostStore(shim: shim) _ = try await writer.upsert(host) _ = try await writer.setAccessToken(Fixtures.makeToken(), host: host.id) // Act let reader = KeychainHostStore(shim: shim) // Assert #expect(try await reader.accessToken(host: host.id) == Fixtures.makeToken()) } @Test("未配置令牌的主机:accessToken(host:) 为 nil(不是错误)") func accessTokenIsNilWhenNeverSet() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) let host = Fixtures.makeHost() _ = try await store.upsert(host) // Act / Assert #expect(try await store.accessToken(host: host.id) == nil) } @Test("未知 host 的 accessToken 查询:nil(不 throw —— 只读路径宽松)") func accessTokenForUnknownHostIsNil() async throws { // Arrange let store = KeychainHostStore(shim: FakeSecItemShim()) // Act / Assert #expect(try await store.accessToken(host: UUID()) == nil) } @Test("setAccessToken(nil) 清除令牌,但主机本身保留") func setAccessTokenNilClearsTokenAndKeepsHost() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) let host = Fixtures.makeHost() _ = try await store.upsert(host) _ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) // Act let updated = try await store.setAccessToken(nil, host: host.id) // Assert #expect(updated.map(\.id) == [host.id]) #expect(updated.first?.accessToken == nil) #expect(try await store.accessToken(host: host.id) == nil) #expect(payloadText(shim.recordedUpdates.last?.attributes).contains(Fixtures.validTokenString) == false) } @Test("clearAccessToken(host:) 等价于 setAccessToken(nil, host:)") func clearAccessTokenRemovesTheToken() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) let host = Fixtures.makeHost() _ = try await store.upsert(host) _ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) // Act let updated = try await store.clearAccessToken(host: host.id) // Assert #expect(updated.first?.accessToken == nil) #expect(try await store.accessToken(host: host.id) == nil) } @Test("替换令牌:旧值不再出现在写入载荷里") func replacingTokenLeavesNoTraceOfTheOldValue() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) let host = Fixtures.makeHost() _ = try await store.upsert(host) _ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) // Act let updated = try await store.setAccessToken( Fixtures.makeToken(Fixtures.otherValidTokenString), host: host.id ) // Assert #expect(updated.first?.accessToken?.rawValue == Fixtures.otherValidTokenString) let payload = payloadText(shim.recordedUpdates.last?.attributes) #expect(payload.contains(Fixtures.otherValidTokenString)) #expect(payload.contains(Fixtures.validTokenString) == false) } @Test("重复写同一令牌:显式 no-op,零额外持久化调用") func settingTheSameTokenTwiceIsAnExplicitNoOp() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) let host = Fixtures.makeHost() _ = try await store.upsert(host) _ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) let addsBefore = shim.recordedAdds.count let updatesBefore = shim.recordedUpdates.count // Act let updated = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) // Assert #expect(updated.first?.accessToken == Fixtures.makeToken()) #expect(shim.recordedAdds.count == addsBefore) #expect(shim.recordedUpdates.count == updatesBefore) } @Test("给未知 host 写令牌:抛 .unknownHost,零持久化(绝不静默丢弃)") func setAccessTokenForUnknownHostThrowsAndPersistsNothing() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) _ = try await store.upsert(Fixtures.makeHost()) let addsBefore = shim.recordedAdds.count let unknownId = UUID() // Act / Assert await #expect(throws: AccessTokenError.unknownHost(unknownId)) { _ = try await store.setAccessToken(Fixtures.makeToken(), host: unknownId) } #expect(shim.recordedAdds.count == addsBefore) #expect(shim.recordedUpdates.isEmpty) #expect(shim.recordedDeletes.isEmpty) } // MARK: - 移除主机连带清除令牌(无孤儿密文) @Test("remove 最后一个主机:整个 keychain item 被删,令牌随之消失") func removingLastHostDeletesTheItemAndItsToken() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) let host = Fixtures.makeHost() _ = try await store.upsert(host) _ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) // Act let updated = try await store.remove(id: host.id) // Assert #expect(updated.isEmpty) #expect(shim.recordedDeletes.count == 1) #expect(try await store.accessToken(host: host.id) == nil) #expect(try await KeychainHostStore(shim: shim).loadAll().isEmpty) } @Test("remove 其中一个主机:新载荷不含其令牌,另一主机的令牌完好") func removingOneHostStripsOnlyItsTokenFromThePayload() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) let doomed = Fixtures.makeHost(name: "doomed") let keeper = Fixtures.makeHost(name: "keeper", urlString: "https://mac.ts.net") _ = try await store.upsert(doomed) _ = try await store.upsert(keeper) _ = try await store.setAccessToken(Fixtures.makeToken(), host: doomed.id) _ = try await store.setAccessToken( Fixtures.makeToken(Fixtures.otherValidTokenString), host: keeper.id ) // Act let updated = try await store.remove(id: doomed.id) // Assert #expect(updated.map(\.id) == [keeper.id]) let payload = payloadText(shim.recordedUpdates.last?.attributes) #expect(payload.contains(Fixtures.validTokenString) == false) #expect(payload.contains(Fixtures.otherValidTokenString)) #expect(try await store.accessToken(host: keeper.id)?.rawValue == Fixtures.otherValidTokenString) } // MARK: - §5.3 属性字典不因令牌而改变 @Test("§5.3 带令牌写入时 add 属性字典仍是策略集:6 键、无 synchronizable、accessible 重申") func storingATokenDoesNotWeakenTheKeychainPolicy() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim, service: "svc.test", account: "acct.test") let host = Fixtures.makeHost().withAccessToken(Fixtures.makeToken()) // Act _ = try await store.upsert(host) // Assert let attrs = try #require(shim.recordedAdds.first) #expect(attrs[kSecUseDataProtectionKeychain as String] as? Bool == true) #expect(attrs[kSecAttrAccessible as String] as? String == kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String) #expect(attrs[kSecAttrSynchronizable as String] == nil) #expect(attrs.count == 6) } // MARK: - 实现间契约对等 @Test("InMemoryHostStore:read/write/clear 与 Keychain 实现语义一致") func inMemoryStoreHasTheSameAccessTokenSemantics() async throws { // Arrange let host = Fixtures.makeHost() let store = InMemoryHostStore(hosts: [host]) let token = Fixtures.makeToken() // Act let afterSet = try await store.setAccessToken(token, host: host.id) let read = try await store.accessToken(host: host.id) let afterClear = try await store.clearAccessToken(host: host.id) // Assert #expect(afterSet.first?.accessToken == token) #expect(read == token) #expect(afterClear.first?.accessToken == nil) #expect(try await store.accessToken(host: host.id) == nil) } @Test("InMemoryHostStore:未知 host 写令牌同样抛 .unknownHost") func inMemoryStoreRejectsUnknownHostToo() async { // Arrange let store = InMemoryHostStore() let unknownId = UUID() // Act / Assert await #expect(throws: AccessTokenError.unknownHost(unknownId)) { _ = try await store.setAccessToken(Fixtures.makeToken(), host: unknownId) } } @Test("InMemoryHostStore:remove 主机 → 令牌一并消失") func inMemoryStoreRemovingHostDropsItsToken() async throws { // Arrange let host = Fixtures.makeHost() let store = InMemoryHostStore(hosts: [host]) _ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) // Act _ = try await store.remove(id: host.id) // Assert #expect(try await store.accessToken(host: host.id) == nil) } @Test("协议默认实现:只实现原三方法的 conformer 也能 read/write/clear") func protocolDefaultImplementationsWorkForMinimalConformers() async throws { // Arrange let host = Fixtures.makeHost() let store = MinimalHostStore(hosts: [host]) let token = Fixtures.makeToken() // Act let afterSet = try await store.setAccessToken(token, host: host.id) let read = try await store.accessToken(host: host.id) let afterClear = try await store.clearAccessToken(host: host.id) // Assert #expect(afterSet.first?.accessToken == token) #expect(read == token) #expect(afterClear.first?.accessToken == nil) } @Test("协议默认实现:未知 host 写令牌抛 .unknownHost") func protocolDefaultImplementationRejectsUnknownHost() async { // Arrange let store = MinimalHostStore(hosts: []) let unknownId = UUID() // Act / Assert await #expect(throws: AccessTokenError.unknownHost(unknownId)) { _ = try await store.setAccessToken(Fixtures.makeToken(), host: unknownId) } }