APIClient (77 -> 125 tests, coverage 92.22%): POST /auth probe with the four distinct outcomes from the frozen contract, Cookie/Accept landed at the same single header choke point that already enforces Origin-iff-G, plus the whole project-ops surface the server has had since late July and iOS consumed none of: /projects/log, /projects/pr, /projects/worktree/state, git stage/commit/push/ fetch, worktree create/remove/prune, GET /sessions, follow-up queue. HostRegistry (30 -> 73 tests, 88.12% -> 92.49%): per-host token in the Keychain under the existing SecItemShim conventions (device-only, never synchronizable), charset/length validated at the boundary, old token-less records still decode. SessionCore (93 -> 108 tests, 96.74%): the WS upgrade carries the cookie from the same point that writes Origin, and a 401 handshake is a terminal .unauthorized -- never entering the backoff loop, since retrying one wrong shared token is a brute-force generator against the server's 10/min limiter.
338 lines
12 KiB
Swift
338 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|