diff --git a/docs/PLAN_IOS_CLIENT.md b/docs/PLAN_IOS_CLIENT.md index 8125922..12cfa30 100644 --- a/docs/PLAN_IOS_CLIENT.md +++ b/docs/PLAN_IOS_CLIENT.md @@ -540,7 +540,7 @@ W5 验收(report-only, 并行) | 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-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-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 无分发通道) | @@ -558,7 +558,7 @@ W5 验收(report-only, 并行) | 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-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-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**(含改写后的既有拒绝用例) | @@ -588,7 +588,7 @@ W5 验收(report-only, 并行) 另补 iPad UI-test 腿(同 commit)。 - **签名解锁**:`DEVELOPMENT_TEAM` + target 级 `CODE_SIGN_IDENTITY` + `WEBTERM_PUSH_ENTITLEMENTS` env 开关 (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` 自举) 跑通:对令牌放行 / 错令牌 401 终态且 **connect 计数 == 1**(另有可重试失败的对照组证明"零重试"不是计时假象)/ 令牌不替代 Origin(合法 cookie + 外域 Origin 仍拒)/ `POST /auth` 的 204+Set-Cookie、401、204-无-Set-Cookie 三态; diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 3f060a5..81ee887 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -38,7 +38,7 @@ |---|---|---| | 包测试 | 310 | **452** | | App 测试 | 296(1 失败) | **550**(iPhone + iPad 各一遍,**0 known issue**) | - | 集成测试 | 10 | **26** | + | 集成测试 | 10 | **32** | | Android 测试 | 687 | **691** | | ClientTLS 覆盖率 | 55.76%(**不在门内**) | **89.49%**(已入门) | | 覆盖率门 | 4 个包 | **5 个包全过**(最低余量 +9.49pp) | diff --git a/ios/IntegrationTests/APIClientTokenPolicyProbe.swift b/ios/IntegrationTests/APIClientTokenPolicyProbe.swift new file mode 100644 index 0000000..a9b9166 --- /dev/null +++ b/ios/IntegrationTests/APIClientTokenPolicyProbe.swift @@ -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) + } +} diff --git a/ios/IntegrationTests/AccessTokenGateTests.swift b/ios/IntegrationTests/AccessTokenGateTests.swift new file mode 100644 index 0000000..de327bb --- /dev/null +++ b/ios/IntegrationTests/AccessTokenGateTests.swift @@ -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)") + } +} diff --git a/ios/IntegrationTests/GitFixture.swift b/ios/IntegrationTests/GitFixture.swift new file mode 100644 index 0000000..6086b8c --- /dev/null +++ b/ios/IntegrationTests/GitFixture.swift @@ -0,0 +1,155 @@ +// +// GitFixture.swift — T-iOS-32 端到端腿的**一次性 git 仓库**夹具 +// +// 为什么必须是自建的临时仓库:`POST /projects/worktree` 是全应用**唯一**真正 +// 写盘的功能,服务器会在 `/-worktrees/` 下真建 +// 目录、真开分支(src/http/worktrees.ts:146-160,224-256)。把它指向用户自己的 +// 仓库 = 在别人的工作区里留垃圾分支/目录。所以夹具把 repo **和**它派生出来的 +// worktree 根一起关在同一个临时目录里,`destroy()` 一把删干净。 +// +// 布局(worktreeBase 的算法与服务端逐字对应,不是猜的): +// /repo ← 夹具仓库(一次空提交,HEAD 可用) +// /repo-worktrees/ ← 服务器自己算出来的 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 `)。 + 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 + /// 夹具仓库本体(`/repo`)。 + let repo: URL + + /// 服务端在 `worktreeRoot` 未配置时算出来的 worktree 根: + /// `/-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) +} diff --git a/ios/IntegrationTests/Package.swift b/ios/IntegrationTests/Package.swift index 5f1b808..096fb96 100644 --- a/ios/IntegrationTests/Package.swift +++ b/ios/IntegrationTests/Package.swift @@ -4,6 +4,17 @@ // ephemeral loopback port; see ServerHarness.swift for the run modes). // Flat layout (plan §2): test sources live directly in ios/IntegrationTests/. // `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 let package = Package( @@ -11,11 +22,19 @@ let package = Package( platforms: [.iOS(.v17), .macOS(.v14)], dependencies: [ .package(path: "../Packages/WireProtocol"), + .package(path: "../Packages/SessionCore"), + .package(path: "../Packages/HostRegistry"), + .package(path: "../Packages/APIClient"), ], targets: [ .testTarget( 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: ".", exclude: ["scripts"] ), diff --git a/ios/IntegrationTests/ServerHarness.swift b/ios/IntegrationTests/ServerHarness.swift index b0b345f..8bba9c9 100644 --- a/ios/IntegrationTests/ServerHarness.swift +++ b/ios/IntegrationTests/ServerHarness.swift @@ -19,6 +19,16 @@ // B. 外部服务器:WEBTERM_SERVER_URL=http://127.0.0.1:(须为 loopback, // 这样其自身 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 一律由 // `HostEndpoint` 单点派生(plan §5.1 铁律:禁止手拼),不再本地复刻常量。 @@ -51,6 +61,9 @@ enum HarnessTunables { static let outputAccumulationTimeout: Duration = .seconds(90) /// 等待服务器主动关闭 WS 的上限(kill 路径)。 static let closeObserveTimeout: Duration = .seconds(30) + /// F3「零重连」的观察窗口:必须显著大于 `ReconnectMachine` 的第一级退避(1s), + /// 这样"没有重连"才是结论而不是"还没轮到"。3.5s 覆盖 1s + 2s 两级。 + static let retryObservationWindow: Duration = .milliseconds(3500) } 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.. Character in + alphabet[Int.random(in: 0.. Pipe { @@ -138,6 +187,7 @@ private enum ServerProcessRegistry { actor ServerHarness { static let shared = ServerHarness() private var bootTask: Task? + private var tokenBootTask: Task? func server() async throws -> TestServer { if let bootTask { return try await bootTask.value } @@ -146,19 +196,28 @@ actor ServerHarness { 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 { if let external = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"] { guard let url = URL(string: external) else { throw HarnessError.setup("WEBTERM_SERVER_URL 不是合法 URL: \(external)") } 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 } let spawned = try spawnLocalServer() ServerProcessRegistry.register(spawned.process) 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 { ServerProcessRegistry.killNow() throw error @@ -166,12 +225,71 @@ actor ServerHarness { return spawned.server } - private static func spawnLocalServer() throws -> (server: TestServer, process: Process, logURL: URL) { - let repoRoot = try locateRepoRoot() - let tsx = repoRoot.appending(path: "node_modules/.bin/tsx") - guard FileManager.default.isExecutableFile(atPath: tsx.path) else { - throw HarnessError.setup("找不到 \(tsx.path) — 先在 repo 根目录跑 npm install/npm ci") + private static func bootstrapTokenGated() async throws -> TokenGatedServer { + let environment = ProcessInfo.processInfo.environment + if let external = environment["WEBTERM_TOKEN_SERVER_URL"] { + guard let token = environment["WEBTERM_TOKEN_SERVER_TOKEN"], !token.isEmpty else { + 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() guard let baseURL = URL(string: "http://127.0.0.1:\(port)") else { throw HarnessError.setup("无法构造 baseURL, port=\(port)") @@ -193,6 +311,9 @@ actor ServerHarness { env["SHELL_PATH"] = "/bin/bash" // 确定性输出(不吃用户 zsh 配置) env["USE_TMUX"] = "0" 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.standardOutput = logHandle process.standardError = logHandle @@ -200,15 +321,20 @@ actor ServerHarness { return (server, process, logURL) } - private static func awaitReady(_ server: TestServer, process: Process?, logURL: URL?) async throws { - let probeURL = server.baseURL.appending(path: "live-sessions") + /// - Parameter cookie: 令牌网关服务器的就绪探针必须带 `Cookie: webterm_auth=…`, + /// 否则 `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 while ContinuousClock.now < deadline { if let process, !process.isRunning { throw HarnessError.setup( "服务器进程提前退出(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, (try? JSONSerialization.jsonObject(with: data)) is [Any] { return @@ -220,6 +346,45 @@ actor ServerHarness { } } +/// `node_modules/.bin/tsx`:从 repo 根**逐级向上**找。 +/// +/// 为什么向上找:会话工作在 `.claude/worktrees//` 里,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=` 的头**值**(名字取自服务端 `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 { if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] { return URL(fileURLWithPath: override, isDirectory: true) diff --git a/ios/IntegrationTests/TokenPolicyDriftTests.swift b/ios/IntegrationTests/TokenPolicyDriftTests.swift new file mode 100644 index 0000000..5726deb --- /dev/null +++ b/ios/IntegrationTests/TokenPolicyDriftTests.swift @@ -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` + count(Keychain 边界) +// 3. `APIClient/Endpoints.swift`(AccessToken.swift) —— `Set` + 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).. 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).. @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=,无分号无空格") + 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)") + } +} diff --git a/ios/IntegrationTests/WSTestClient.swift b/ios/IntegrationTests/WSTestClient.swift index b513ce4..79ae7a6 100644 --- a/ios/IntegrationTests/WSTestClient.swift +++ b/ios/IntegrationTests/WSTestClient.swift @@ -21,16 +21,29 @@ final class WSTestClient: @unchecked Sendable { /// - Parameters: /// - origin: nil = 不带 Origin header(守卫负路径)。带值时必须来自 /// `TestServer.origin`(HostEndpoint 单点派生)或刻意构造的失配值。 + /// - accessToken: nil = 不带 `Cookie` 头。带值时手写 + /// `Cookie: webterm_auth=`(§1.1 冻结做法:原生客户端不解析 + /// `Set-Cookie`、不依赖 cookie jar)。F3 需要"合法 cookie + 外域 Origin" + /// 这种真实客户端造不出来的组合来证明两道门**正交**,故这里保留原始拼装。 /// - maxMessageBytes: 默认 `Tunables.maxWSMessageBytes`(16 MiB); /// 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) request.timeoutInterval = 15 if let origin { // T-iOS-2 已定案的平台事实:Origin 不在 reserved-header 列表,setValue 生效。 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 } self.task = task task.resume() @@ -261,6 +274,30 @@ func deleteLiveSession(server: TestServer, id: String, origin: String?) async th 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 列表。 func liveSessionIds(server: TestServer) async throws -> [String] { let url = server.baseURL.appending(path: "live-sessions") diff --git a/ios/IntegrationTests/WorktreeLifecycleTests.swift b/ios/IntegrationTests/WorktreeLifecycleTests.swift new file mode 100644 index 0000000..bc3f040 --- /dev/null +++ b/ios/IntegrationTests/WorktreeLifecycleTests.swift @@ -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), +// 连服务器派生出来的 `-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( + _ outcome: GitWriteOutcome, _ 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 是分数毫秒,必须解出正值") + } +}