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.
This commit is contained in:
Yaojia Wang
2026-07-30 12:45:26 +02:00
parent c4f8b5b47f
commit 850531fd07
33 changed files with 4191 additions and 106 deletions

View File

@@ -0,0 +1,210 @@
import Foundation
import Testing
import HostRegistry
// AccessToken: + ""
// : doc §1.1 `[A-Za-z0-9._~+/=-]` 16512
// ( src/config.ts:178 WEBTERM_TOKEN_RE );,
// / URL / (§5.3)
// MARK: - ()
@Test("合法令牌:覆盖全字符集 → 构造成功且 rawValue 逐字保真")
func validTokenPreservesRawValueVerbatim() throws {
// Arrange
let raw = Fixtures.validTokenString
// Act
let token = try AccessToken(validating: raw)
// Assert
#expect(token.rawValue == raw)
}
@Test("非法字符 → .invalidCharacters", arguments: [
"abcdefghijklmno p", //
"abcdefghijklmnop!", //
"abcdefghijklmnop%", // ( URL cookie-safe )
"abcdefghijklmnop,", // (cookie )
"abcdefghijklmnop;", // (cookie )
"abcdefghijklmnop令", // ASCII
])
func invalidCharactersAreRejectedWithTypedError(raw: String) {
// Act / Assert
#expect(throws: AccessTokenError.invalidCharacters) {
_ = try AccessToken(validating: raw)
}
}
@Test("非法字符错误不携带令牌内容(错误对象本身不得成为泄漏面)")
func invalidCharactersErrorCarriesNoTokenPayload() {
// Arrange
let secretish = "supersecret-tail!!!!!!"
// Act
var captured = ""
do {
_ = try AccessToken(validating: secretish)
Issue.record("期望抛错,实际构造成功")
} catch {
captured = String(describing: error) + String(reflecting: error)
}
// Assert
#expect(captured.isEmpty == false)
#expect(captured.contains("supersecret") == false)
}
// MARK: - ()
@Test("长度下限:15 → .tooShort(15)")
func tokenBelowMinimumLengthIsRejected() {
// Act / Assert
#expect(throws: AccessTokenError.tooShort(length: 15)) {
_ = try AccessToken(validating: Fixtures.tokenString(length: 15))
}
}
@Test("长度下限:恰好 16 → 通过")
func tokenAtMinimumLengthIsAccepted() throws {
// Act
let token = try AccessToken(validating: Fixtures.tokenString(length: AccessToken.minLength))
// Assert
#expect(token.rawValue.count == 16)
}
@Test("长度上限:恰好 512 → 通过")
func tokenAtMaximumLengthIsAccepted() throws {
// Act
let token = try AccessToken(validating: Fixtures.tokenString(length: AccessToken.maxLength))
// Assert
#expect(token.rawValue.count == 512)
}
@Test("长度上限:513 → .tooLong(513)")
func tokenAboveMaximumLengthIsRejected() {
// Act / Assert
#expect(throws: AccessTokenError.tooLong(length: 513)) {
_ = try AccessToken(validating: Fixtures.tokenString(length: 513))
}
}
@Test("空串 → .tooShort(0)(不是 invalidCharacters:先判长度更贴近用户话术)")
func emptyTokenIsRejectedAsTooShort() {
// Act / Assert
#expect(throws: AccessTokenError.tooShort(length: 0)) {
_ = try AccessToken(validating: "")
}
}
// MARK: - UX:
@Test("首尾空白/换行被裁剪后再校验:合法令牌不受影响(空白永非合法令牌字符)")
func surroundingWhitespaceIsTrimmedBeforeValidation() throws {
// Arrange
let pasted = " \n\t" + Fixtures.validTokenString + " \n"
// Act
let token = try AccessToken(validating: pasted)
// Assert
#expect(token.rawValue == Fixtures.validTokenString)
}
@Test("中间空白不被裁剪:仍是 .invalidCharacters(不做静默规范化)")
func interiorWhitespaceIsStillInvalid() {
// Act / Assert
#expect(throws: AccessTokenError.invalidCharacters) {
_ = try AccessToken(validating: "abcdefgh ijklmnop")
}
}
@Test("init?(rawValue:) 宽松入口:非法 → nil,不抛")
func lenientRawValueInitReturnsNilForInvalidToken() {
// Act / Assert
#expect(AccessToken(rawValue: "too-short") == nil)
#expect(AccessToken(rawValue: Fixtures.validTokenString)?.rawValue == Fixtures.validTokenString)
}
// MARK: - §5.3
@Test("§5.3 description/debugDescription 不含令牌,只给长度")
func stringRepresentationsRedactTheToken() {
// Arrange
let token = Fixtures.makeToken()
// Act
let description = token.description
let debugDescription = token.debugDescription
// Assert
#expect(description.contains(Fixtures.validTokenString) == false)
#expect(debugDescription.contains(Fixtures.validTokenString) == false)
#expect(description.contains("redacted"))
#expect(description.contains("\(Fixtures.validTokenString.count)"))
}
@Test("§5.3 String(describing:)/String(reflecting:) 也不泄漏(插值即日志的默认路径)")
func interpolationPathsRedactTheToken() {
// Arrange
let token = Fixtures.makeToken()
// Act
let interpolated = "token=\(token)"
// Assert
#expect(interpolated.contains(Fixtures.validTokenString) == false)
#expect(String(describing: token).contains(Fixtures.validTokenString) == false)
#expect(String(reflecting: token).contains(Fixtures.validTokenString) == false)
}
@Test("§5.3 Mirror/dump 反射不泄漏令牌(崩溃报告与 dump 是常见旁路)")
func reflectionDoesNotExposeTheToken() {
// Arrange
let token = Fixtures.makeToken()
// Act
let children = Mirror(reflecting: token).children.map { String(describing: $0.value) }
var dumped = ""
dump(token, to: &dumped)
// Assert
#expect(children.contains(where: { $0.contains(Fixtures.validTokenString) }) == false)
#expect(dumped.contains(Fixtures.validTokenString) == false)
}
@Test("§5.3 Host 的字符串表示不含令牌,只标注是否已配置")
func hostStringRepresentationRedactsTheToken() {
// Arrange
let host = Fixtures.makeHost().withAccessToken(Fixtures.makeToken())
// Act
let described = String(describing: host)
let reflected = String(reflecting: host)
var dumped = ""
dump(host, to: &dumped)
// Assert
#expect(described.contains(Fixtures.validTokenString) == false)
#expect(reflected.contains(Fixtures.validTokenString) == false)
#expect(dumped.contains(Fixtures.validTokenString) == false)
#expect(described.contains("hasAccessToken: true"))
#expect(String(describing: Fixtures.makeHost()).contains("hasAccessToken: false"))
}
// MARK: -
@Test("Equatable/Hashable:同值相等且同 hash,异值不等")
func tokenValueSemanticsAreByRawValue() {
// Arrange
let a = Fixtures.makeToken()
let b = Fixtures.makeToken()
let other = Fixtures.makeToken(Fixtures.otherValidTokenString)
// Assert
#expect(a == b)
#expect(a.hashValue == b.hashValue)
#expect(a != other)
}

