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:
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
|
||||
/// Complete failure taxonomy for a per-host access token: the three validation
|
||||
/// verdicts plus the one store-level miss. One enum so the UI has a single
|
||||
/// thing to switch over when it saves a token (never a generic `Error`).
|
||||
public enum AccessTokenError: Error, Equatable {
|
||||
case tooShort(length: Int)
|
||||
case tooLong(length: Int)
|
||||
/// Deliberately payload-free. An error value gets logged, wrapped in an
|
||||
/// alert string, and attached to crash reports — carrying the rejected
|
||||
/// token here would turn every one of those into a leak (§5.3). The
|
||||
/// length cases carry only a length, which is not secret.
|
||||
case invalidCharacters
|
||||
/// `setAccessToken` for an id that is not in the store. Explicit error
|
||||
/// rather than a silent no-op: a UI that thinks it saved a token but
|
||||
/// didn't would send unauthenticated requests forever.
|
||||
case unknownHost(UUID)
|
||||
}
|
||||
|
||||
/// The host's shared access token (`WEBTERM_TOKEN`), validated at the boundary.
|
||||
///
|
||||
/// Charset and length are the FROZEN server contract (coordination doc §1.1),
|
||||
/// byte-for-byte the same rule the server enforces at config load —
|
||||
/// `/^[A-Za-z0-9._~+/=-]{16,512}$/` (src/config.ts:178). Validating here means
|
||||
/// a token that cannot possibly authenticate is rejected at the keyboard
|
||||
/// instead of turning into a mystery 401 later, and a value of this type is
|
||||
/// always safe to place verbatim in a `Cookie: webterm_auth=<t>` header (the
|
||||
/// charset is exactly the cookie-safe one — nothing to escape).
|
||||
///
|
||||
/// SECRET MATERIAL (§5.3). Three deliberate properties keep it out of logs:
|
||||
/// - `description`/`debugDescription` are redacted, so string interpolation
|
||||
/// (`"\(token)"`) — the way a token usually reaches a log — cannot leak it.
|
||||
/// - `customMirror` hides the storage, so `dump()` and reflection-based crash
|
||||
/// reporters see `<redacted>` too.
|
||||
/// - it is intentionally NOT `Codable`: nothing can serialize a token by
|
||||
/// accident. `Host` encodes it explicitly (one audited call site) into the
|
||||
/// Keychain item, which is the only place a token is ever persisted.
|
||||
public struct AccessToken: Sendable, Hashable, RawRepresentable,
|
||||
CustomStringConvertible, CustomDebugStringConvertible,
|
||||
CustomReflectable {
|
||||
/// §1.1 length window (server: `{16,512}`).
|
||||
public static let minLength = 16
|
||||
public static let maxLength = 512
|
||||
|
||||
/// §1.1 charset `[A-Za-z0-9._~+/=-]`. A `Set` so validation is O(n) in the
|
||||
/// token length with no regex engine in the hot path.
|
||||
private static let allowedCharacters: Set<Character> = Set(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~+/=-"
|
||||
)
|
||||
|
||||
/// The token, verbatim as it must appear in the `Cookie` header.
|
||||
public let rawValue: String
|
||||
|
||||
/// Validating init — the ONLY way a token enters the app.
|
||||
///
|
||||
/// Leading/trailing whitespace is trimmed first: tokens arrive by paste,
|
||||
/// and whitespace is never a legal token character, so trimming can never
|
||||
/// change the meaning of a valid token. Interior whitespace is NOT
|
||||
/// tolerated (that would be silent normalization of a secret).
|
||||
public init(validating raw: String) throws {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let length = trimmed.count
|
||||
guard length >= Self.minLength else { throw AccessTokenError.tooShort(length: length) }
|
||||
guard length <= Self.maxLength else { throw AccessTokenError.tooLong(length: length) }
|
||||
guard trimmed.allSatisfy(Self.allowedCharacters.contains) else {
|
||||
throw AccessTokenError.invalidCharacters
|
||||
}
|
||||
self.rawValue = trimmed
|
||||
}
|
||||
|
||||
/// Lenient seam over the same rules (`RawRepresentable`): invalid → nil.
|
||||
/// Used where a rejected value must degrade instead of failing a whole
|
||||
/// operation — decoding a tampered Keychain record (see `Host`).
|
||||
///
|
||||
/// Note: whitespace-padded input normalizes, so `rawValue` round-trips
|
||||
/// only for already-trimmed strings.
|
||||
public init?(rawValue: String) {
|
||||
guard let token = try? AccessToken(validating: rawValue) else { return nil }
|
||||
self = token
|
||||
}
|
||||
|
||||
public var description: String { "AccessToken(redacted, \(rawValue.count) chars)" }
|
||||
|
||||
public var debugDescription: String { description }
|
||||
|
||||
public var customMirror: Mirror {
|
||||
Mirror(self, children: ["rawValue": "<redacted>"], displayStyle: .struct)
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,90 @@ import WireProtocol
|
||||
/// Note: on macOS this shadows `Foundation.Host` (NSHost) inside this module;
|
||||
/// downstream test/app targets that import Foundation should alias
|
||||
/// `typealias Host = HostRegistry.Host` or qualify.
|
||||
public struct Host: Sendable, Equatable, Codable, Identifiable {
|
||||
public struct Host: Sendable, Equatable, Codable, Identifiable,
|
||||
CustomStringConvertible, CustomDebugStringConvertible {
|
||||
public let id: UUID
|
||||
public let name: String
|
||||
/// Single point of Origin/wsURL derivation — from WireProtocol, never
|
||||
/// redeclared here (plan §6 rule 1).
|
||||
public let endpoint: HostEndpoint
|
||||
/// This host's access token (`WEBTERM_TOKEN`), or nil when the host has
|
||||
/// no auth configured — which is the zero-config LAN default, so nil is
|
||||
/// the normal state, not an error state.
|
||||
///
|
||||
/// Why it lives ON the host record instead of in its own Keychain item:
|
||||
/// plan §5.3 assigns the host list to the Keychain precisely as the place
|
||||
/// for "host 列表(含未来 authMaterial 占位)". Piggy-backing on that one
|
||||
/// item means the token inherits the audited protection policy
|
||||
/// (`AfterFirstUnlockThisDeviceOnly`, never synchronizable — see
|
||||
/// `KeychainItemSpec`) and, just as importantly, that removing a host
|
||||
/// removes its secret in the same write — a separate item could be
|
||||
/// orphaned in the Keychain forever.
|
||||
public let accessToken: AccessToken?
|
||||
|
||||
public init(id: UUID, name: String, endpoint: HostEndpoint) {
|
||||
/// `accessToken` defaults to nil so every existing three-argument call
|
||||
/// site (pairing, tests, fixtures) keeps compiling and keeps meaning
|
||||
/// "no token configured".
|
||||
public init(id: UUID, name: String, endpoint: HostEndpoint, accessToken: AccessToken? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.endpoint = endpoint
|
||||
self.accessToken = accessToken
|
||||
}
|
||||
|
||||
/// Immutable update: returns a NEW host with the token set (or cleared with
|
||||
/// nil). Never mutates the receiver — the store persists the returned value.
|
||||
public func withAccessToken(_ token: AccessToken?) -> Host {
|
||||
Host(id: id, name: name, endpoint: endpoint, accessToken: token)
|
||||
}
|
||||
|
||||
// MARK: - Codable (hand-written: migration + tamper behaviour is the point)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, endpoint, accessToken
|
||||
}
|
||||
|
||||
/// MIGRATION SAFETY: a host paired by an earlier build has no
|
||||
/// `accessToken` key at all. `decodeIfPresent` maps both "key absent" and
|
||||
/// "key is null" to nil, so those records keep loading unchanged.
|
||||
///
|
||||
/// TAMPER SAFETY: a stored value that no longer passes §1.1 validation
|
||||
/// degrades to nil (the user re-enters a token) instead of throwing —
|
||||
/// throwing here would surface as `KeychainHostStoreError.corruptedData`
|
||||
/// for the WHOLE list, i.e. every paired host lost over one bad field.
|
||||
/// The `endpoint` stays strictly validated (a host without a dialable URL
|
||||
/// is not a host), so this leniency is scoped to the one optional field.
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.id = try container.decode(UUID.self, forKey: .id)
|
||||
self.name = try container.decode(String.self, forKey: .name)
|
||||
self.endpoint = try container.decode(HostEndpoint.self, forKey: .endpoint)
|
||||
let stored = try container.decodeIfPresent(String.self, forKey: .accessToken)
|
||||
self.accessToken = stored.flatMap(AccessToken.init(rawValue:))
|
||||
}
|
||||
|
||||
/// Encodes the token as a plain string under `accessToken`, and omits the
|
||||
/// key entirely when there is none (an absent key is exactly what the
|
||||
/// decoder above treats as "no token", so old and new shapes stay
|
||||
/// interchangeable in both directions).
|
||||
public func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(endpoint, forKey: .endpoint)
|
||||
try container.encodeIfPresent(accessToken?.rawValue, forKey: .accessToken)
|
||||
}
|
||||
|
||||
// MARK: - Redacted description (§5.3)
|
||||
|
||||
/// Hand-written so the token can never ride along into a log line: the
|
||||
/// synthesized reflection dump would print the `accessToken` child, and a
|
||||
/// `Host` is the natural thing to interpolate when logging a connection.
|
||||
/// Only the token's PRESENCE is reported.
|
||||
public var description: String {
|
||||
"Host(id: \(id), name: \(name), origin: \(endpoint.originHeader), "
|
||||
+ "hasAccessToken: \(accessToken != nil))"
|
||||
}
|
||||
|
||||
public var debugDescription: String { description }
|
||||
}
|
||||
|
||||
@@ -10,8 +10,44 @@ public protocol HostStore: Sendable {
|
||||
/// Returns the new collection.
|
||||
func upsert(_ host: Host) async throws -> [Host]
|
||||
/// Removing an unknown `id` is an explicit no-op: returns the unchanged
|
||||
/// collection, never throws for "not found".
|
||||
/// collection, never throws for "not found". Removing a host also removes
|
||||
/// its `accessToken` — the token is part of the record (see `Host`), so no
|
||||
/// orphaned secret can survive in storage.
|
||||
func remove(id: UUID) async throws -> [Host]
|
||||
|
||||
/// This host's access token, or nil when none is configured / the host is
|
||||
/// unknown. A read never throws for "not found" (mirrors `remove`).
|
||||
func accessToken(host: UUID) async throws -> AccessToken?
|
||||
/// Sets (or with nil, clears) the host's access token and returns the new
|
||||
/// collection. Throws `AccessTokenError.unknownHost` rather than silently
|
||||
/// dropping a token the UI believes it saved.
|
||||
func setAccessToken(_ token: AccessToken?, host: UUID) async throws -> [Host]
|
||||
}
|
||||
|
||||
/// Generic token read/write built from `loadAll`/`upsert`, so every conformer —
|
||||
/// including test doubles outside this package — gets the behaviour for free
|
||||
/// and cannot drift from it. `KeychainHostStore`/`InMemoryHostStore` override
|
||||
/// both to do the read-modify-write INSIDE their actor (atomic against a
|
||||
/// concurrent upsert/remove); this default's two suspension points cannot be.
|
||||
extension HostStore {
|
||||
public func accessToken(host id: UUID) async throws -> AccessToken? {
|
||||
try await loadAll().first { $0.id == id }?.accessToken
|
||||
}
|
||||
|
||||
public func setAccessToken(_ token: AccessToken?, host id: UUID) async throws -> [Host] {
|
||||
let current = try await loadAll()
|
||||
guard let target = current.first(where: { $0.id == id }) else {
|
||||
throw AccessTokenError.unknownHost(id)
|
||||
}
|
||||
guard target.accessToken != token else { return current } // no-op: unchanged
|
||||
return try await upsert(target.withAccessToken(token))
|
||||
}
|
||||
|
||||
/// Clearing is writing nil — named so the intent reads at the call site
|
||||
/// (the token-settings UI removes a token; it does not "set nil").
|
||||
public func clearAccessToken(host id: UUID) async throws -> [Host] {
|
||||
try await setAccessToken(nil, host: id)
|
||||
}
|
||||
}
|
||||
|
||||
// Pure collection transforms shared by all HostStore implementations (DRY);
|
||||
@@ -26,4 +62,12 @@ extension [Host] {
|
||||
func removing(id: UUID) -> [Host] {
|
||||
filter { $0.id != id }
|
||||
}
|
||||
|
||||
/// Sets/clears one host's token. Returns nil when `id` is not in the
|
||||
/// collection so callers can raise `AccessTokenError.unknownHost` — a
|
||||
/// sentinel-free way to distinguish "unchanged" from "unknown host".
|
||||
func settingAccessToken(_ token: AccessToken?, host id: UUID) -> [Host]? {
|
||||
guard contains(where: { $0.id == id }) else { return nil }
|
||||
return map { $0.id == id ? $0.withAccessToken(token) : $0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,18 @@ public actor InMemoryHostStore: HostStore {
|
||||
hosts = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
public func accessToken(host id: UUID) async throws -> AccessToken? {
|
||||
hosts.first { $0.id == id }?.accessToken
|
||||
}
|
||||
|
||||
/// Atomic override for the same reason as `KeychainHostStore`'s: the
|
||||
/// read-modify-write stays inside the actor.
|
||||
public func setAccessToken(_ token: AccessToken?, host id: UUID) async throws -> [Host] {
|
||||
guard let updated = hosts.settingAccessToken(token, host: id) else {
|
||||
throw AccessTokenError.unknownHost(id)
|
||||
}
|
||||
hosts = updated
|
||||
return updated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,27 @@ public actor KeychainHostStore: HostStore {
|
||||
return updated
|
||||
}
|
||||
|
||||
// MARK: - Access token (stored inside the same host record — see Host)
|
||||
|
||||
public func accessToken(host id: UUID) async throws -> AccessToken? {
|
||||
try readHosts().first { $0.id == id }?.accessToken
|
||||
}
|
||||
|
||||
/// Atomic override of the `HostStore` default: the read-modify-write runs
|
||||
/// inside the actor, so a concurrent `upsert`/`remove` cannot interleave
|
||||
/// and lose the token (the default implementation awaits twice).
|
||||
public func setAccessToken(_ token: AccessToken?, host id: UUID) async throws -> [Host] {
|
||||
let current = try readHosts()
|
||||
guard let updated = current.settingAccessToken(token, host: id) else {
|
||||
throw AccessTokenError.unknownHost(id)
|
||||
}
|
||||
guard updated != current else {
|
||||
return current // explicit no-op: same token, nothing persisted
|
||||
}
|
||||
try persist(updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
// MARK: - Keychain I/O (dictionaries built solely by KeychainItemSpec)
|
||||
|
||||
private func readHosts() throws -> [Host] {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import HostRegistry
|
||||
|
||||
// AccessToken:边界校验 + "零泄漏"表示。
|
||||
// 契约来源:协调 doc §1.1 —— 字符集 `[A-Za-z0-9._~+/=-]`、长度 16–512
|
||||
// (与服务端 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user