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.
440 lines
23 KiB
Swift
440 lines
23 KiB
Swift
//
|
||
// TokenPolicyDriftTests.swift — F3(2):三份令牌策略 + 服务端正则的漂移守卫
|
||
//
|
||
// 问题:同一条访问令牌规则(cookie 名 `webterm_auth`,字符集 `[A-Za-z0-9._~+/=-]`,
|
||
// 长度 16–512)现在有 **四份** 独立实现:
|
||
// 1. `SessionCore/AuthCookie.swift` —— Swift `Regex` 字面量(WS upgrade 头)
|
||
// 2. `HostRegistry/AccessToken.swift` —— `Set<Character>` + count(Keychain 边界)
|
||
// 3. `APIClient/Endpoints.swift`(AccessToken.swift) —— `Set<Character>` + count(HTTP 头)
|
||
// 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=1,JS 数码元=2 —— 若字符集放宽会立刻分叉) ──
|
||
add("combining-acute", "A\u{0301}bCdEfGh01234567")
|
||
add("combining-only", String(repeated("a\u{0301}", count: 16)))
|
||
// Swift 数 Character = 256(在 16…512 窗口内),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 Character、JS 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)")
|
||
}
|
||
}
|