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.
211 lines
6.7 KiB
Swift
211 lines
6.7 KiB
Swift
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)
|
||
}
|