Files
web-terminal/ios/IntegrationTests/TokenPolicyDriftTests.swift
Yaojia Wang ddab77b337 test(ios): close T-iOS-32's end-to-end half — real worktree lifecycle against a real server
T-iOS-32's acceptance is "builder 测试 + 端到端一次". The builder half was green,
but grep for projects/worktree across the whole test tree returned nothing — no
test had ever created or removed a real git worktree against a real server.

Six tests on a throwaway git fixture: create/remove/prune verified against both
the filesystem and git's own `worktree list --porcelain` + .git/worktrees/, and
asserting the values the SERVER derives rather than echoing our input (branch
feature/e2e-worktree becomes directory feature-e2e-worktree; the raw branch name
must never appear in the path). Includes a differential leg for the guarded-route
rule — the same request without Origin is 403 with zero side effects, and with it
is 200 — which needs raw URLSession, since APIClient structurally cannot emit an
Origin-less guarded request.

GET /sessions gets its own HOME-isolated server: history.ts reads
os.homedir()/.claude/projects, so against the shared harness the assertions would
land on the developer's real Claude history, and in CI the directory is absent so
it degrades to [] and proves nothing.

Integration tests 26 -> 32, independently re-run.
2026-07-30 16:58:22 +02:00

440 lines
23 KiB
Swift
Raw 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.