View File

@@ -0,0 +1,337 @@
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)
}
}

View File

@@ -0,0 +1,192 @@
import Foundation
import Testing
import WireProtocol
import HostRegistry
// + (B2)
// **线** Keychain ( accessToken );
// = nil,(
// .corruptedData,)
/// `Host` : id/name/endpoint, Codable
/// "", JSON
private struct LegacyHostRecord: Encodable {
let id: UUID
let name: String
let endpoint: HostEndpoint
}
private func encodedLegacyPayload(_ host: Host) throws -> Data {
try JSONEncoder().encode([
LegacyHostRecord(id: host.id, name: host.name, endpoint: host.endpoint),
])
}
// MARK: - ()
@Test("迁移:旧形状记录(无 accessToken 字段)照常读出,令牌为 nil")
func legacyRecordWithoutTokenFieldStillLoads() async throws {
// Arrange
let legacy = Fixtures.makeHost(name: "paired-before-tokens")
let shim = FakeSecItemShim()
shim.forceCopyData(try encodedLegacyPayload(legacy))
let store = KeychainHostStore(shim: shim)
// Act
let loaded = try await store.loadAll()
// Assert
#expect(loaded.count == 1)
#expect(loaded.first?.id == legacy.id)
#expect(loaded.first?.name == "paired-before-tokens")
#expect(loaded.first?.endpoint == legacy.endpoint)
#expect(loaded.first?.accessToken == nil)
#expect(loaded == [legacy]) // == nil
}
@Test("迁移:逐字旧 JSON(键名钉死 id/name/endpoint.baseURL)解码成功且令牌为 nil")
func literalLegacyJSONDecodesWithNilToken() throws {
// Arrange ,()
let json = """
[{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F",\
"name":"mac-studio","endpoint":{"baseURL":"http://192.168.1.5:3000"}}]
"""
// Act
let hosts = try JSONDecoder().decode([Host].self, from: Data(json.utf8))
// Assert
#expect(hosts.count == 1)
#expect(hosts.first?.name == "mac-studio")
#expect(hosts.first?.endpoint.originHeader == "http://192.168.1.5:3000")
#expect(hosts.first?.accessToken == nil)
}
@Test("被篡改的非法令牌值:降级为 nil,不把整张主机表打成 .corruptedData")
func tamperedTokenValueDegradesToNilInsteadOfCorruptingTheList() async throws {
// Arrange ()
let json = """
[{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F","name":"mac-studio",\
"endpoint":{"baseURL":"http://192.168.1.5:3000"},"accessToken":"short!"}]
"""
let shim = FakeSecItemShim()
shim.forceCopyData(Data(json.utf8))
let store = KeychainHostStore(shim: shim)
// Act
let loaded = try await store.loadAll()
// Assert
#expect(loaded.count == 1)
#expect(loaded.first?.name == "mac-studio")
#expect(loaded.first?.accessToken == nil)
}
@Test("accessToken JSON null:, nil")
func explicitNullTokenDecodesAsNil() throws {
// Arrange
let json = """
[{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F","name":"mac-studio",\
"endpoint":{"baseURL":"http://192.168.1.5:3000"},"accessToken":null}]
"""
// Act
let hosts = try JSONDecoder().decode([Host].self, from: Data(json.utf8))
// Assert
#expect(hosts.first?.accessToken == nil)
}
@Test("endpoint : http(s) baseURL ( endpoint)")
func endpointRemainsStrictlyValidatedOnDecode() {
// Arrange
let json = """
[{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F","name":"bad",\
"endpoint":{"baseURL":"ftp://192.168.1.5"}}]
"""
// Act / Assert
#expect(throws: (any Error).self) {
_ = try JSONDecoder().decode([Host].self, from: Data(json.utf8))
}
}
// MARK: - 新形状
@Test(": accessToken (/)")
func tokenIsPersistedAsAPlainStringUnderAccessTokenKey() throws {
// Arrange
let host = Fixtures.makeHost().withAccessToken(Fixtures.makeToken())
// Act
let data = try JSONEncoder().encode(host)
let object = try #require(
JSONSerialization.jsonObject(with: data) as? [String: Any]
)
// Assert
#expect(object["accessToken"] as? String == Fixtures.validTokenString)
}
@Test(": nil accessToken ()")
func nilTokenIsOmittedFromTheEncodedForm() throws {
// Arrange
let host = Fixtures.makeHost()
// Act
let data = try JSONEncoder().encode(host)
let object = try #require(
JSONSerialization.jsonObject(with: data) as? [String: Any]
)
// Assert
#expect(object.keys.contains("accessToken") == false)
#expect(object.keys.sorted() == ["endpoint", "id", "name"])
}
@Test("Codable : Host ")
func codableRoundTripPreservesTheToken() throws {
// Arrange
let host = Fixtures.makeHost(name: "studio", urlString: "https://mac.ts.net:8443")
.withAccessToken(Fixtures.makeToken())
// Act
let decoded = try JSONDecoder().decode(Host.self, from: JSONEncoder().encode(host))
// Assert
#expect(decoded == host)
#expect(decoded.accessToken == Fixtures.makeToken())
#expect(decoded.endpoint.originHeader == "https://mac.ts.net:8443")
}
// MARK: -
@Test("withAccessToken 不可变:返回新值,原 Host 不变")
func withAccessTokenReturnsANewValueWithoutMutatingTheOriginal() {
// Arrange
let original = Fixtures.makeHost()
// Act
let withToken = original.withAccessToken(Fixtures.makeToken())
let cleared = withToken.withAccessToken(nil)
// Assert
#expect(original.accessToken == nil)
#expect(withToken.accessToken == Fixtures.makeToken())
#expect(cleared.accessToken == nil)
#expect(withToken.id == original.id)
#expect(withToken.name == original.name)
#expect(withToken.endpoint == original.endpoint)
#expect(cleared == original)
}
@Test("Equatable 含令牌:令牌不同的同 id 主机不相等(UI 才能感知令牌变化)")
func hostsDifferingOnlyByTokenAreNotEqual() {
// Arrange
let base = Fixtures.makeHost()
// Act / Assert
#expect(base.withAccessToken(Fixtures.makeToken()) != base)
#expect(base.withAccessToken(Fixtures.makeToken())
!= base.withAccessToken(Fixtures.makeToken(Fixtures.otherValidTokenString)))
}

View File

@@ -25,4 +25,28 @@ enum Fixtures {
static func makeSuiteName() -> String {
"HostRegistryTests." + UUID().uuidString
}
// MARK: - Access-token fixtures (coordination doc §1.1)
/// 22 chars exercising EVERY class of the frozen charset
/// `[A-Za-z0-9._~+/=-]` (upper/lower/digit/`.`/`_`/`~`/`+`/`/`/`=`/`-`),
/// comfortably above the 16-char minimum. Test-only value not a secret.
static let validTokenString = "A9z._~+/=-Abcdef012345"
/// A second, distinct valid token (for "replaced/other host" assertions).
static let otherValidTokenString = "Zy8-=/+~_.9876543210fedcba"
/// Builds a valid token; traps loudly if a fixture string is malformed
/// (a broken fixture must fail the suite, not silently skip assertions).
static func makeToken(_ raw: String = Fixtures.validTokenString) -> AccessToken {
guard let token = AccessToken(rawValue: raw) else {
fatalError("test fixture token invalid: length \(raw.count)")
}
return token
}
/// A repeated-character token of an exact length (length-boundary tests).
static func tokenString(length: Int) -> String {
String(repeating: "a", count: length)
}
}