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.
This commit is contained in:
@@ -540,7 +540,7 @@ W5 验收(report-only, 并行)
|
|||||||
| T-iOS-13 SessionList | `[x]` | `Screens/SessionListScreen.swift`、`Components/TelemetryChips.swift`、`ViewModels/SessionListViewModel.swift` + Tests |
|
| T-iOS-13 SessionList | `[x]` | `Screens/SessionListScreen.swift`、`Components/TelemetryChips.swift`、`ViewModels/SessionListViewModel.swift` + Tests |
|
||||||
| T-iOS-14 Gate/Digest UI | `[x]` | `Components/{GateBanner,PlanGateSheet,AwayDigestView}.swift`、`ViewModels/GateViewModel.swift` + `GateViewModelTests` |
|
| T-iOS-14 Gate/Digest UI | `[x]` | `Components/{GateBanner,PlanGateSheet,AwayDigestView}.swift`、`ViewModels/GateViewModel.swift` + `GateViewModelTests` |
|
||||||
| T-iOS-15 App 接线+生命周期 | `[x]` | `Wiring/{RootView,AppCoordinator,PrivacyShade,ColdStartPolicy,TerminalContainerView}.swift` + `PrivacyShadeTests`/`ColdStartPolicyTests` |
|
| T-iOS-15 App 接线+生命周期 | `[x]` | `Wiring/{RootView,AppCoordinator,PrivacyShade,ColdStartPolicy,TerminalContainerView}.swift` + `PrivacyShadeTests`/`ColdStartPolicyTests` |
|
||||||
| T-iOS-16 集成 CI | `[x]` | `IntegrationTests/**` **26** `@Test`(10 基线 + 8 令牌端到端 + 8 令牌策略漂移守卫)+ `scripts/coverage-gate.sh`(现门 **5** 个包,含 ClientTLS)+ `ios.yml` 六个 job(含 iPad 单测腿/iPad UI 腿/iOS-17 底线腿)+ 新增 `android.yml`。`ServerHarness.locateTsx` 改为逐级向上解析,故 worktree 内无 `node_modules` 也能自举。**未核实**:GH Actions 平台侧的运行结果——本仓库唯一 remote 是自建 Gitea,两个 workflow **从未在任何 runner 上跑过**;本地已逐条复跑其命令 |
|
| T-iOS-16 集成 CI | `[x]` | `IntegrationTests/**` **32** `@Test`(10 基线 + 8 令牌端到端 + 8 令牌策略漂移守卫 + 6 worktree 生命周期端到端)+ `scripts/coverage-gate.sh`(现门 **5** 个包,含 ClientTLS)+ `ios.yml` 六个 job(含 iPad 单测腿/iPad UI 腿/iOS-17 底线腿)+ 新增 `android.yml`。`ServerHarness.locateTsx` 改为逐级向上解析,故 worktree 内无 `node_modules` 也能自举。**未核实**:GH Actions 平台侧的运行结果——本仓库唯一 remote 是自建 Gitea,两个 workflow **从未在任何 runner 上跑过**;本地已逐条复跑其命令 |
|
||||||
| T-iOS-17 ntfy 桥验证+文档 | `[x]` | `ios/README.md` ntfy 章节(逐条引 `setup-hooks.mjs` 行号,只读验证,未动用户 hook 配置)。**DEFERRED**:手机端到端 |
|
| T-iOS-17 ntfy 桥验证+文档 | `[x]` | `ios/README.md` ntfy 章节(逐条引 `setup-hooks.mjs` 行号,只读验证,未动用户 hook 配置)。**DEFERRED**:手机端到端 |
|
||||||
| T-iOS-18 F 走查(真机) | `[~]` | 机器可执行项已执行并指认测试名(P0 收官条目)。**缺口**:F-iOS 的真机项(QR 扫码/IME/震动/切换器遮罩目检/ntfy 端到端)仍 DEFERRED,手工清单在 LOG |
|
| T-iOS-18 F 走查(真机) | `[~]` | 机器可执行项已执行并指认测试名(P0 收官条目)。**缺口**:F-iOS 的真机项(QR 扫码/IME/震动/切换器遮罩目检/ntfy 端到端)仍 DEFERRED,手工清单在 LOG |
|
||||||
| T-iOS-19 安全核对 | `[~]` | P0 面已逐条核(Origin 单点、G/RO 分界、五段 CIDR + 零 ArbitraryLoads、Keychain 属性)。P2 新增的 `NSMicrophoneUsageDescription`/`NSSpeechRecognitionUsageDescription` **已在产物层复核**(真机构建产物的 Info.plist 实测有这两键)。**剩余缺口**:release ipa 层核对仍未做(免费 team 无分发通道) |
|
| T-iOS-19 安全核对 | `[~]` | P0 面已逐条核(Origin 单点、G/RO 分界、五段 CIDR + 零 ArbitraryLoads、Keychain 属性)。P2 新增的 `NSMicrophoneUsageDescription`/`NSSpeechRecognitionUsageDescription` **已在产物层复核**(真机构建产物的 Info.plist 实测有这两键)。**剩余缺口**:release ipa 层核对仍未做(免费 team 无分发通道) |
|
||||||
@@ -558,7 +558,7 @@ W5 验收(report-only, 并行)
|
|||||||
| T-iOS-29 new-in-cwd + 退出清理 | `[x]` | `TerminalScreen`/`TerminalContainerView` 增量 + `NewSessionInCwdTests` |
|
| T-iOS-29 new-in-cwd + 退出清理 | `[x]` | `TerminalScreen`/`TerminalContainerView` 增量 + `NewSessionInCwdTests` |
|
||||||
| T-iOS-30 P1 验收+安全复核 | `[x]` | report-only 双 PASS 零 findings(LOG 条目)。**DEFERRED**:真机锁屏/unread 目视等,手工清单在 LOG |
|
| T-iOS-30 P1 验收+安全复核 | `[x]` | report-only 双 PASS 零 findings(LOG 条目)。**DEFERRED**:真机锁屏/unread 目视等,手工清单在 LOG |
|
||||||
| T-iOS-31 语音 PTT | `[x]` | `Components/{VoicePTT,VoicePTTBanner,SpeechDictation}.swift` + `KeyBar` 🎤 键 + `VoicePTTTests` **40** `@Test`。**DEFERRED**:真机口述→确认→注入 |
|
| T-iOS-31 语音 PTT | `[x]` | `Components/{VoicePTT,VoicePTTBanner,SpeechDictation}.swift` + `KeyBar` 🎤 键 + `VoicePTTTests` **40** `@Test`。**DEFERRED**:真机口述→确认→注入 |
|
||||||
| T-iOS-32 Worktree + `--resume` 历史 | `[~]` | `Screens/{WorktreeSheet,ResumeHistorySheet}.swift`、`ViewModels/{WorktreeViewModel,ResumeHistoryViewModel}.swift` + `WorktreeViewModelTests`(22)/`ResumeHistoryViewModelTests`(12)/`ProjectResumeLaunchTests`(7)。**缺口**:Accept 的"端到端一次"(对真服务器真建/真删 worktree)无自动化腿、本环境也未手工跑 |
|
| T-iOS-32 Worktree + `--resume` 历史 | `[x]` | `Screens/{WorktreeSheet,ResumeHistorySheet}.swift`、`ViewModels/{WorktreeViewModel,ResumeHistoryViewModel}.swift` + `WorktreeViewModelTests`(22)/`ResumeHistoryViewModelTests`(12)/`ProjectResumeLaunchTests`(7)。**Accept 的"端到端一次"已闭合**(收尾波):`IntegrationTests/WorktreeLifecycleTests.swift` **6 例**在临时 git fixture 仓上对真服务器真建/真删/真 prune——断言的是服务端**推导**出的路径与分支(`feature/e2e-worktree` → 目录 `feature-e2e-worktree`,原始分支名绝不出现在路径里),并用 `git worktree list --porcelain` 与 `.git/worktrees/` 双向核对;含 G 路由纪律的差分腿(无 `Origin` → 403 且零副作用,同一请求加 `Origin` → 200)与非法分支名不创建;`GET /sessions` 用 **HOME 隔离**的专用服务器逐字段解码(否则会打到开发者真实 Claude 历史,CI 上又退化成 `[]` 什么都证明不了) |
|
||||||
| T-iOS-33 终端内搜索 | `[x]` | `Components/TerminalSearchBar.swift` + `TerminalScreen` 绑定 SwiftTerm 搜索 API + `TerminalSearchTests` **18**(含 2 条真 view 高亮断言) |
|
| T-iOS-33 终端内搜索 | `[x]` | `Components/TerminalSearchBar.swift` + `TerminalScreen` 绑定 SwiftTerm 搜索 API + `TerminalSearchTests` **18**(含 2 条真 view 高亮断言) |
|
||||||
| T-iOS-34 主题 + Dynamic Type | `[x]` | `DesignSystem/{AppTheme,TerminalPalette}.swift`、`Screens/SettingsScreen.swift`、`Tokens/Typography` 浅色档 + `AppThemeTests`(24)/`DynamicTypeLayoutTests`(**14**)/`KeyBarTests`(**12**)。**缺口已闭合**(收尾波):`KeyBarMetrics.barHeight` 由常量 52pt 改为按字号档推导,`withKnownIssue` 已删、换成正向断言(AX5 不裁切 · 全 12 档不裁切且 ≥44pt · XS–XL 逐点仍 52pt 零回归 · 未封顶 AX5 溢出旧固定高的护栏)。**取舍**:键帽字体封顶在 `DS.Typography.numericClamp`(.accessibility2),故 AX3–AX5 的字号不再增长(不封顶时 AX5 要 108.24pt,会吃掉终端);该封顶由测试钉死不得漂移 |
|
| T-iOS-34 主题 + Dynamic Type | `[x]` | `DesignSystem/{AppTheme,TerminalPalette}.swift`、`Screens/SettingsScreen.swift`、`Tokens/Typography` 浅色档 + `AppThemeTests`(24)/`DynamicTypeLayoutTests`(**14**)/`KeyBarTests`(**12**)。**缺口已闭合**(收尾波):`KeyBarMetrics.barHeight` 由常量 52pt 改为按字号档推导,`withKnownIssue` 已删、换成正向断言(AX5 不裁切 · 全 12 档不裁切且 ≥44pt · XS–XL 逐点仍 52pt 零回归 · 未封顶 AX5 溢出旧固定高的护栏)。**取舍**:键帽字体封顶在 `DS.Typography.numericClamp`(.accessibility2),故 AX3–AX5 的字号不再增长(不封顶时 AX5 要 108.24pt,会吃掉终端);该封顶由测试钉死不得漂移 |
|
||||||
| T-iOS-35 web `?join=` 互通 | `[x]` | `DeepLinkRouter.swift` 的 `.joinShared` 分支 + `DeepLinkJoinTests` **19**(含改写后的既有拒绝用例) |
|
| T-iOS-35 web `?join=` 互通 | `[x]` | `DeepLinkRouter.swift` 的 `.joinShared` 分支 + `DeepLinkJoinTests` **19**(含改写后的既有拒绝用例) |
|
||||||
@@ -588,7 +588,7 @@ W5 验收(report-only, 并行)
|
|||||||
另补 iPad UI-test 腿(同 commit)。
|
另补 iPad UI-test 腿(同 commit)。
|
||||||
- **签名解锁**:`DEVELOPMENT_TEAM` + target 级 `CODE_SIGN_IDENTITY` + `WEBTERM_PUSH_ENTITLEMENTS` env 开关
|
- **签名解锁**:`DEVELOPMENT_TEAM` + target 级 `CODE_SIGN_IDENTITY` + `WEBTERM_PUSH_ENTITLEMENTS` env 开关
|
||||||
(commit `c4f8b5b`;真机构建在免费 team 上 `BUILD SUCCEEDED`,7 天临时 profile)。
|
(commit `c4f8b5b`;真机构建在免费 team 上 `BUILD SUCCEEDED`,7 天临时 profile)。
|
||||||
- **收尾波(2026-07-30)闭合了其中三条**:① AX5 键栏裁切 → 已修(见 T-iOS-34 行);
|
- **收尾波(2026-07-30)把四条全部闭合**:④ worktree 端到端 → 见 T-iOS-32 行;① AX5 键栏裁切 → 已修(见 T-iOS-34 行);
|
||||||
② 端到端令牌腿 → `IntegrationTests/AccessTokenGateTests.swift` **8 例**对真服务器(带 `WEBTERM_TOKEN` 自举)
|
② 端到端令牌腿 → `IntegrationTests/AccessTokenGateTests.swift` **8 例**对真服务器(带 `WEBTERM_TOKEN` 自举)
|
||||||
跑通:对令牌放行 / 错令牌 401 终态且 **connect 计数 == 1**(另有可重试失败的对照组证明"零重试"不是计时假象)/
|
跑通:对令牌放行 / 错令牌 401 终态且 **connect 计数 == 1**(另有可重试失败的对照组证明"零重试"不是计时假象)/
|
||||||
令牌不替代 Origin(合法 cookie + 外域 Origin 仍拒)/ `POST /auth` 的 204+Set-Cookie、401、204-无-Set-Cookie 三态;
|
令牌不替代 Origin(合法 cookie + 外域 Origin 仍拒)/ `POST /auth` 的 204+Set-Cookie、401、204-无-Set-Cookie 三态;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 包测试 | 310 | **452** |
|
| 包测试 | 310 | **452** |
|
||||||
| App 测试 | 296(1 失败) | **550**(iPhone + iPad 各一遍,**0 known issue**) |
|
| App 测试 | 296(1 失败) | **550**(iPhone + iPad 各一遍,**0 known issue**) |
|
||||||
| 集成测试 | 10 | **26** |
|
| 集成测试 | 10 | **32** |
|
||||||
| Android 测试 | 687 | **691** |
|
| Android 测试 | 687 | **691** |
|
||||||
| ClientTLS 覆盖率 | 55.76%(**不在门内**) | **89.49%**(已入门) |
|
| ClientTLS 覆盖率 | 55.76%(**不在门内**) | **89.49%**(已入门) |
|
||||||
| 覆盖率门 | 4 个包 | **5 个包全过**(最低余量 +9.49pp) |
|
| 覆盖率门 | 4 个包 | **5 个包全过**(最低余量 +9.49pp) |
|
||||||
|
|||||||
25
ios/IntegrationTests/APIClientTokenPolicyProbe.swift
Normal file
25
ios/IntegrationTests/APIClientTokenPolicyProbe.swift
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// APIClientTokenPolicyProbe.swift — F3:APIClient 侧令牌策略的取值窗口
|
||||||
|
//
|
||||||
|
// 为什么要单独一个文件:`APIClient` 既是**模块名**又是模块里的**结构体名**,
|
||||||
|
// 于是 `APIClient.AuthCookie` 恒被解析成"结构体 APIClient 的嵌套类型"(不存在),
|
||||||
|
// 无法在同时 import 了 SessionCore 的文件里限定到模块级符号;而不限定的
|
||||||
|
// `AuthCookie` 又会和 `SessionCore.AuthCookie` 撞名。
|
||||||
|
//
|
||||||
|
// 解法:本文件**只** import APIClient,此处的裸 `AuthCookie` / `AccessTokenRule`
|
||||||
|
// 唯一地解析到 APIClient 的那两个 internal 类型,再以带前缀的名字转出去。
|
||||||
|
// TokenPolicyDriftTests 用这个窗口,SessionCore 侧则直接 `SessionCore.AuthCookie`。
|
||||||
|
|
||||||
|
@testable import APIClient
|
||||||
|
|
||||||
|
enum APIClientTokenPolicy {
|
||||||
|
static var cookieName: String { AuthCookie.name }
|
||||||
|
|
||||||
|
static func headerValue(for token: String) -> String {
|
||||||
|
AuthCookie.headerValue(for: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isWellFormed(_ candidate: String) -> Bool {
|
||||||
|
AccessTokenRule.isWellFormed(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
352
ios/IntegrationTests/AccessTokenGateTests.swift
Normal file
352
ios/IntegrationTests/AccessTokenGateTests.swift
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
//
|
||||||
|
// AccessTokenGateTests.swift — F3(1):访问令牌的端到端腿(真服务器 + 真客户端栈)
|
||||||
|
//
|
||||||
|
// 本波风险最高的面:一枚共享密钥被同时盖在 **HTTP 请求**(APIClient)和
|
||||||
|
// **WS upgrade**(SessionCore/URLSessionTermTransport)上,此前只有对着 fake 的
|
||||||
|
// 单测。这里把它接到**真的门**上:`WEBTERM_TOKEN` 启用的真 Node 服务器
|
||||||
|
// (ServerHarness.tokenGatedServer()),跑真 `URLSessionTermTransport` /
|
||||||
|
// 真 `APIClient` / 真 `SessionEngine`。
|
||||||
|
//
|
||||||
|
// 服务端真源(已逐条 curl 核实,见条目里的实测值):
|
||||||
|
// src/http/auth.ts、src/server.ts:340-430(/auth + authGate)、
|
||||||
|
// src/server.ts:1353-1385(upgrade:先 Origin 再 cookie)。
|
||||||
|
// 客户端冻结契约:docs/plans/ios-completion.md §1.1。
|
||||||
|
//
|
||||||
|
// 断言里的四条不变式:
|
||||||
|
// 1. 正确令牌 ⇒ WS 升级成功 + attach 成功;RO 与 G 两类 HTTP 都放行。
|
||||||
|
// 2. 错误令牌 ⇒ upgrade 401 ⇒ **终态** `.unauthorized`,**零重连尝试**
|
||||||
|
// (这条是拦住客户端把服务器自己的 10 次/分钟限流器打成暴力破解的性质)。
|
||||||
|
// 3. 无令牌打令牌网关 ⇒ 同上终态;HTTP 侧 401 ⇒ 类型化 `.unauthorized`。
|
||||||
|
// 4. 令牌是**加法不是替代**:合法 cookie + 外域/缺失 Origin 仍被各自的门拒绝。
|
||||||
|
//
|
||||||
|
// 密级纪律:令牌是一次性随机串,只在进程内存里流动;下面任何 `#expect` 文案都
|
||||||
|
// 不含令牌,只含状态码/事件/计数。
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import WireProtocol
|
||||||
|
@testable import APIClient
|
||||||
|
@testable import SessionCore
|
||||||
|
|
||||||
|
// ─── 测试侧的真 HTTP 出口(App 层的 URLSessionHTTPTransport 不可依赖,等价重写) ──
|
||||||
|
|
||||||
|
/// `HTTPTransport` over URLSession,cookie jar 关闭:手写的 `Cookie` 头是唯一权威
|
||||||
|
/// (§1.1),jar 不许覆盖它,也不许把密级材料留在共享存储里。
|
||||||
|
struct IntegrationHTTPTransport: HTTPTransport {
|
||||||
|
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||||
|
let (data, response) = try await cookieFreeSession.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw HarnessError.setup("非 HTTP 响应")
|
||||||
|
}
|
||||||
|
return (data, http)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 连接计数("零重连"必须被计数证明,不能只看没有事件) ─────────────────────
|
||||||
|
|
||||||
|
actor ConnectCounter {
|
||||||
|
private(set) var count = 0
|
||||||
|
func bump() { count += 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 转发到真 `URLSessionTermTransport`,只多做一件事:数 `connect` 次数。
|
||||||
|
///
|
||||||
|
/// 注意它**不**实现 `PingableTermTransport`,因此引擎走 `transport.connect(to:)`
|
||||||
|
/// 分支 —— 对 401 路径没有影响(连接根本没建起来,无从 ping)。
|
||||||
|
struct CountingTermTransport: TermTransport {
|
||||||
|
let inner: URLSessionTermTransport
|
||||||
|
let counter: ConnectCounter
|
||||||
|
|
||||||
|
func connect(to endpoint: HostEndpoint) async throws -> TransportConnection {
|
||||||
|
await counter.bump()
|
||||||
|
return try await inner.connect(to: endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 事件收集(引擎的 events 是单消费者 AsyncStream) ─────────────────────────
|
||||||
|
|
||||||
|
/// 收集事件直到 `isDone` 命中或超时。返回**收到的全部事件**(顺序保留)。
|
||||||
|
func collectEvents(
|
||||||
|
from engine: SessionEngine, timeout: Duration,
|
||||||
|
until isDone: @escaping @Sendable ([SessionEvent]) -> Bool
|
||||||
|
) async -> [SessionEvent] {
|
||||||
|
let stream = engine.events
|
||||||
|
let collector = Task { () -> [SessionEvent] in
|
||||||
|
var seen: [SessionEvent] = []
|
||||||
|
for await event in stream {
|
||||||
|
seen.append(event)
|
||||||
|
if isDone(seen) { return seen }
|
||||||
|
}
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
let deadline = Task {
|
||||||
|
try? await Task.sleep(for: timeout)
|
||||||
|
collector.cancel()
|
||||||
|
}
|
||||||
|
defer { deadline.cancel() }
|
||||||
|
return await collector.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeEngine(
|
||||||
|
transport: any TermTransport, endpoint: HostEndpoint
|
||||||
|
) -> SessionEngine {
|
||||||
|
SessionEngine(
|
||||||
|
transport: transport, clock: ContinuousClock(), endpoint: endpoint,
|
||||||
|
eventsSource: { _ in [] }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 用例 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Suite("F3 访问令牌端到端(真令牌网关服务器)", .serialized, .timeLimit(.minutes(10)))
|
||||||
|
struct AccessTokenGateTests {
|
||||||
|
|
||||||
|
// MARK: 1. 正确令牌 —— WS + 两类 HTTP 全通
|
||||||
|
|
||||||
|
@Test("正确令牌:WS 升级成功且 attach 成功;RO(GET /live-sessions)与 G(DELETE)均放行")
|
||||||
|
func correctTokenOpensWebSocketAndBothHTTPFamilies() async throws {
|
||||||
|
// Arrange
|
||||||
|
let gated = try await ServerHarness.shared.tokenGatedServer()
|
||||||
|
let token = gated.token
|
||||||
|
let transport = URLSessionTermTransport(tokenProvider: { token })
|
||||||
|
let engine = makeEngine(transport: transport, endpoint: gated.server.endpoint)
|
||||||
|
let client = APIClient(
|
||||||
|
endpoint: gated.server.endpoint, http: IntegrationHTTPTransport(),
|
||||||
|
accessToken: token
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act: 真 WS 升级 → 真 attach 握手 → 服务器回 attached
|
||||||
|
await engine.open(sessionId: nil, cwd: nil)
|
||||||
|
let events = await collectEvents(from: engine, timeout: HarnessTunables.markerTimeout) {
|
||||||
|
$0.contains { if case .adopted = $0 { return true } else { return false } }
|
||||||
|
}
|
||||||
|
let adopted = events.compactMap { event -> UUID? in
|
||||||
|
if case let .adopted(id) = event { return id } else { return nil }
|
||||||
|
}.first
|
||||||
|
|
||||||
|
// Assert: 升级 + attach 都过了令牌门
|
||||||
|
let sessionId = try #require(
|
||||||
|
adopted, "带正确令牌的 WS 升级/attach 应成功,实际事件: \(events)")
|
||||||
|
#expect(events.contains(.connection(.connected)),
|
||||||
|
"应观察到 .connected,实际事件: \(events)")
|
||||||
|
#expect(events.contains(.connection(.failed(.unauthorized))) == false,
|
||||||
|
"正确令牌不该出现 .unauthorized")
|
||||||
|
|
||||||
|
// Assert: RO 路由(无 Origin,带 cookie)放行且看得见刚建的会话
|
||||||
|
let live = try await client.liveSessions()
|
||||||
|
#expect(live.contains { $0.id == sessionId },
|
||||||
|
"RO GET /live-sessions 应含刚 attach 的会话 id")
|
||||||
|
|
||||||
|
// Assert: G 路由(带 Origin + cookie)放行 —— 204 即不抛
|
||||||
|
await engine.close()
|
||||||
|
try await client.killSession(id: sessionId)
|
||||||
|
let afterKill = try await client.liveSessions()
|
||||||
|
#expect(afterKill.contains { $0.id == sessionId } == false,
|
||||||
|
"G DELETE /live-sessions/:id 应已回收该会话")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: 2. 错误令牌 —— 401 终态 + 零重连
|
||||||
|
|
||||||
|
@Test("错误令牌:WS 升级 401 → 终态 .unauthorized,且**零**重连尝试(只连了 1 次)")
|
||||||
|
func wrongTokenIsTerminalAndNeverRetries() async throws {
|
||||||
|
// Arrange: 形状合法但值错误的令牌 —— 形状非法会被客户端自己挡掉,
|
||||||
|
// 那样测的就不是服务器的门了。
|
||||||
|
let gated = try await ServerHarness.shared.tokenGatedServer()
|
||||||
|
let wrong = gated.wrongButWellFormedToken
|
||||||
|
let counter = ConnectCounter()
|
||||||
|
let transport = CountingTermTransport(
|
||||||
|
inner: URLSessionTermTransport(tokenProvider: { wrong }), counter: counter
|
||||||
|
)
|
||||||
|
let engine = makeEngine(transport: transport, endpoint: gated.server.endpoint)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await engine.open(sessionId: nil, cwd: nil)
|
||||||
|
let events = await collectEvents(from: engine, timeout: HarnessTunables.frameTimeout) {
|
||||||
|
$0.contains(.connection(.failed(.unauthorized)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert: 终态
|
||||||
|
#expect(events.contains(.connection(.failed(.unauthorized))),
|
||||||
|
"错误令牌应得终态 .unauthorized,实际事件: \(events)")
|
||||||
|
|
||||||
|
// Assert: 零重连 —— 等过第一级退避(ReconnectMachine 1s,且这个窗口够第二、
|
||||||
|
// 第三级都发生)再看计数。这条性质拦住的是"客户端拿错令牌把服务器
|
||||||
|
// 10 次/分钟的限流器打成暴力破解"。
|
||||||
|
try await Task.sleep(for: HarnessTunables.retryObservationWindow)
|
||||||
|
let attempts = await counter.count
|
||||||
|
#expect(attempts == 1, "应只连一次(零重连),实际 connect 次数 = \(attempts)")
|
||||||
|
#expect(events.contains { if case .connection(.reconnecting) = $0 { return true } else { return false } } == false,
|
||||||
|
"401 不得进入退避环(.reconnecting),实际事件: \(events)")
|
||||||
|
|
||||||
|
await engine.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对照组:没有这条,「connect 次数 == 1」可能只是因为计数器/观察窗口本身失灵。
|
||||||
|
/// 同一个 `CountingTermTransport` + 同一个观察窗口,换成**可重试**的失败
|
||||||
|
/// (端口上没人监听 → ECONNREFUSED),必须看到 ≥2 次连接与 `.reconnecting`。
|
||||||
|
@Test("对照组:可重试失败(端口无人监听)在同一观察窗口里确实重连 ≥2 次")
|
||||||
|
func retryableFailureDoesRetryWithinTheSameWindow() async throws {
|
||||||
|
// Arrange: 抓一个空闲端口但**不**在上面起服务器。
|
||||||
|
let port = try findFreeLoopbackPort()
|
||||||
|
let baseURL = try #require(URL(string: "http://127.0.0.1:\(port)"))
|
||||||
|
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||||
|
let counter = ConnectCounter()
|
||||||
|
let transport = CountingTermTransport(
|
||||||
|
inner: URLSessionTermTransport(tokenProvider: { nil }), counter: counter
|
||||||
|
)
|
||||||
|
let engine = makeEngine(transport: transport, endpoint: endpoint)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await engine.open(sessionId: nil, cwd: nil)
|
||||||
|
let events = await collectEvents(
|
||||||
|
from: engine, timeout: HarnessTunables.retryObservationWindow
|
||||||
|
) { _ in false }
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
let attempts = await counter.count
|
||||||
|
#expect(attempts >= 2,
|
||||||
|
"可重试失败应在窗口内重连,实际 connect 次数 = \(attempts);若为 1 说明上一条用例的『零重连』是观察不到而非真的没有")
|
||||||
|
#expect(events.contains { if case .connection(.reconnecting) = $0 { return true } else { return false } },
|
||||||
|
"可重试失败应发 .reconnecting,实际事件: \(events)")
|
||||||
|
#expect(events.contains(.connection(.failed(.unauthorized))) == false,
|
||||||
|
"连不上 ≠ 未授权:不得报 .unauthorized")
|
||||||
|
|
||||||
|
await engine.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: 3. 无令牌打令牌网关
|
||||||
|
|
||||||
|
@Test("无令牌打令牌网关:WS 401 → 同一终态零重连;RO HTTP 401 → 类型化 .unauthorized")
|
||||||
|
func missingTokenAgainstGatedServerIsTerminalToo() async throws {
|
||||||
|
// Arrange: tokenProvider 返回 nil ⇒ 完全不带 Cookie 头(LAN 零配置那条老路)
|
||||||
|
let gated = try await ServerHarness.shared.tokenGatedServer()
|
||||||
|
let counter = ConnectCounter()
|
||||||
|
let transport = CountingTermTransport(
|
||||||
|
inner: URLSessionTermTransport(tokenProvider: { nil }), counter: counter
|
||||||
|
)
|
||||||
|
let engine = makeEngine(transport: transport, endpoint: gated.server.endpoint)
|
||||||
|
let clientWithoutToken = APIClient(
|
||||||
|
endpoint: gated.server.endpoint, http: IntegrationHTTPTransport(), accessToken: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await engine.open(sessionId: nil, cwd: nil)
|
||||||
|
let events = await collectEvents(from: engine, timeout: HarnessTunables.frameTimeout) {
|
||||||
|
$0.contains(.connection(.failed(.unauthorized)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert: WS 侧与错误令牌完全同一处理
|
||||||
|
#expect(events.contains(.connection(.failed(.unauthorized))),
|
||||||
|
"无令牌应得终态 .unauthorized,实际事件: \(events)")
|
||||||
|
try await Task.sleep(for: HarnessTunables.retryObservationWindow)
|
||||||
|
let attempts = await counter.count
|
||||||
|
#expect(attempts == 1, "无令牌同样零重连,实际 connect 次数 = \(attempts)")
|
||||||
|
await engine.close()
|
||||||
|
|
||||||
|
// Assert: HTTP 侧 401 ⇒ 类型化 .unauthorized(不是通用网络错,UI 才能引导补令牌)
|
||||||
|
await #expect(throws: APIClientError.unauthorized) {
|
||||||
|
_ = try await clientWithoutToken.liveSessions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: 4. 令牌不替代 Origin(两道门正交)
|
||||||
|
|
||||||
|
@Test("令牌是加法不是替代:合法 cookie + 外域/缺失 Origin,WS 仍 401、G 路由仍 403")
|
||||||
|
func tokenNeverSubstitutesForTheOriginGuard() async throws {
|
||||||
|
// Arrange
|
||||||
|
let gated = try await ServerHarness.shared.tokenGatedServer()
|
||||||
|
let server = gated.server
|
||||||
|
let token = gated.token
|
||||||
|
let foreignOrigin = "http://127.0.0.1:\(server.port == HarnessTunables.mismatchedOriginPort ? HarnessTunables.mismatchedOriginPortFallback : HarnessTunables.mismatchedOriginPort)"
|
||||||
|
let bogusId = UUID().uuidString.lowercased()
|
||||||
|
|
||||||
|
// Act + Assert(WS):Origin 先于 cookie 判定(src/server.ts:1363-1379)
|
||||||
|
let foreign = WSTestClient(server: server, origin: foreignOrigin, accessToken: token)
|
||||||
|
defer { foreign.close() }
|
||||||
|
let foreignFailure = await foreign.handshakeFailure(timeout: HarnessTunables.frameTimeout)
|
||||||
|
#expect(foreignFailure != nil, "外域 Origin + 合法 cookie 的升级必须失败")
|
||||||
|
#expect(foreign.handshakeStatusCode == 401,
|
||||||
|
"应 401,实际 status=\(String(describing: foreign.handshakeStatusCode))")
|
||||||
|
|
||||||
|
let noOrigin = WSTestClient(server: server, origin: nil, accessToken: token)
|
||||||
|
defer { noOrigin.close() }
|
||||||
|
let noOriginFailure = await noOrigin.handshakeFailure(timeout: HarnessTunables.frameTimeout)
|
||||||
|
#expect(noOriginFailure != nil, "缺 Origin + 合法 cookie 的升级必须失败")
|
||||||
|
#expect(noOrigin.handshakeStatusCode == 401,
|
||||||
|
"应 401,实际 status=\(String(describing: noOrigin.handshakeStatusCode))")
|
||||||
|
|
||||||
|
// Act + Assert(HTTP G):403 来自 Origin 守卫,不是 401 令牌门 —— 状态码差异
|
||||||
|
// 本身就是"两道门各自独立"的证据。
|
||||||
|
let path = "live-sessions/\(bogusId)"
|
||||||
|
let foreignOriginStatus = try await rawStatusCode(
|
||||||
|
server: server, method: "DELETE", path: path, origin: foreignOrigin,
|
||||||
|
accessToken: token)
|
||||||
|
let noOriginStatus = try await rawStatusCode(
|
||||||
|
server: server, method: "DELETE", path: path, origin: nil, accessToken: token)
|
||||||
|
let bothStatus = try await rawStatusCode(
|
||||||
|
server: server, method: "DELETE", path: path, origin: server.origin,
|
||||||
|
accessToken: token)
|
||||||
|
let originOnlyStatus = try await rawStatusCode(
|
||||||
|
server: server, method: "DELETE", path: path, origin: server.origin,
|
||||||
|
accessToken: nil)
|
||||||
|
|
||||||
|
#expect(foreignOriginStatus == 403, "外域 Origin + 合法 cookie 应 403,实际 \(foreignOriginStatus)")
|
||||||
|
#expect(noOriginStatus == 403, "缺 Origin + 合法 cookie 应 403,实际 \(noOriginStatus)")
|
||||||
|
// 差分证明:两个头都对时才走到业务逻辑(id 不存在 → 404)。
|
||||||
|
#expect(bothStatus == 404, "Origin+cookie 都对应 404(id 不存在),实际 \(bothStatus)")
|
||||||
|
// 反向差分:Origin 对但无令牌 → 401(令牌门),与上面的 403 泾渭分明。
|
||||||
|
#expect(originOnlyStatus == 401, "有 Origin 无令牌应 401,实际 \(originOnlyStatus)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: 5. POST /auth 探针三态
|
||||||
|
|
||||||
|
@Test("POST /auth:正确令牌 204+Set-Cookie(.valid);错误 401(.invalidToken)")
|
||||||
|
func authProbeDistinguishesValidFromInvalid() async throws {
|
||||||
|
// Arrange
|
||||||
|
let gated = try await ServerHarness.shared.tokenGatedServer()
|
||||||
|
// 探针本身不带 cookie —— /auth 是配对期的一次性校验,恰恰是还没有 cookie 时用的。
|
||||||
|
let client = APIClient(
|
||||||
|
endpoint: gated.server.endpoint, http: IntegrationHTTPTransport(), accessToken: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act + Assert
|
||||||
|
#expect(try await client.probeAccessToken(gated.token) == .valid,
|
||||||
|
"正确令牌应得 .valid(204 + Set-Cookie)")
|
||||||
|
#expect(try await client.probeAccessToken(gated.wrongButWellFormedToken) == .invalidToken,
|
||||||
|
"错误令牌应得 .invalidToken(401)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("POST /auth:服务器未启用鉴权 → 204 但**无** Set-Cookie ⇒ .authDisabled(绝不当作已认证)")
|
||||||
|
func authProbeReportsAuthDisabledOnUngatedServer() async throws {
|
||||||
|
// Arrange: 这条只能在**没**开 WEBTERM_TOKEN 的服务器上出现,故用默认那台。
|
||||||
|
let plain = try await ServerHarness.shared.server()
|
||||||
|
let client = APIClient(
|
||||||
|
endpoint: plain.endpoint, http: IntegrationHTTPTransport(), accessToken: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let result = try await client.probeAccessToken(TokenCorpus.validBase)
|
||||||
|
|
||||||
|
// Assert: 必须是 .authDisabled 而不是 .valid —— 把"没开门"读成"已认证"
|
||||||
|
// 会让 UI 谎称主机受保护,并存下一枚什么都不守的令牌(§1.1)。
|
||||||
|
#expect(result == .authDisabled, "未启用鉴权的服务器应得 .authDisabled,实际 \(result)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("未启用鉴权的服务器:不带令牌的 RO/G 请求与从前**逐字节**一样(零回归)")
|
||||||
|
func ungatedServerKeepsZeroConfigBehaviour() async throws {
|
||||||
|
// Arrange
|
||||||
|
let plain = try await ServerHarness.shared.server()
|
||||||
|
let bogusId = UUID().uuidString.lowercased()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let roStatus = try await rawStatusCode(
|
||||||
|
server: plain, method: "GET", path: "live-sessions", origin: nil, accessToken: nil)
|
||||||
|
let guardedStatus = try await rawStatusCode(
|
||||||
|
server: plain, method: "DELETE", path: "live-sessions/\(bogusId)",
|
||||||
|
origin: plain.origin, accessToken: nil)
|
||||||
|
|
||||||
|
// Assert: 未设 WEBTERM_TOKEN ⇒ 整个门是关掉的,LAN 零配置不受影响。
|
||||||
|
#expect(roStatus == 200, "未启用鉴权时 RO 应 200,实际 \(roStatus)")
|
||||||
|
#expect(guardedStatus == 404, "未启用鉴权时 G 应走到业务逻辑(404),实际 \(guardedStatus)")
|
||||||
|
}
|
||||||
|
}
|
||||||
155
ios/IntegrationTests/GitFixture.swift
Normal file
155
ios/IntegrationTests/GitFixture.swift
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
//
|
||||||
|
// GitFixture.swift — T-iOS-32 端到端腿的**一次性 git 仓库**夹具
|
||||||
|
//
|
||||||
|
// 为什么必须是自建的临时仓库:`POST /projects/worktree` 是全应用**唯一**真正
|
||||||
|
// 写盘的功能,服务器会在 `<dirname(repo)>/<basename(repo)>-worktrees/` 下真建
|
||||||
|
// 目录、真开分支(src/http/worktrees.ts:146-160,224-256)。把它指向用户自己的
|
||||||
|
// 仓库 = 在别人的工作区里留垃圾分支/目录。所以夹具把 repo **和**它派生出来的
|
||||||
|
// worktree 根一起关在同一个临时目录里,`destroy()` 一把删干净。
|
||||||
|
//
|
||||||
|
// 布局(worktreeBase 的算法与服务端逐字对应,不是猜的):
|
||||||
|
// <root>/repo ← 夹具仓库(一次空提交,HEAD 可用)
|
||||||
|
// <root>/repo-worktrees/<sanitized> ← 服务器自己算出来的 worktree 目录
|
||||||
|
//
|
||||||
|
// 所有断言都拿 **git 自己**的 `worktree list --porcelain` 当第二信源:只信
|
||||||
|
// HTTP 响应体的话,"服务器说建好了" 和 "盘上真建好了" 就分不开。
|
||||||
|
|
||||||
|
import Darwin
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `git worktree list --porcelain` 里的一条记录(只留断言用得上的字段)。
|
||||||
|
struct RegisteredWorktree: Sendable, Equatable {
|
||||||
|
let path: String
|
||||||
|
let branch: String?
|
||||||
|
/// git 自己标记的"目录没了、登记还在"(`prunable <reason>`)。
|
||||||
|
let prunable: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 跑一条 git 命令并返回合并后的输出;非零退出码抛 `HarnessError.setup`
|
||||||
|
/// (夹具失败必须**响亮**,绝不能静默变成一条"断言不成立")。
|
||||||
|
@discardableResult
|
||||||
|
func runGit(_ arguments: [String], in directory: URL) throws -> String {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
|
||||||
|
process.arguments = arguments
|
||||||
|
process.currentDirectoryURL = directory
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = pipe
|
||||||
|
var environment = ProcessInfo.processInfo.environment
|
||||||
|
environment["GIT_TERMINAL_PROMPT"] = "0" // 夹具永远不联网、永远不问密码
|
||||||
|
process.environment = environment
|
||||||
|
try process.run()
|
||||||
|
// 先读到 EOF 再 wait:反过来会在输出撑满管道缓冲时死锁。
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
process.waitUntilExit()
|
||||||
|
let text = String(decoding: data, as: UTF8.self)
|
||||||
|
guard process.terminationStatus == 0 else {
|
||||||
|
throw HarnessError.setup(
|
||||||
|
"git \(arguments.joined(separator: " ")) 退出码 \(process.terminationStatus): \(text)")
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个临时目录里的一次性 git 仓库。值语义 + 显式 `destroy()`(用例 `defer` 调)。
|
||||||
|
struct GitFixture: Sendable {
|
||||||
|
/// 临时根目录,**已解 symlink**(macOS 的 $TMPDIR 是 /var/… → /private/var/…;
|
||||||
|
/// 不解的话服务端 realpath 之后返回的路径与我们手上的字符串对不上)。
|
||||||
|
let root: URL
|
||||||
|
/// 夹具仓库本体(`<root>/repo`)。
|
||||||
|
let repo: URL
|
||||||
|
|
||||||
|
/// 服务端在 `worktreeRoot` 未配置时算出来的 worktree 根:
|
||||||
|
/// `<dirname(repo)>/<basename(repo)>-worktrees`(src/http/worktrees.ts:151-152)。
|
||||||
|
var worktreeBase: URL { root.appending(path: "\(repo.lastPathComponent)-worktrees") }
|
||||||
|
|
||||||
|
/// 提交身份走 `-c`:不读、不写用户的全局 git 配置,也不触发 gpg 签名。
|
||||||
|
private static let identity = [
|
||||||
|
"-c", "user.email=fixture@example.invalid",
|
||||||
|
"-c", "user.name=WebTerm Fixture",
|
||||||
|
"-c", "commit.gpgsign=false",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// 建仓 + 一次空提交(`git worktree add` 在 unborn HEAD 上会失败,所以必须有)。
|
||||||
|
static func make() throws -> GitFixture {
|
||||||
|
let manager = FileManager.default
|
||||||
|
let raw = manager.temporaryDirectory
|
||||||
|
.appending(path: "webterm-worktree-e2e-\(UUID().uuidString)")
|
||||||
|
try manager.createDirectory(at: raw, withIntermediateDirectories: true)
|
||||||
|
// 一次性钉成规范形式(macOS 的 $TMPDIR 是 /var/… → /private/var/…)。服务端
|
||||||
|
// 拿到的就是规范路径,它算出来的 worktree 目录、git 报回来的路径三者同形。
|
||||||
|
let root = URL(fileURLWithPath: canonicalPath(raw.path), isDirectory: true)
|
||||||
|
let repo = root.appending(path: "repo")
|
||||||
|
try manager.createDirectory(at: repo, withIntermediateDirectories: true)
|
||||||
|
try runGit(["-c", "init.defaultBranch=main", "init", "-q"], in: repo)
|
||||||
|
try runGit(identity + ["commit", "-q", "--allow-empty", "-m", "fixture"], in: repo)
|
||||||
|
return GitFixture(root: root, repo: repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 尽力而为的清理:repo 与它的 worktree 根都在 `root` 底下,一把删完。
|
||||||
|
/// 吞错是刻意的 —— 清理失败只留一个临时目录,不该把用例判定改写掉。
|
||||||
|
func destroy() {
|
||||||
|
try? FileManager.default.removeItem(at: root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 第二信源:git 自己的登记 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 解析 `git worktree list --porcelain`(空行分隔的记录,首条是主 worktree)。
|
||||||
|
func registeredWorktrees() throws -> [RegisteredWorktree] {
|
||||||
|
let porcelain = try runGit(["worktree", "list", "--porcelain"], in: repo)
|
||||||
|
var out: [RegisteredWorktree] = []
|
||||||
|
var path: String?
|
||||||
|
var branch: String?
|
||||||
|
var prunable = false
|
||||||
|
func flush() {
|
||||||
|
guard let path else { return }
|
||||||
|
out.append(RegisteredWorktree(path: path, branch: branch, prunable: prunable))
|
||||||
|
(branch, prunable) = (nil, false)
|
||||||
|
}
|
||||||
|
for rawLine in porcelain.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||||
|
let line = String(rawLine)
|
||||||
|
if line.isEmpty {
|
||||||
|
flush()
|
||||||
|
path = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("worktree ") {
|
||||||
|
path = String(line.dropFirst("worktree ".count))
|
||||||
|
} else if line.hasPrefix("branch refs/heads/") {
|
||||||
|
branch = String(line.dropFirst("branch refs/heads/".count))
|
||||||
|
} else if line == "prunable" || line.hasPrefix("prunable ") {
|
||||||
|
prunable = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按规范路径找一条登记(symlink 别名与规范路径必须算同一个)。
|
||||||
|
func registeredWorktree(atCanonicalPath target: String) throws -> RegisteredWorktree? {
|
||||||
|
let wanted = canonicalPath(target)
|
||||||
|
return try registeredWorktrees().first { canonicalPath($0.path) == wanted }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `.git/worktrees/` 下的行政登记目录名(prune 要清掉的正是这些)。
|
||||||
|
func administrativeEntries() -> [String] {
|
||||||
|
let dir = repo.appending(path: ".git/worktrees")
|
||||||
|
let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path)
|
||||||
|
return (names ?? []).sorted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 规范绝对路径 = `realpath(3)`;路径不存在时**原样返回**。
|
||||||
|
///
|
||||||
|
/// 为什么不能用 `URL.resolvingSymlinksInPath()`(实测踩过,本文件的 RED 就是它):
|
||||||
|
/// 它的文档行为是「若路径以 /private 开头就**去掉** /private」,方向正好相反 ——
|
||||||
|
/// 于是夹具根停在 `/var/folders/…`,而 git 的 `worktree list` 报的是
|
||||||
|
/// `/private/var/folders/…`。更糟的是那层转换**依赖路径是否存在**:目录被删掉之后
|
||||||
|
/// `/private` 不再被剥离,于是"删干净了吗"这类断言会因为路径形状而不是因为事实
|
||||||
|
/// 失败。`realpath(3)` 只做一个方向(→ 规范形式),夹具在建立时就把根钉成规范形式,
|
||||||
|
/// 此后所有比较都是纯字符串相等,与"路径此刻还存不存在"无关。
|
||||||
|
func canonicalPath(_ path: String) -> String {
|
||||||
|
guard let resolved = realpath(path, nil) else { return path }
|
||||||
|
defer { free(resolved) }
|
||||||
|
return String(cString: resolved)
|
||||||
|
}
|
||||||
@@ -4,6 +4,17 @@
|
|||||||
// ephemeral loopback port; see ServerHarness.swift for the run modes).
|
// ephemeral loopback port; see ServerHarness.swift for the run modes).
|
||||||
// Flat layout (plan §2): test sources live directly in ios/IntegrationTests/.
|
// Flat layout (plan §2): test sources live directly in ios/IntegrationTests/.
|
||||||
// `scripts/` holds the per-package coverage gate used by .github/workflows/ios.yml.
|
// `scripts/` holds the per-package coverage gate used by .github/workflows/ios.yml.
|
||||||
|
//
|
||||||
|
// F3 · why this package depends on FOUR client packages while every other
|
||||||
|
// package depends strictly downward: the access-token policy is duplicated in
|
||||||
|
// three modules (SessionCore.AuthCookie, HostRegistry.AccessToken,
|
||||||
|
// APIClient.AccessTokenRule) plus the server's own `WEBTERM_TOKEN_RE`, and the
|
||||||
|
// orchestrator froze `WireProtocol` (a shared-secret helper does not belong in
|
||||||
|
// the cross-language wire contract). No single package can therefore see all
|
||||||
|
// three predicates at once — this test target is the ONLY place that can, so
|
||||||
|
// the anti-drift guard lives here (TokenPolicyDriftTests). The same deps let
|
||||||
|
// the token end-to-end leg drive the REAL client transport/HTTP layer against a
|
||||||
|
// REAL token-gated server instead of fakes (AccessTokenGateTests).
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
@@ -11,11 +22,19 @@ let package = Package(
|
|||||||
platforms: [.iOS(.v17), .macOS(.v14)],
|
platforms: [.iOS(.v17), .macOS(.v14)],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(path: "../Packages/WireProtocol"),
|
.package(path: "../Packages/WireProtocol"),
|
||||||
|
.package(path: "../Packages/SessionCore"),
|
||||||
|
.package(path: "../Packages/HostRegistry"),
|
||||||
|
.package(path: "../Packages/APIClient"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "IntegrationTests",
|
name: "IntegrationTests",
|
||||||
dependencies: [.product(name: "WireProtocol", package: "WireProtocol")],
|
dependencies: [
|
||||||
|
.product(name: "WireProtocol", package: "WireProtocol"),
|
||||||
|
.product(name: "SessionCore", package: "SessionCore"),
|
||||||
|
.product(name: "HostRegistry", package: "HostRegistry"),
|
||||||
|
.product(name: "APIClient", package: "APIClient"),
|
||||||
|
],
|
||||||
path: ".",
|
path: ".",
|
||||||
exclude: ["scripts"]
|
exclude: ["scripts"]
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -19,6 +19,16 @@
|
|||||||
// B. 外部服务器:WEBTERM_SERVER_URL=http://127.0.0.1:<port>(须为 loopback,
|
// B. 外部服务器:WEBTERM_SERVER_URL=http://127.0.0.1:<port>(须为 loopback,
|
||||||
// 这样其自身 origin 天然在白名单内)。
|
// 这样其自身 origin 天然在白名单内)。
|
||||||
//
|
//
|
||||||
|
// F3 追加:**第二台**服务器 —— 开了 `WEBTERM_TOKEN` 的令牌网关服务器
|
||||||
|
// (`ServerHarness.tokenGatedServer()`)。两台并存是必需的:`POST /auth` 的
|
||||||
|
// 「204 但没有 Set-Cookie」只在**未启用**鉴权的服务器上才出现,而 401/终态用例
|
||||||
|
// 只在**启用**的服务器上才出现,同一进程无法同时是两者(令牌只在启动时读一次)。
|
||||||
|
// A. 默认自举:随机生成一枚符合 §1.1 字符集/长度的**一次性**令牌,经 env
|
||||||
|
// 传给子进程(与真实部署同一通路),仅存在于本进程内存;绝不写日志、
|
||||||
|
// 绝不进 URL、绝不出现在断言文案里。
|
||||||
|
// B. 外部服务器:WEBTERM_TOKEN_SERVER_URL + WEBTERM_TOKEN_SERVER_TOKEN
|
||||||
|
// (worktree 无 node_modules 时用:从主 checkout 手工起一台带令牌的服务器)。
|
||||||
|
//
|
||||||
// 与 spike 版的关键差异:WireProtocol 已冻结(T-iOS-3),Origin/wsURL 一律由
|
// 与 spike 版的关键差异:WireProtocol 已冻结(T-iOS-3),Origin/wsURL 一律由
|
||||||
// `HostEndpoint` 单点派生(plan §5.1 铁律:禁止手拼),不再本地复刻常量。
|
// `HostEndpoint` 单点派生(plan §5.1 铁律:禁止手拼),不再本地复刻常量。
|
||||||
|
|
||||||
@@ -51,6 +61,9 @@ enum HarnessTunables {
|
|||||||
static let outputAccumulationTimeout: Duration = .seconds(90)
|
static let outputAccumulationTimeout: Duration = .seconds(90)
|
||||||
/// 等待服务器主动关闭 WS 的上限(kill 路径)。
|
/// 等待服务器主动关闭 WS 的上限(kill 路径)。
|
||||||
static let closeObserveTimeout: Duration = .seconds(30)
|
static let closeObserveTimeout: Duration = .seconds(30)
|
||||||
|
/// F3「零重连」的观察窗口:必须显著大于 `ReconnectMachine` 的第一级退避(1s),
|
||||||
|
/// 这样"没有重连"才是结论而不是"还没轮到"。3.5s 覆盖 1s + 2s 两级。
|
||||||
|
static let retryObservationWindow: Duration = .milliseconds(3500)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum HarnessError: Error, CustomStringConvertible {
|
enum HarnessError: Error, CustomStringConvertible {
|
||||||
@@ -65,6 +78,37 @@ enum HarnessError: Error, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 令牌网关服务器(F3;令牌是密级材料,见下方纪律注释) ─────────────────────
|
||||||
|
|
||||||
|
/// 一台启用了 `WEBTERM_TOKEN` 的服务器 + 打开它的那枚一次性令牌。
|
||||||
|
///
|
||||||
|
/// 密级纪律(§1.1):`token` 只在进程内存里传递给 `URLRequest`/`tokenProvider`。
|
||||||
|
/// **绝不**插进断言文案、日志、URL query —— 用例失败时只报状态码与判定结果。
|
||||||
|
struct TokenGatedServer: Sendable {
|
||||||
|
let server: TestServer
|
||||||
|
let token: String
|
||||||
|
|
||||||
|
/// 一枚**错误但形状合法**的令牌(同长度、同字符集)。用于 401 用例:形状
|
||||||
|
/// 非法的令牌会被客户端自己挡掉(`AuthCookie.headerValue` 返回 nil),那测的
|
||||||
|
/// 就不是服务器的门了。
|
||||||
|
var wrongButWellFormedToken: String {
|
||||||
|
// 首字符换成集合内的另一个字符:长度/字符集不变,值必不相同。
|
||||||
|
let replacement: Character = token.first == "z" ? "y" : "z"
|
||||||
|
return String(replacement) + token.dropFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成一次性令牌:32 个字符,取自 §1.1 字符集的 ASCII 字母数字子集
|
||||||
|
/// (子集足够——测的是服务器的门,不是字符集本身;字符集由 TokenPolicyDriftTests 守)。
|
||||||
|
func makeThrowawayToken(length: Int = 32) -> String {
|
||||||
|
let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
|
||||||
|
var generator = SystemRandomNumberGenerator()
|
||||||
|
let characters = (0..<length).map { _ -> Character in
|
||||||
|
alphabet[Int.random(in: 0..<alphabet.count, using: &generator)]
|
||||||
|
}
|
||||||
|
return String(characters)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 服务器端点快照(不可变;派生全部走 HostEndpoint 冻结契约) ────────────────
|
// ─── 服务器端点快照(不可变;派生全部走 HostEndpoint 冻结契约) ────────────────
|
||||||
|
|
||||||
struct TestServer: Sendable {
|
struct TestServer: Sendable {
|
||||||
@@ -94,16 +138,21 @@ struct TestServer: Sendable {
|
|||||||
|
|
||||||
private enum ServerProcessRegistry {
|
private enum ServerProcessRegistry {
|
||||||
private static let lock = NSLock()
|
private static let lock = NSLock()
|
||||||
nonisolated(unsafe) private static var process: Process?
|
/// F3:从单值改成数组 —— 现在同时跑两台服务器(普通 + 令牌网关)。若继续用
|
||||||
|
/// 单值持有,第二次 register 会覆盖第一台的 deathPipe,写端被 deinit 关闭 →
|
||||||
|
/// 第一台的 watchdog 立刻收到 EOF 并把还在用的服务器杀掉。
|
||||||
|
nonisolated(unsafe) private static var processes: [Process] = []
|
||||||
/// 持有写端防 deinit 关闭;测试进程死亡时由内核关闭 → watchdog 触发。
|
/// 持有写端防 deinit 关闭;测试进程死亡时由内核关闭 → watchdog 触发。
|
||||||
nonisolated(unsafe) private static var deathPipe: Pipe?
|
nonisolated(unsafe) private static var deathPipes: [Pipe] = []
|
||||||
nonisolated(unsafe) private static var installedAtexit = false
|
nonisolated(unsafe) private static var installedAtexit = false
|
||||||
|
|
||||||
static func register(_ p: Process) {
|
static func register(_ p: Process) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
process = p
|
processes.append(p)
|
||||||
deathPipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier)
|
if let pipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier) {
|
||||||
|
deathPipes.append(pipe)
|
||||||
|
}
|
||||||
guard !installedAtexit else { return }
|
guard !installedAtexit else { return }
|
||||||
installedAtexit = true
|
installedAtexit = true
|
||||||
atexit { ServerProcessRegistry.killNow() }
|
atexit { ServerProcessRegistry.killNow() }
|
||||||
@@ -112,8 +161,8 @@ private enum ServerProcessRegistry {
|
|||||||
static func killNow() {
|
static func killNow() {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
if let p = process, p.isRunning { p.terminate() }
|
for p in processes where p.isRunning { p.terminate() }
|
||||||
process = nil
|
processes = []
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func spawnDeathWatchdog(serverPid: Int32) throws -> Pipe {
|
private static func spawnDeathWatchdog(serverPid: Int32) throws -> Pipe {
|
||||||
@@ -138,6 +187,7 @@ private enum ServerProcessRegistry {
|
|||||||
actor ServerHarness {
|
actor ServerHarness {
|
||||||
static let shared = ServerHarness()
|
static let shared = ServerHarness()
|
||||||
private var bootTask: Task<TestServer, any Error>?
|
private var bootTask: Task<TestServer, any Error>?
|
||||||
|
private var tokenBootTask: Task<TokenGatedServer, any Error>?
|
||||||
|
|
||||||
func server() async throws -> TestServer {
|
func server() async throws -> TestServer {
|
||||||
if let bootTask { return try await bootTask.value }
|
if let bootTask { return try await bootTask.value }
|
||||||
@@ -146,19 +196,28 @@ actor ServerHarness {
|
|||||||
return try await task.value
|
return try await task.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// F3:开了 `WEBTERM_TOKEN` 的第二台服务器(同样"起一次、全 suite 复用")。
|
||||||
|
func tokenGatedServer() async throws -> TokenGatedServer {
|
||||||
|
if let tokenBootTask { return try await tokenBootTask.value }
|
||||||
|
let task = Task { try await Self.bootstrapTokenGated() }
|
||||||
|
tokenBootTask = task
|
||||||
|
return try await task.value
|
||||||
|
}
|
||||||
|
|
||||||
private static func bootstrap() async throws -> TestServer {
|
private static func bootstrap() async throws -> TestServer {
|
||||||
if let external = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"] {
|
if let external = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"] {
|
||||||
guard let url = URL(string: external) else {
|
guard let url = URL(string: external) else {
|
||||||
throw HarnessError.setup("WEBTERM_SERVER_URL 不是合法 URL: \(external)")
|
throw HarnessError.setup("WEBTERM_SERVER_URL 不是合法 URL: \(external)")
|
||||||
}
|
}
|
||||||
let server = try TestServer.make(baseURL: url)
|
let server = try TestServer.make(baseURL: url)
|
||||||
try await awaitReady(server, process: nil, logURL: nil)
|
try await awaitReady(server, process: nil, logURL: nil, cookie: nil)
|
||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
let spawned = try spawnLocalServer()
|
let spawned = try spawnLocalServer()
|
||||||
ServerProcessRegistry.register(spawned.process)
|
ServerProcessRegistry.register(spawned.process)
|
||||||
do {
|
do {
|
||||||
try await awaitReady(spawned.server, process: spawned.process, logURL: spawned.logURL)
|
try await awaitReady(
|
||||||
|
spawned.server, process: spawned.process, logURL: spawned.logURL, cookie: nil)
|
||||||
} catch {
|
} catch {
|
||||||
ServerProcessRegistry.killNow()
|
ServerProcessRegistry.killNow()
|
||||||
throw error
|
throw error
|
||||||
@@ -166,12 +225,71 @@ actor ServerHarness {
|
|||||||
return spawned.server
|
return spawned.server
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func spawnLocalServer() throws -> (server: TestServer, process: Process, logURL: URL) {
|
private static func bootstrapTokenGated() async throws -> TokenGatedServer {
|
||||||
let repoRoot = try locateRepoRoot()
|
let environment = ProcessInfo.processInfo.environment
|
||||||
let tsx = repoRoot.appending(path: "node_modules/.bin/tsx")
|
if let external = environment["WEBTERM_TOKEN_SERVER_URL"] {
|
||||||
guard FileManager.default.isExecutableFile(atPath: tsx.path) else {
|
guard let token = environment["WEBTERM_TOKEN_SERVER_TOKEN"], !token.isEmpty else {
|
||||||
throw HarnessError.setup("找不到 \(tsx.path) — 先在 repo 根目录跑 npm install/npm ci")
|
throw HarnessError.setup(
|
||||||
|
"设了 WEBTERM_TOKEN_SERVER_URL 就必须同时设 WEBTERM_TOKEN_SERVER_TOKEN")
|
||||||
}
|
}
|
||||||
|
guard let url = URL(string: external) else {
|
||||||
|
throw HarnessError.setup("WEBTERM_TOKEN_SERVER_URL 不是合法 URL: \(external)")
|
||||||
|
}
|
||||||
|
let server = try TestServer.make(baseURL: url)
|
||||||
|
let gated = TokenGatedServer(server: server, token: token)
|
||||||
|
// 就绪探针必须带 cookie:令牌网关下 GET /live-sessions 无 cookie 是 401。
|
||||||
|
try await awaitReady(
|
||||||
|
server, process: nil, logURL: nil, cookie: authCookieHeader(token: token))
|
||||||
|
return gated
|
||||||
|
}
|
||||||
|
let token = makeThrowawayToken()
|
||||||
|
let spawned = try spawnLocalServer(extraEnvironment: ["WEBTERM_TOKEN": token])
|
||||||
|
ServerProcessRegistry.register(spawned.process)
|
||||||
|
do {
|
||||||
|
try await awaitReady(
|
||||||
|
spawned.server, process: spawned.process, logURL: spawned.logURL,
|
||||||
|
cookie: authCookieHeader(token: token))
|
||||||
|
} catch {
|
||||||
|
ServerProcessRegistry.killNow()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return TokenGatedServer(server: spawned.server, token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// T-iOS-32:起一台**一次性**服务器 —— 给需要 bespoke 进程环境的用例用。
|
||||||
|
///
|
||||||
|
/// 为什么不能复用 `server()`:`GET /sessions` 的数据源是
|
||||||
|
/// `os.homedir()/.claude/projects`(src/http/history.ts:81),只有把子进程的
|
||||||
|
/// `HOME` 换掉才能让它**确定**(否则断言就落在开发者本机的真实 Claude 历史上:
|
||||||
|
/// CI 上那个目录根本不存在 ⇒ 恒 `[]` ⇒ 断言恒真,等于没测;本机上又会把真实
|
||||||
|
/// 会话内容读进测试进程)。`HOME` 只在启动时经 env 传一次,同一进程无法既是
|
||||||
|
/// 隔离的又是真实的 —— 与 `tokenGatedServer()` 需要第二台是同一个理由。
|
||||||
|
///
|
||||||
|
/// 不进 shared 缓存(每次调用一台),但**照样**登记进 `ServerProcessRegistry`,
|
||||||
|
/// 所以 death-pipe watchdog 一样管它,测试进程无论怎么死都不留孤儿 tsx。
|
||||||
|
/// 就绪失败时只 `terminate()` 自己这一台 —— 绝不调 `killNow()`(那会连别的
|
||||||
|
/// suite 正在用的共享服务器一起杀掉)。
|
||||||
|
///
|
||||||
|
/// 只走自举一条路:外部服务器(`WEBTERM_SERVER_URL`)的 `HOME` 不在我们手里,
|
||||||
|
/// 拿不到本方法存在的理由,所以这里不接受外部模式。
|
||||||
|
static func disposableServer(extraEnvironment: [String: String]) async throws -> TestServer {
|
||||||
|
let spawned = try spawnLocalServer(extraEnvironment: extraEnvironment)
|
||||||
|
ServerProcessRegistry.register(spawned.process)
|
||||||
|
do {
|
||||||
|
try await awaitReady(
|
||||||
|
spawned.server, process: spawned.process, logURL: spawned.logURL, cookie: nil)
|
||||||
|
} catch {
|
||||||
|
spawned.process.terminate()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return spawned.server
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func spawnLocalServer(
|
||||||
|
extraEnvironment: [String: String] = [:]
|
||||||
|
) throws -> (server: TestServer, process: Process, logURL: URL) {
|
||||||
|
let repoRoot = try locateRepoRoot()
|
||||||
|
let tsx = try locateTsx(from: repoRoot)
|
||||||
let port = try findFreeLoopbackPort()
|
let port = try findFreeLoopbackPort()
|
||||||
guard let baseURL = URL(string: "http://127.0.0.1:\(port)") else {
|
guard let baseURL = URL(string: "http://127.0.0.1:\(port)") else {
|
||||||
throw HarnessError.setup("无法构造 baseURL, port=\(port)")
|
throw HarnessError.setup("无法构造 baseURL, port=\(port)")
|
||||||
@@ -193,6 +311,9 @@ actor ServerHarness {
|
|||||||
env["SHELL_PATH"] = "/bin/bash" // 确定性输出(不吃用户 zsh 配置)
|
env["SHELL_PATH"] = "/bin/bash" // 确定性输出(不吃用户 zsh 配置)
|
||||||
env["USE_TMUX"] = "0"
|
env["USE_TMUX"] = "0"
|
||||||
env["PATH"] = (env["PATH"] ?? "") + ":/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
|
env["PATH"] = (env["PATH"] ?? "") + ":/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
|
||||||
|
// F3:令牌经 env 传给子进程 —— 与真实部署完全同一通路(`WEBTERM_TOKEN`),
|
||||||
|
// 且不落盘、不进命令行(`ps` 看不到 env 之外的东西)。
|
||||||
|
for (key, value) in extraEnvironment { env[key] = value }
|
||||||
process.environment = env
|
process.environment = env
|
||||||
process.standardOutput = logHandle
|
process.standardOutput = logHandle
|
||||||
process.standardError = logHandle
|
process.standardError = logHandle
|
||||||
@@ -200,15 +321,20 @@ actor ServerHarness {
|
|||||||
return (server, process, logURL)
|
return (server, process, logURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func awaitReady(_ server: TestServer, process: Process?, logURL: URL?) async throws {
|
/// - Parameter cookie: 令牌网关服务器的就绪探针必须带 `Cookie: webterm_auth=…`,
|
||||||
let probeURL = server.baseURL.appending(path: "live-sessions")
|
/// 否则 `GET /live-sessions` 恒 401(src/server.ts:459),会误判成"未就绪"。
|
||||||
|
private static func awaitReady(
|
||||||
|
_ server: TestServer, process: Process?, logURL: URL?, cookie: String?
|
||||||
|
) async throws {
|
||||||
|
var probe = URLRequest(url: server.baseURL.appending(path: "live-sessions"))
|
||||||
|
if let cookie { probe.setValue(cookie, forHTTPHeaderField: "Cookie") }
|
||||||
let deadline = ContinuousClock.now + HarnessTunables.serverReadyTimeout
|
let deadline = ContinuousClock.now + HarnessTunables.serverReadyTimeout
|
||||||
while ContinuousClock.now < deadline {
|
while ContinuousClock.now < deadline {
|
||||||
if let process, !process.isRunning {
|
if let process, !process.isRunning {
|
||||||
throw HarnessError.setup(
|
throw HarnessError.setup(
|
||||||
"服务器进程提前退出(exit \(process.terminationStatus))— 日志: \(logURL?.path ?? "n/a")")
|
"服务器进程提前退出(exit \(process.terminationStatus))— 日志: \(logURL?.path ?? "n/a")")
|
||||||
}
|
}
|
||||||
if let (data, response) = try? await URLSession.shared.data(from: probeURL),
|
if let (data, response) = try? await cookieFreeSession.data(for: probe),
|
||||||
(response as? HTTPURLResponse)?.statusCode == 200,
|
(response as? HTTPURLResponse)?.statusCode == 200,
|
||||||
(try? JSONSerialization.jsonObject(with: data)) is [Any] {
|
(try? JSONSerialization.jsonObject(with: data)) is [Any] {
|
||||||
return
|
return
|
||||||
@@ -220,6 +346,45 @@ actor ServerHarness {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `node_modules/.bin/tsx`:从 repo 根**逐级向上**找。
|
||||||
|
///
|
||||||
|
/// 为什么向上找:会话工作在 `.claude/worktrees/<name>/` 里,worktree 没有自己的
|
||||||
|
/// `node_modules`;Node 自己的模块解析同样是逐级向上,所以 worktree 里的
|
||||||
|
/// `src/server.ts` 本来就跑得起来(依赖解析到主 checkout 的 node_modules)。
|
||||||
|
/// 只查根目录会把这种完全正常的布局误判成"没装依赖"。
|
||||||
|
func locateTsx(from repoRoot: URL) throws -> URL {
|
||||||
|
var directory = repoRoot.standardizedFileURL
|
||||||
|
var visited: [String] = []
|
||||||
|
while true {
|
||||||
|
let candidate = directory.appending(path: "node_modules/.bin/tsx")
|
||||||
|
if FileManager.default.isExecutableFile(atPath: candidate.path) { return candidate }
|
||||||
|
visited.append(candidate.path)
|
||||||
|
let parent = directory.deletingLastPathComponent().standardizedFileURL
|
||||||
|
if parent.path == directory.path { break }
|
||||||
|
directory = parent
|
||||||
|
}
|
||||||
|
throw HarnessError.setup(
|
||||||
|
"找不到 node_modules/.bin/tsx(逐级向上查过 \(visited.count) 处,"
|
||||||
|
+ "从 \(visited.first ?? "?") 起)— 先在 repo 根目录跑 npm install/npm ci")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Cookie: webterm_auth=<token>` 的头**值**(名字取自服务端 `AUTH_COOKIE_NAME`,
|
||||||
|
/// 由 TokenPolicyDriftTests 对钉 src/http/auth.ts)。
|
||||||
|
func authCookieHeader(token: String) -> String {
|
||||||
|
"webterm_auth=\(token)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关掉 cookie jar 的会话:手写的 `Cookie` 头是唯一权威(§1.1 冻结),任何主机的
|
||||||
|
/// `Set-Cookie` 都不许覆盖它、也不许把密级材料留在共享存储里
|
||||||
|
/// (与 `SessionCore.WSConnection.sessionConfiguration` 同一纪律)。
|
||||||
|
let cookieFreeSession: URLSession = {
|
||||||
|
let configuration = URLSessionConfiguration.ephemeral
|
||||||
|
configuration.httpShouldSetCookies = false
|
||||||
|
configuration.httpCookieAcceptPolicy = .never
|
||||||
|
configuration.httpCookieStorage = nil
|
||||||
|
return URLSession(configuration: configuration)
|
||||||
|
}()
|
||||||
|
|
||||||
func locateRepoRoot() throws -> URL {
|
func locateRepoRoot() throws -> URL {
|
||||||
if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] {
|
if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] {
|
||||||
return URL(fileURLWithPath: override, isDirectory: true)
|
return URL(fileURLWithPath: override, isDirectory: true)
|
||||||
|
|||||||
439
ios/IntegrationTests/TokenPolicyDriftTests.swift
Normal file
439
ios/IntegrationTests/TokenPolicyDriftTests.swift
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
//
|
||||||
|
// 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,16 +21,29 @@ final class WSTestClient: @unchecked Sendable {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - origin: nil = 不带 Origin header(守卫负路径)。带值时必须来自
|
/// - origin: nil = 不带 Origin header(守卫负路径)。带值时必须来自
|
||||||
/// `TestServer.origin`(HostEndpoint 单点派生)或刻意构造的失配值。
|
/// `TestServer.origin`(HostEndpoint 单点派生)或刻意构造的失配值。
|
||||||
|
/// - accessToken: nil = 不带 `Cookie` 头。带值时手写
|
||||||
|
/// `Cookie: webterm_auth=<t>`(§1.1 冻结做法:原生客户端不解析
|
||||||
|
/// `Set-Cookie`、不依赖 cookie jar)。F3 需要"合法 cookie + 外域 Origin"
|
||||||
|
/// 这种真实客户端造不出来的组合来证明两道门**正交**,故这里保留原始拼装。
|
||||||
/// - maxMessageBytes: 默认 `Tunables.maxWSMessageBytes`(16 MiB);
|
/// - maxMessageBytes: 默认 `Tunables.maxWSMessageBytes`(16 MiB);
|
||||||
/// nil = 保持平台默认 1 MiB(仅 spike③ 复现分支)。
|
/// nil = 保持平台默认 1 MiB(仅 spike③ 复现分支)。
|
||||||
init(server: TestServer, origin: String?, maxMessageBytes: Int? = Tunables.maxWSMessageBytes) {
|
init(
|
||||||
|
server: TestServer, origin: String?, accessToken: String? = nil,
|
||||||
|
maxMessageBytes: Int? = Tunables.maxWSMessageBytes
|
||||||
|
) {
|
||||||
var request = URLRequest(url: server.wsURL)
|
var request = URLRequest(url: server.wsURL)
|
||||||
request.timeoutInterval = 15
|
request.timeoutInterval = 15
|
||||||
if let origin {
|
if let origin {
|
||||||
// T-iOS-2 已定案的平台事实:Origin 不在 reserved-header 列表,setValue 生效。
|
// T-iOS-2 已定案的平台事实:Origin 不在 reserved-header 列表,setValue 生效。
|
||||||
request.setValue(origin, forHTTPHeaderField: "Origin")
|
request.setValue(origin, forHTTPHeaderField: "Origin")
|
||||||
}
|
}
|
||||||
let task = URLSession.shared.webSocketTask(with: request)
|
if let accessToken {
|
||||||
|
request.setValue(authCookieHeader(token: accessToken), forHTTPHeaderField: "Cookie")
|
||||||
|
}
|
||||||
|
// 带 cookie 时走关掉 cookie jar 的会话:手写头必须是唯一权威,
|
||||||
|
// 共享 jar 里的 Set-Cookie 不许覆盖它(§1.1)。
|
||||||
|
let session = accessToken == nil ? URLSession.shared : cookieFreeSession
|
||||||
|
let task = session.webSocketTask(with: request)
|
||||||
if let maxMessageBytes { task.maximumMessageSize = maxMessageBytes }
|
if let maxMessageBytes { task.maximumMessageSize = maxMessageBytes }
|
||||||
self.task = task
|
self.task = task
|
||||||
task.resume()
|
task.resume()
|
||||||
@@ -261,6 +274,30 @@ func deleteLiveSession(server: TestServer, id: String, origin: String?) async th
|
|||||||
return http.statusCode
|
return http.statusCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// F3:任意方法 + 任意 Origin/Cookie 组合的裸 HTTP 请求 → 状态码。
|
||||||
|
///
|
||||||
|
/// 存在的理由与 `WSTestClient(accessToken:)` 相同:真实客户端(APIClient)**不可能**
|
||||||
|
/// 造出"合法 cookie + 外域 Origin"这种组合(Origin 恒由 `HostEndpoint` 单点派生),
|
||||||
|
/// 而那正是"令牌是加法、不是替代"这一性质的判定式。
|
||||||
|
func rawStatusCode(
|
||||||
|
server: TestServer, method: String, path: String, origin: String?, accessToken: String?
|
||||||
|
) async throws -> Int {
|
||||||
|
var request = URLRequest(url: server.baseURL.appending(path: path))
|
||||||
|
request.httpMethod = method
|
||||||
|
// 与 APIClient 同:Accept 绝不含 text/html,否则服务器按浏览器导航处理,
|
||||||
|
// 401 会变成 302→/login(src/server.ts:366-369,459)。
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
if let origin { request.setValue(origin, forHTTPHeaderField: "Origin") }
|
||||||
|
if let accessToken {
|
||||||
|
request.setValue(authCookieHeader(token: accessToken), forHTTPHeaderField: "Cookie")
|
||||||
|
}
|
||||||
|
let (_, response) = try await cookieFreeSession.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw HarnessError.setup("\(method) \(path) 非 HTTP 响应")
|
||||||
|
}
|
||||||
|
return http.statusCode
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /live-sessions(RO,无 Origin)→ 当前会话 id 列表。
|
/// GET /live-sessions(RO,无 Origin)→ 当前会话 id 列表。
|
||||||
func liveSessionIds(server: TestServer) async throws -> [String] {
|
func liveSessionIds(server: TestServer) async throws -> [String] {
|
||||||
let url = server.baseURL.appending(path: "live-sessions")
|
let url = server.baseURL.appending(path: "live-sessions")
|
||||||
|
|||||||
362
ios/IntegrationTests/WorktreeLifecycleTests.swift
Normal file
362
ios/IntegrationTests/WorktreeLifecycleTests.swift
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
//
|
||||||
|
// WorktreeLifecycleTests.swift — T-iOS-32 缺失的那一半:**端到端一次**
|
||||||
|
//
|
||||||
|
// T-iOS-32("worktree 创建 + claude --resume 历史",PLAN_IOS_CLIENT.md:917)的验收
|
||||||
|
// 是「builder 测试 + 端到端一次」。builder 侧(WorktreeViewModelTests 22 /
|
||||||
|
// ResumeHistoryViewModelTests 12 / ProjectResumeLaunchTests 7)早就绿了,但在本文件
|
||||||
|
// 之前,`grep -rn "projects/worktree" ios/IntegrationTests/` **零命中** —— 从没有任何
|
||||||
|
// 一条测试对着真服务器建过、删过一个真 worktree。这里补的就是这条腿。
|
||||||
|
//
|
||||||
|
// 三条纪律:
|
||||||
|
// 1. **真**:真 Node 服务器(ServerHarness)+ 真 `APIClient`(不是 fake transport),
|
||||||
|
// 走真 HTTP。
|
||||||
|
// 2. **不碰用户的仓库**:所有写盘都发生在自建的一次性 git 夹具里(GitFixture),
|
||||||
|
// 连服务器派生出来的 `<repo>-worktrees/` 也在夹具的临时根底下,`destroy()` 全删。
|
||||||
|
// 3. **双信源**:HTTP 200 只说明服务器认为成功;每条断言都再问一次 **git 自己**
|
||||||
|
// (`worktree list --porcelain` / `.git/worktrees/`)和**文件系统**。
|
||||||
|
//
|
||||||
|
// 服务端真源:src/server.ts:1094-1173(三条路由 + Origin 守卫 + 开关)、
|
||||||
|
// src/http/worktrees.ts(validate → contain → execFile,无 shell)。
|
||||||
|
//
|
||||||
|
// 安全面(不可软化):三条路由都是 **G** 类。缺 Origin 必须 403 —— 这是 CSWSH/CSRF
|
||||||
|
// 的唯一防线,而这三条是全应用**唯一**真写盘的通道。任何"为了过 CI 放宽"= CRITICAL。
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import WireProtocol
|
||||||
|
@testable import APIClient
|
||||||
|
|
||||||
|
// ─── 小工具 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 从 `GitWriteOutcome` 里取出 200 载荷;不是 `.ok` 就**抛**(夹具/前置失败要响亮,
|
||||||
|
/// 不能退化成后续一串看不懂的断言不成立)。
|
||||||
|
func requireGitWriteOK<Payload>(
|
||||||
|
_ outcome: GitWriteOutcome<Payload>, _ what: String
|
||||||
|
) throws -> Payload {
|
||||||
|
guard case let .ok(payload) = outcome else {
|
||||||
|
throw HarnessError.setup("\(what) 应为 200 .ok,实际 \(outcome)")
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 裸 HTTP:任意方法 + 任意 Origin + 真 JSON body → (状态码, 解析后的 body)。
|
||||||
|
///
|
||||||
|
/// 为什么不能直接用 `APIClient` 做守卫负路径:真客户端**造不出**"没有 Origin 的 G
|
||||||
|
/// 请求"(`APIRoute.originPolicy == .guarded` 恒由 `HostEndpoint` 盖头,见 §5.1)。
|
||||||
|
/// 差分的两条腿只差这一个头,其余逐字节相同 —— 403 才能归因到守卫本身。
|
||||||
|
func rawJSONRequest(
|
||||||
|
server: TestServer, method: String, path: String, origin: String?, json: [String: Any]
|
||||||
|
) async throws -> (status: Int, body: [String: Any]) {
|
||||||
|
var request = URLRequest(url: server.baseURL.appending(path: path))
|
||||||
|
request.httpMethod = method
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
// Accept 绝不含 text/html:否则服务器按浏览器导航处理,状态码语义会变。
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
if let origin { request.setValue(origin, forHTTPHeaderField: "Origin") }
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: json)
|
||||||
|
let (data, response) = try await cookieFreeSession.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw HarnessError.setup("\(method) \(path) 非 HTTP 响应")
|
||||||
|
}
|
||||||
|
let parsed = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
||||||
|
return (http.statusCode, parsed ?? [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 用例 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Suite("T-iOS-32 worktree 生命周期端到端(真服务器 + 真 APIClient)", .serialized, .timeLimit(.minutes(5)))
|
||||||
|
struct WorktreeLifecycleTests {
|
||||||
|
|
||||||
|
/// 无令牌的真客户端(默认那台服务器没开 `WEBTERM_TOKEN`)。
|
||||||
|
private func makeClient(_ server: TestServer) -> APIClient {
|
||||||
|
APIClient(endpoint: server.endpoint, http: IntegrationHTTPTransport(), accessToken: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let branchWithSlash = "feature/e2e-worktree"
|
||||||
|
private let sanitizedDirName = "feature-e2e-worktree"
|
||||||
|
|
||||||
|
// MARK: 1. create
|
||||||
|
|
||||||
|
@Test("create:200 带回**服务端算出来**的 path/branch(目录名被 sanitize),盘上真存在且被 git 登记")
|
||||||
|
func createProducesARealWorktreeRegisteredWithGit() async throws {
|
||||||
|
// Arrange
|
||||||
|
let server = try await ServerHarness.shared.server()
|
||||||
|
let fixture = try GitFixture.make()
|
||||||
|
defer { fixture.destroy() }
|
||||||
|
let client = makeClient(server)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let outcome = try await client.createWorktree(
|
||||||
|
path: fixture.repo.path, branch: branchWithSlash, base: nil)
|
||||||
|
|
||||||
|
// Assert:响应体是 git/服务端的规范值,不是我们输入的回声
|
||||||
|
let result = try requireGitWriteOK(outcome, "POST /projects/worktree")
|
||||||
|
let createdPath = try #require(result.path, "200 必须带回服务端算出的路径")
|
||||||
|
#expect(result.branch == branchWithSlash,
|
||||||
|
"branch 应原样回来(它就是 git 建的分支名),实际 \(String(describing: result.branch))")
|
||||||
|
// 关键:目录名是 sanitizeBranchForDir 的产物,'/' 变成了 '-'。若这里等于
|
||||||
|
// 输入回声,说明路径根本不是服务端派生的。
|
||||||
|
#expect(URL(fileURLWithPath: createdPath).lastPathComponent == sanitizedDirName,
|
||||||
|
"目录名应是 sanitize 后的 \(sanitizedDirName),实际 \(createdPath)")
|
||||||
|
#expect(createdPath.hasPrefix(fixture.worktreeBase.path + "/"),
|
||||||
|
"路径必须落在受控根 \(fixture.worktreeBase.path) 内,实际 \(createdPath)")
|
||||||
|
#expect(createdPath.contains(branchWithSlash) == false,
|
||||||
|
"带 '/' 的原始分支名不该出现在路径里,实际 \(createdPath)")
|
||||||
|
|
||||||
|
// Assert:文件系统(HTTP 说成功 ≠ 盘上真有)
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
let exists = FileManager.default.fileExists(atPath: createdPath, isDirectory: &isDirectory)
|
||||||
|
#expect(exists && isDirectory.boolValue, "worktree 目录应真存在:\(createdPath)")
|
||||||
|
#expect(FileManager.default.fileExists(atPath: createdPath + "/.git"),
|
||||||
|
"worktree 里应有 .git 指针文件")
|
||||||
|
|
||||||
|
// Assert:git 自己的登记(第二信源)
|
||||||
|
let registered = try #require(
|
||||||
|
try fixture.registeredWorktree(atCanonicalPath: createdPath),
|
||||||
|
"git worktree list 应登记 \(createdPath)")
|
||||||
|
#expect(registered.branch == branchWithSlash,
|
||||||
|
"git 登记的分支应是 \(branchWithSlash),实际 \(String(describing: registered.branch))")
|
||||||
|
let all = try fixture.registeredWorktrees()
|
||||||
|
#expect(all.count == 2, "应是主 worktree + 新建这一条,实际 \(all)")
|
||||||
|
#expect(fixture.administrativeEntries() == [sanitizedDirName],
|
||||||
|
"行政登记 .git/worktrees/ 应只有这一条,实际 \(fixture.administrativeEntries())")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: 2. remove
|
||||||
|
|
||||||
|
@Test("remove:200 后目录从盘上消失、从 git 登记消失、行政目录也被清掉;主 worktree 毫发无损")
|
||||||
|
func removeDeletesTheWorktreeFromDiskAndFromGit() async throws {
|
||||||
|
// Arrange:先真建一个(用真客户端,不走 git 命令行 —— 删的必须是"服务器建的那个")
|
||||||
|
let server = try await ServerHarness.shared.server()
|
||||||
|
let fixture = try GitFixture.make()
|
||||||
|
defer { fixture.destroy() }
|
||||||
|
let client = makeClient(server)
|
||||||
|
let created = try requireGitWriteOK(
|
||||||
|
try await client.createWorktree(
|
||||||
|
path: fixture.repo.path, branch: branchWithSlash, base: nil),
|
||||||
|
"前置 create")
|
||||||
|
let createdPath = try #require(created.path)
|
||||||
|
let before = try fixture.registeredWorktree(atCanonicalPath: createdPath)
|
||||||
|
#expect(before != nil, "前置:git 应先登记了它")
|
||||||
|
|
||||||
|
// Act:干净 worktree ⇒ 不需要 force
|
||||||
|
let outcome = try await client.removeWorktree(
|
||||||
|
path: fixture.repo.path, worktreePath: createdPath, force: false)
|
||||||
|
|
||||||
|
// Assert:响应体回的是 git 自己的规范路径
|
||||||
|
let removed = try requireGitWriteOK(outcome, "DELETE /projects/worktree")
|
||||||
|
let removedPath = try #require(removed.path, "200 必须带回被删的规范路径")
|
||||||
|
#expect(canonicalPath(removedPath) == canonicalPath(createdPath),
|
||||||
|
"删掉的应正是刚建的那条,\(removedPath) vs \(createdPath)")
|
||||||
|
|
||||||
|
// Assert:盘 + git 两处都没了
|
||||||
|
#expect(FileManager.default.fileExists(atPath: createdPath) == false,
|
||||||
|
"目录应已从盘上消失:\(createdPath)")
|
||||||
|
let afterRemoval = try fixture.registeredWorktree(atCanonicalPath: createdPath)
|
||||||
|
#expect(afterRemoval == nil, "git worktree list 里不该再有它,实际 \(String(describing: afterRemoval))")
|
||||||
|
#expect(fixture.administrativeEntries().isEmpty,
|
||||||
|
"行政登记应一并清掉,实际 \(fixture.administrativeEntries())")
|
||||||
|
|
||||||
|
// Assert:主 worktree 还在(destructive 路由绝不能顺手删了仓库本体)
|
||||||
|
let all = try fixture.registeredWorktrees()
|
||||||
|
#expect(all.count == 1, "应只剩主 worktree,实际 \(all)")
|
||||||
|
#expect(canonicalPath(all.first?.path ?? "") == canonicalPath(fixture.repo.path),
|
||||||
|
"剩下的那条必须是仓库本体")
|
||||||
|
#expect(FileManager.default.fileExists(atPath: fixture.repo.path + "/.git"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: 3. prune
|
||||||
|
|
||||||
|
@Test("prune:手工删掉 worktree 目录后,陈旧登记被 git 标 prunable;prune 回收它,再 prune 幂等([])")
|
||||||
|
func pruneReclaimsTheStaleAdministrativeEntry() async throws {
|
||||||
|
// Arrange
|
||||||
|
let server = try await ServerHarness.shared.server()
|
||||||
|
let fixture = try GitFixture.make()
|
||||||
|
defer { fixture.destroy() }
|
||||||
|
let client = makeClient(server)
|
||||||
|
let created = try requireGitWriteOK(
|
||||||
|
try await client.createWorktree(
|
||||||
|
path: fixture.repo.path, branch: branchWithSlash, base: nil),
|
||||||
|
"前置 create")
|
||||||
|
let createdPath = try #require(created.path)
|
||||||
|
|
||||||
|
// Act 1:绕过服务器,手工把目录删掉 —— 这正是 prune 存在的理由
|
||||||
|
try FileManager.default.removeItem(atPath: createdPath)
|
||||||
|
|
||||||
|
// Assert:登记还在,且被 git 标成 prunable("陈旧"是 git 的判定,不是我们的猜测)
|
||||||
|
let stale = try #require(
|
||||||
|
try fixture.registeredWorktree(atCanonicalPath: createdPath),
|
||||||
|
"目录没了但登记应该还在(这才有得 prune)")
|
||||||
|
#expect(stale.prunable, "git 应把它标成 prunable,实际 \(stale)")
|
||||||
|
#expect(fixture.administrativeEntries() == [sanitizedDirName],
|
||||||
|
"行政目录应仍在,实际 \(fixture.administrativeEntries())")
|
||||||
|
|
||||||
|
// Act 2
|
||||||
|
let pruned = try requireGitWriteOK(
|
||||||
|
try await client.pruneWorktrees(path: fixture.repo.path),
|
||||||
|
"POST /projects/worktree/prune")
|
||||||
|
|
||||||
|
// Assert:回收了这一条,并且报出来的标签指向它
|
||||||
|
#expect(pruned.pruned.count == 1, "应回收 1 条,实际 \(pruned.pruned)")
|
||||||
|
#expect(pruned.pruned.first?.hasSuffix(sanitizedDirName) == true,
|
||||||
|
"回收标签应指向 \(sanitizedDirName),实际 \(pruned.pruned)")
|
||||||
|
let afterPrune = try fixture.registeredWorktree(atCanonicalPath: createdPath)
|
||||||
|
#expect(afterPrune == nil, "陈旧登记应已消失,实际 \(String(describing: afterPrune))")
|
||||||
|
#expect(fixture.administrativeEntries().isEmpty,
|
||||||
|
"行政目录应已清掉,实际 \(fixture.administrativeEntries())")
|
||||||
|
|
||||||
|
// Assert:幂等 —— 再来一次没得回收,仍是 200 + 空列表
|
||||||
|
let again = try requireGitWriteOK(
|
||||||
|
try await client.pruneWorktrees(path: fixture.repo.path), "第二次 prune")
|
||||||
|
#expect(again.pruned.isEmpty, "干净仓库再 prune 应为空,实际 \(again.pruned)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: 4. G 类守卫(缺 Origin 必须 403,且**什么都没发生**)
|
||||||
|
|
||||||
|
@Test("G 守卫端到端:三条 worktree 路由缺 Origin 一律 403 且零副作用;仅补上 Origin 的同一请求 → 200")
|
||||||
|
func worktreeRoutesRejectRequestsWithoutOrigin() async throws {
|
||||||
|
// Arrange
|
||||||
|
let server = try await ServerHarness.shared.server()
|
||||||
|
let fixture = try GitFixture.make()
|
||||||
|
defer { fixture.destroy() }
|
||||||
|
let createBody: [String: Any] = ["path": fixture.repo.path, "branch": branchWithSlash]
|
||||||
|
|
||||||
|
// Act + Assert(create):无 Origin → 403,且**盘上什么都没建**
|
||||||
|
let blockedCreate = try await rawJSONRequest(
|
||||||
|
server: server, method: "POST", path: "projects/worktree", origin: nil, json: createBody)
|
||||||
|
#expect(blockedCreate.status == 403,
|
||||||
|
"缺 Origin 的 create 应 403(src/server.ts:1096),实际 \(blockedCreate.status)")
|
||||||
|
#expect(FileManager.default.fileExists(atPath: fixture.worktreeBase.path) == false,
|
||||||
|
"被守卫拒掉的请求不该在盘上留下 \(fixture.worktreeBase.path)")
|
||||||
|
let afterBlockedCreate = try fixture.registeredWorktrees()
|
||||||
|
#expect(afterBlockedCreate.count == 1,
|
||||||
|
"被守卫拒掉的请求不该产生任何 git 登记,实际 \(afterBlockedCreate)")
|
||||||
|
|
||||||
|
// 差分:唯一的差别是补上 Origin —— 于是走到业务逻辑并成功
|
||||||
|
let allowedCreate = try await rawJSONRequest(
|
||||||
|
server: server, method: "POST", path: "projects/worktree", origin: server.origin,
|
||||||
|
json: createBody)
|
||||||
|
#expect(allowedCreate.status == 200,
|
||||||
|
"带 Origin 的同一请求应 200,实际 \(allowedCreate.status)")
|
||||||
|
let createdPath = try #require(allowedCreate.body["path"] as? String)
|
||||||
|
let afterAllowedCreate = try fixture.registeredWorktree(atCanonicalPath: createdPath)
|
||||||
|
#expect(afterAllowedCreate != nil, "带 Origin 的 create 应真的登记了 \(createdPath)")
|
||||||
|
|
||||||
|
// Act + Assert(remove):无 Origin → 403,worktree 必须**还在**
|
||||||
|
let removeBody: [String: Any] = [
|
||||||
|
"path": fixture.repo.path, "worktreePath": createdPath, "force": false,
|
||||||
|
]
|
||||||
|
let blockedRemove = try await rawJSONRequest(
|
||||||
|
server: server, method: "DELETE", path: "projects/worktree", origin: nil,
|
||||||
|
json: removeBody)
|
||||||
|
#expect(blockedRemove.status == 403,
|
||||||
|
"缺 Origin 的 remove 应 403,实际 \(blockedRemove.status)")
|
||||||
|
#expect(FileManager.default.fileExists(atPath: createdPath),
|
||||||
|
"被守卫拒掉的 remove 绝不能真的删掉目录")
|
||||||
|
let afterBlockedRemove = try fixture.registeredWorktree(atCanonicalPath: createdPath)
|
||||||
|
#expect(afterBlockedRemove != nil, "被守卫拒掉的 remove 不该动 git 登记")
|
||||||
|
|
||||||
|
// Act + Assert(prune):无 Origin → 403;带 Origin → 200
|
||||||
|
let blockedPrune = try await rawJSONRequest(
|
||||||
|
server: server, method: "POST", path: "projects/worktree/prune", origin: nil,
|
||||||
|
json: ["path": fixture.repo.path])
|
||||||
|
#expect(blockedPrune.status == 403,
|
||||||
|
"缺 Origin 的 prune 应 403,实际 \(blockedPrune.status)")
|
||||||
|
let allowedPrune = try await rawJSONRequest(
|
||||||
|
server: server, method: "POST", path: "projects/worktree/prune",
|
||||||
|
origin: server.origin, json: ["path": fixture.repo.path])
|
||||||
|
#expect(allowedPrune.status == 200,
|
||||||
|
"带 Origin 的 prune 应 200,实际 \(allowedPrune.status)")
|
||||||
|
|
||||||
|
// 收尾:带 Origin 把它删掉,证明 remove 的差分腿也通
|
||||||
|
let allowedRemove = try await rawJSONRequest(
|
||||||
|
server: server, method: "DELETE", path: "projects/worktree", origin: server.origin,
|
||||||
|
json: removeBody)
|
||||||
|
#expect(allowedRemove.status == 200,
|
||||||
|
"带 Origin 的 remove 应 200,实际 \(allowedRemove.status)")
|
||||||
|
#expect(FileManager.default.fileExists(atPath: createdPath) == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: 5. 非法分支名 —— 400 且什么都没建
|
||||||
|
|
||||||
|
@Test("非法分支名:400 且**零副作用**(不建目录、不留 git 登记)")
|
||||||
|
func invalidBranchNamesAreRejectedWithoutCreatingAnything() async throws {
|
||||||
|
// Arrange:每一条都命中 validateBranchName 的一个分支
|
||||||
|
// (src/http/worktrees.ts:95-105):'..' / 前导 '-'(flag 注入) / 空白 /
|
||||||
|
// '.lock' 结尾 / 前导 '/'。
|
||||||
|
let server = try await ServerHarness.shared.server()
|
||||||
|
let fixture = try GitFixture.make()
|
||||||
|
defer { fixture.destroy() }
|
||||||
|
let client = makeClient(server)
|
||||||
|
let invalidBranches = ["../evil", "-force-flag", "bad name", "ends.lock", "/leading-slash"]
|
||||||
|
|
||||||
|
for branch in invalidBranches {
|
||||||
|
// Act
|
||||||
|
let outcome = try await client.createWorktree(
|
||||||
|
path: fixture.repo.path, branch: branch, base: nil)
|
||||||
|
|
||||||
|
// Assert:类型化拒绝 + 服务器的 SAFE 文案(绝非 git 原始 stderr)
|
||||||
|
guard case let .rejected(status, message) = outcome else {
|
||||||
|
Issue.record("分支 \(branch) 应被拒,实际 \(outcome)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
#expect(status == 400, "分支 \(branch) 应 400,实际 \(status)")
|
||||||
|
#expect(message?.isEmpty == false, "400 应带一句可展示的原因,分支 \(branch)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert:五次拒绝之后,盘上和 git 里都一无所有
|
||||||
|
#expect(FileManager.default.fileExists(atPath: fixture.worktreeBase.path) == false,
|
||||||
|
"非法分支名不该创建受控根 \(fixture.worktreeBase.path)")
|
||||||
|
let registered = try fixture.registeredWorktrees()
|
||||||
|
#expect(registered.count == 1, "应仍只有主 worktree,实际 \(registered)")
|
||||||
|
#expect(fixture.administrativeEntries().isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: 6. T-iOS-32 的另一半 —— GET /sessions(claude --resume 历史)
|
||||||
|
|
||||||
|
@Test("GET /sessions:真 APIClient 对着**HOME 隔离**的真服务器,把 jsonl 解成 HistorySession")
|
||||||
|
func claudeResumeHistoryDecodesFromTheRealServer() async throws {
|
||||||
|
// Arrange:自建一个假 HOME —— `/sessions` 读的是
|
||||||
|
// `os.homedir()/.claude/projects`(src/http/history.ts:81)。不隔离的话,
|
||||||
|
// CI 上那个目录不存在 ⇒ 恒 `[]` ⇒ 断言恒真;本机上又会把用户真实的会话
|
||||||
|
// 内容读进测试进程。两者都不可接受。
|
||||||
|
let manager = FileManager.default
|
||||||
|
let home = manager.temporaryDirectory
|
||||||
|
.appending(path: "webterm-history-home-\(UUID().uuidString)")
|
||||||
|
.resolvingSymlinksInPath()
|
||||||
|
defer { try? manager.removeItem(at: home) }
|
||||||
|
let projectDir = home.appending(path: ".claude/projects/-fixture-demo-project")
|
||||||
|
try manager.createDirectory(at: projectDir, withIntermediateDirectories: true)
|
||||||
|
let sessionId = UUID().uuidString.lowercased()
|
||||||
|
let lines: [[String: Any]] = [
|
||||||
|
["type": "summary", "summary": "不是 user 行,应被跳过"],
|
||||||
|
[
|
||||||
|
"type": "user",
|
||||||
|
"cwd": "/fixture/demo-project",
|
||||||
|
// content 的数组形态(parseSessionMeta 两种形态都要吃)
|
||||||
|
"message": ["content": [["type": "text", "text": "resume me\tplease"]]],
|
||||||
|
],
|
||||||
|
]
|
||||||
|
let jsonl = try lines
|
||||||
|
.map { String(decoding: try JSONSerialization.data(withJSONObject: $0), as: UTF8.self) }
|
||||||
|
.joined(separator: "\n")
|
||||||
|
try jsonl.write(
|
||||||
|
to: projectDir.appending(path: "\(sessionId).jsonl"), atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
let server = try await ServerHarness.disposableServer(
|
||||||
|
extraEnvironment: ["HOME": home.path])
|
||||||
|
let client = makeClient(server)
|
||||||
|
|
||||||
|
// Act:RO 路由 —— 真 APIClient 不带 Origin
|
||||||
|
let sessions = try await client.claudeSessions()
|
||||||
|
|
||||||
|
// Assert:逐字段解码(HOME 隔离 ⇒ 有且仅有我们放的那一条)
|
||||||
|
#expect(sessions.count == 1, "隔离 HOME 下应恰好 1 条,实际 \(sessions.count)")
|
||||||
|
let session = try #require(sessions.first)
|
||||||
|
#expect(session.id == sessionId, "id 应是 jsonl 的文件名主干(claude --resume 拿它)")
|
||||||
|
#expect(session.cwd == "/fixture/demo-project")
|
||||||
|
#expect(session.project == "demo-project", "project = cwd 的末段")
|
||||||
|
#expect(session.preview == "resume me please", "preview 应折叠空白后截断")
|
||||||
|
#expect(session.mtimeMs > 0, "mtimeMs 是分数毫秒,必须解出正值")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user