Files
web-terminal/ios/Packages/HostRegistry/Tests/HostRegistryTests/HostAccessTokenStoreTests.swift
Yaojia Wang 850531fd07 feat(ios): access-token support + git-panel endpoints across the package layer
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.
2026-07-30 12:45:26 +02:00

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)
}
}