Files
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

211 lines
6.7 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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