//
// TokenPolicyDriftTests.swift F3(2) +
//
// 访cookie `webterm_auth` `[A-Za-z0-9._~+/=-]`
// 16512 ****
// 1. `SessionCore/AuthCookie.swift` Swift `Regex` WS upgrade
// 2. `HostRegistry/AccessToken.swift` `Set<Character>` + countKeychain
// 3. `APIClient/Endpoints.swift`(AccessToken.swift) `Set<Character>` + countHTTP
// 4. `src/config.ts:178` `WEBTERM_TOKEN_RE`
// 774 ****西****
//
// orchestrator `WireProtocol`
// 线IntegrationTests ****
// Package.swift " = "
//
//
// - Swift + ****
// - `src/config.ts` ****
// Swift
// - cookie `src/http/auth.ts` `AUTH_COOKIE_NAME`
//
// ""
// ****
import Foundation
import HostRegistry
import Testing
@testable import APIClient
@testable import SessionCore
// src/
enum ServerTokenPolicy {
/// `src/config.ts` `const WEBTERM_TOKEN_RE = //`
static func extractedTokenPatternSource() throws -> String {
let source = try readRepoFile("src/config.ts")
guard let line = source.split(separator: "\n").first(where: {
$0.contains("const WEBTERM_TOKEN_RE")
}) else {
throw HarnessError.setup("src/config.ts 里找不到 WEBTERM_TOKEN_RE 定义")
}
guard let open = line.firstIndex(of: "/"), let close = line.lastIndex(of: "/"),
open < close
else {
throw HarnessError.setup("WEBTERM_TOKEN_RE 那一行不是 /…/ 正则字面量: \(line)")
}
return String(line[line.index(after: open)..<close])
}
/// `src/http/auth.ts` `AUTH_COOKIE_NAME = '<name>'`
static func extractedCookieName() throws -> String {
let source = try readRepoFile("src/http/auth.ts")
guard let line = source.split(separator: "\n").first(where: {
$0.contains("export const AUTH_COOKIE_NAME")
}) else {
throw HarnessError.setup("src/http/auth.ts 里找不到 AUTH_COOKIE_NAME 定义")
}
guard let open = line.firstIndex(of: "'"), let close = line.lastIndex(of: "'"),
open < close
else {
throw HarnessError.setup("AUTH_COOKIE_NAME 那一行没有单引号字面量: \(line)")
}
return String(line[line.index(after: open)..<close])
}
/// ****
/// - JS `^`/`$` `m` = **** ICU `\A`/`\z`
/// JS `$` PCRE/ICU "" `$`
/// - `{16,512}` JS **UTF-16 **NSRegularExpression
/// UTF-16 Swift `Character`
/// **** ASCII
static func makeEvaluator() throws -> @Sendable (String) -> Bool {
let jsSource = try extractedTokenPatternSource()
var icuSource = jsSource
guard icuSource.hasPrefix("^"), icuSource.hasSuffix("$") else {
throw HarnessError.setup("WEBTERM_TOKEN_RE 不再是 ^…$ 整串锚定形状: \(jsSource)")
}
icuSource.removeFirst()
icuSource.removeLast()
let regex = try NSRegularExpression(pattern: "\\A\(icuSource)\\z")
return { candidate in
let range = NSRange(candidate.startIndex..., in: candidate)
return regex.firstMatch(in: candidate, options: [], range: range) != nil
}
}
private static func readRepoFile(_ relativePath: String) throws -> String {
let url = try locateRepoRoot().appending(path: relativePath)
guard let text = try? String(contentsOf: url, encoding: .utf8) else {
throw HarnessError.setup("读不到 \(url.path)(服务端真源缺失)")
}
return text
}
}
// +
struct TokenProbe: Sendable {
let label: String
let candidate: String
}
enum TokenCorpus {
/// 16
static let validBase = "AbCdEfGh01234567"
static func repeated(_ unit: String, count: Int) -> String {
String(repeating: unit, count: count)
}
/// emoji
/// U+2013 '-' 15/16/512/513
static let probes: [TokenProbe] = {
var out: [TokenProbe] = []
func add(_ label: String, _ candidate: String) {
out.append(TokenProbe(label: label, candidate: candidate))
}
// ASCII
add("len15(ASCII)", String(repeated("a", count: 15)))
add("len16(ASCII)", String(repeated("a", count: 16)))
add("len512(ASCII)", String(repeated("a", count: 512)))
add("len513(ASCII)", String(repeated("a", count: 513)))
add("len0(empty)", "")
//
add("charset-all-legal", "AZaz09._~+/=-" + "abc")
add("charset-dot-only", String(repeated(".", count: 16)))
add("charset-slash-only", String(repeated("/", count: 16)))
add("charset-equals-only", String(repeated("=", count: 16)))
add("charset-tilde-only", String(repeated("~", count: 16)))
add("charset-plus-only", String(repeated("+", count: 16)))
add("charset-hyphen-only", String(repeated("-", count: 16)))
//
// `_` ****`[A-Za-z0-9._~+/=-]` `.` `_`,
// "",
//
add("underscore(在集合内)", "AbCdEfGh0123456_")
// ""
add("colon", "AbCdEfGh0123456:")
add("semicolon(cookie 分隔符)", "AbCdEfGh0123456;")
add("space-interior", "AbCdEfGh 123456789")
add("percent", "AbCdEfGh0123456%")
add("backslash", "AbCdEfGh0123456\\")
add("comma", "AbCdEfGh0123456,")
add("quote-double", "AbCdEfGh0123456\"")
// CR/LF
add("CR-suffix", validBase + "\r")
add("LF-suffix", validBase + "\n")
add("CRLF-injection", validBase + "\r\nX-Evil: 1")
add("LF-interior", "AbCdEfGh\n123456789")
add("NUL-suffix", validBase + "\u{0}")
add("TAB-suffix", validBase + "\t")
// HostRegistry trim divergence
add("leading-space", " " + validBase)
add("trailing-space", validBase + " ")
add("leading-newline", "\n" + validBase)
// U+2013 EN DASH vs ASCII '-'
add("ascii-hyphen", "AbCdEfGh-1234567")
add("en-dash-U+2013", "AbCdEfGh\u{2013}1234567")
add("minus-sign-U+2212", "AbCdEfGh\u{2212}1234567")
add("non-breaking-hyphen-U+2011", "AbCdEfGh\u{2011}1234567")
// Halfwidth and Fullwidth Forms
add("fullwidth-A(U+FF21)", "\u{FF21}bCdEfGh01234567")
add("fullwidth-digit0(U+FF10)", "\u{FF10}bCdEfGh01234567")
add("fullwidth-fullstop(U+FF0E)", "AbCdEfGh0123456\u{FF0E}")
add("fullwidth-solidus(U+FF0F)", "AbCdEfGh0123456\u{FF0F}")
add("fullwidth-all", String(repeated("\u{FF21}", count: 16)))
// Swift Character=1JS =2
add("combining-acute", "A\u{0301}bCdEfGh01234567")
add("combining-only", String(repeated("a\u{0301}", count: 16)))
// Swift Character = 256 16512 JS UTF-16 = 512
// ""
add("combining-256-graphemes-512-units", String(repeated("a\u{0301}", count: 256)))
add("precomposed-e-acute", "\u{00E9}bCdEfGh01234567")
// /
add("zero-width-space(U+200B)", "AbCdEfGh\u{200B}1234567")
add("zero-width-joiner(U+200D)", "AbCdEfGh\u{200D}1234567")
add("zero-width-nbsp(U+FEFF)", "\u{FEFF}" + validBase)
add("soft-hyphen(U+00AD)", "AbCdEfGh\u{00AD}1234567")
add("nbsp(U+00A0)", "AbCdEfGh\u{00A0}1234567")
add("LTR-override(U+202E)", "AbCdEfGh\u{202E}1234567")
// emoji / Swift 1 CharacterJS 2
add("emoji-single", "AbCdEfGh\u{1F600}1234567")
add("emoji-zwj-family", "AbCdEfG\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}1234567")
add("emoji-flag", "AbCdEfGh\u{1F1E8}\u{1F1F3}123456")
add("emoji-only-16-graphemes", String(repeated("\u{1F600}", count: 16)))
add("emoji-fill-to-512-units", String(repeated("\u{1F600}", count: 256)))
// 西 / homoglyph
add("cyrillic-a(U+0430)", "\u{0430}bCdEfGh01234567")
add("greek-omicron(U+03BF)", "\u{03BF}bCdEfGh01234567")
// ASCII
add("len513-with-fullwidth-tail", String(repeated("a", count: 512)) + "\u{FF21}")
add("len511-plus-emoji", String(repeated("a", count: 511)) + "\u{1F600}")
return out
}()
}
//
struct TokenVerdicts: Equatable {
let sessionCore: Bool
let apiClient: Bool
let hostRegistry: Bool
let server: Bool
/// SessionCore / APIClient / ****
/// HostRegistry trim
/// ""padded
var coreThreeAgree: Bool {
sessionCore == apiClient && apiClient == server
}
var allFourAgree: Bool {
coreThreeAgree && hostRegistry == server
}
var summary: String {
"SessionCore=\(sessionCore) APIClient=\(apiClient) "
+ "HostRegistry=\(hostRegistry) server=\(server)"
}
}
/// `live(server:)`
/// `disagreementLabels` ****
/// Owns
struct TokenPolicySet {
let sessionCore: @Sendable (String) -> Bool
let apiClient: @Sendable (String) -> Bool
let hostRegistry: @Sendable (String) -> Bool
let server: @Sendable (String) -> Bool
/// Swift + src/
static func live(server: @escaping @Sendable (String) -> Bool) -> TokenPolicySet {
TokenPolicySet(
// internal `AuthCookie`SessionCore
// APIClient APIClientTokenPolicy
sessionCore: { SessionCore.AuthCookie.isValidToken($0) },
apiClient: { APIClientTokenPolicy.isWellFormed($0) },
hostRegistry: { AccessToken(rawValue: $0) != nil },
server: server
)
}
func verdicts(for candidate: String) -> TokenVerdicts {
TokenVerdicts(
sessionCore: sessionCore(candidate), apiClient: apiClient(candidate),
hostRegistry: hostRegistry(candidate), server: server(candidate)
)
}
/// ****
func disagreementLabels(over probes: [TokenProbe]) -> [String] {
probes.compactMap { probe in
let verdicts = self.verdicts(for: probe.candidate)
// HostRegistry trim
// trim
let agrees = hasSurroundingWhitespace(probe.candidate)
? verdicts.coreThreeAgree
: verdicts.allFourAgree
return agrees ? nil : "[\(probe.label)] \(verdicts.summary)"
}
}
}
func evaluateTokenPolicies(
_ candidate: String, server: @escaping @Sendable (String) -> Bool
) -> TokenVerdicts {
TokenPolicySet.live(server: server).verdicts(for: candidate)
}
/// HostRegistry trim
func hasSurroundingWhitespace(_ candidate: String) -> Bool {
candidate != candidate.trimmingCharacters(in: .whitespacesAndNewlines)
}
//
@Suite("F3 令牌策略漂移守卫(三份 Swift + 服务端正则)")
struct TokenPolicyDriftTests {
@Test("对抗语料逐条同判:任意两份实现分歧即红")
func allImplementationsAgreeOverAdversarialCorpus() throws {
let serverPredicate = try ServerTokenPolicy.makeEvaluator()
let disagreements = TokenPolicySet.live(server: serverPredicate)
.disagreementLabels(over: TokenCorpus.probes)
#expect(
disagreements.isEmpty,
"""
令牌策略已漂移(\(disagreements.count)/\(TokenCorpus.probes.count) 条样本分歧)。
四份实现必须同判:SessionCore/AuthCookie.swift、HostRegistry/AccessToken.swift、
APIClient/AccessToken.swift、src/config.ts:WEBTERM_TOKEN_RE。
分歧样本(只打标签,不打令牌):
\(disagreements.joined(separator: "\n"))
"""
)
// (""绿)
#expect(TokenCorpus.probes.count >= 50,
"对抗语料被削减到 \(TokenCorpus.probes.count) 条 —— 守卫失去意义")
}
/// ""
/// 使绿
/// SessionCore/HostRegistry/APIClient
/// Owns
@Test("守卫自身是响的:任一份实现哪怕只放宽一个字符,比较逻辑也必须报分歧")
func guardItselfDetectsEachSingleDrift() throws {
let serverPredicate = try ServerTokenPolicy.makeEvaluator()
let live = TokenPolicySet.live(server: serverPredicate)
// `:`**** `[A-Za-z0-9._~+/=-]`
// `_`
//
let drifted: @Sendable (String) -> Bool = { candidate in
let relaxed = candidate.replacingOccurrences(of: ":", with: ".")
return APIClientTokenPolicy.isWellFormed(relaxed)
}
let mutants: [(String, TokenPolicySet)] = [
("SessionCore", TokenPolicySet(
sessionCore: drifted, apiClient: live.apiClient,
hostRegistry: live.hostRegistry, server: live.server)),
("APIClient", TokenPolicySet(
sessionCore: live.sessionCore, apiClient: drifted,
hostRegistry: live.hostRegistry, server: live.server)),
("HostRegistry", TokenPolicySet(
sessionCore: live.sessionCore, apiClient: live.apiClient,
hostRegistry: drifted, server: live.server)),
("server", TokenPolicySet(
sessionCore: live.sessionCore, apiClient: live.apiClient,
hostRegistry: live.hostRegistry, server: drifted)),
]
for (name, mutant) in mutants {
let found = mutant.disagreementLabels(over: TokenCorpus.probes)
#expect(!found.isEmpty, "\(name) 漂移未被守卫抓到 —— 守卫失效(假绿)")
}
// ""
#expect(live.disagreementLabels(over: TokenCorpus.probes).isEmpty)
}
@Test("语料确实两侧都覆盖:既有被四份一致接受的,也有被四份一致拒绝的")
func corpusExercisesBothVerdicts() throws {
let serverPredicate = try ServerTokenPolicy.makeEvaluator()
let verdicts = TokenCorpus.probes.map {
evaluateTokenPolicies($0.candidate, server: serverPredicate).server
}
// "",绿( false ),
// 绿
#expect(verdicts.contains(true), "语料里没有任何被接受的样本 —— 上一条用例会假绿")
#expect(verdicts.contains(false), "语料里没有任何被拒绝的样本 —— 上一条用例会假绿")
}
@Test("服务端正则原文未变:字符集/长度窗口逐字节对钉")
func serverPatternIsStillTheFrozenShape() throws {
let pattern = try ServerTokenPolicy.extractedTokenPatternSource()
// §1.1 Swift
#expect(pattern == "^[A-Za-z0-9._~+/=-]{16,512}$",
"src/config.ts 的 WEBTERM_TOKEN_RE 变了(实测 \(pattern));三份 Swift 实现必须同步更新")
}
@Test("长度窗口 15/16/512/513:四份实现同判且落在 16…512")
func lengthBoundariesAreIdenticalEverywhere() throws {
let serverPredicate = try ServerTokenPolicy.makeEvaluator()
let cases: [(length: Int, expected: Bool)] = [
(15, false), (16, true), (511, true), (512, true), (513, false),
]
for (length, expected) in cases {
let candidate = String(repeating: "a", count: length)
let verdicts = evaluateTokenPolicies(candidate, server: serverPredicate)
#expect(verdicts.allFourAgree, "长度 \(length): \(verdicts.summary)")
#expect(verdicts.server == expected,
"长度 \(length) 应为 \(expected),实测 \(verdicts.summary)")
}
}
@Test("cookie 名三处 + 服务端 AUTH_COOKIE_NAME 全等")
func cookieNameIsPinnedEverywhere() throws {
let serverName = try ServerTokenPolicy.extractedCookieName()
#expect(serverName == "webterm_auth",
"src/http/auth.ts 的 AUTH_COOKIE_NAME 变了(实测 \(serverName))")
#expect(SessionCore.AuthCookie.name == serverName,
"SessionCore.AuthCookie.name=\(SessionCore.AuthCookie.name) ≠ 服务端 \(serverName)")
#expect(APIClientTokenPolicy.cookieName == serverName,
"APIClient.AuthCookie.name=\(APIClientTokenPolicy.cookieName) ≠ 服务端 \(serverName)")
}
@Test("cookie 头拼装形状三处一致:name=<token>,无分号无空格")
func cookieHeaderAssemblyIsIdentical() throws {
let token = TokenCorpus.validBase
let fromSessionCore = SessionCore.AuthCookie.headerValue(token: token)
let fromAPIClient = APIClientTokenPolicy.headerValue(for: token)
#expect(fromSessionCore == "webterm_auth=\(token)")
#expect(fromAPIClient == fromSessionCore,
"两份 cookie 头拼装分歧:APIClient=\(fromAPIClient) SessionCore=\(fromSessionCore ?? "nil")")
// /cookie
#expect(fromAPIClient.contains(";") == false)
#expect(fromAPIClient.contains("\r") == false && fromAPIClient.contains("\n") == false)
}
@Test("已知且有意的分歧:HostRegistry 先 trim 首尾空白,另两份原样拒绝")
func hostRegistryTrimDivergenceIsIntentionalAndBounded() throws {
let serverPredicate = try ServerTokenPolicy.makeEvaluator()
let padded = " \(TokenCorpus.validBase) "
let verdicts = evaluateTokenPolicies(padded, server: serverPredicate)
// , AccessToken ()
// ," trim /"
#expect(verdicts.hostRegistry, "HostRegistry 应接受首尾空白并归一化")
#expect(verdicts.sessionCore == false, "SessionCore 应原样拒绝带空白的串")
#expect(verdicts.apiClient == false, "APIClient 应原样拒绝带空白的串")
#expect(verdicts.server == false, "服务端正则应原样拒绝带空白的串")
// trim ""
let normalized = try #require(AccessToken(rawValue: padded)).rawValue
let afterTrim = evaluateTokenPolicies(normalized, server: serverPredicate)
#expect(afterTrim.allFourAgree && afterTrim.server,
"trim 后的令牌必须四份都接受,实测 \(afterTrim.summary)")
}
}