diff --git a/docs/PLAN_IOS_CLIENT.md b/docs/PLAN_IOS_CLIENT.md index 0e1d4fa..278b292 100644 --- a/docs/PLAN_IOS_CLIENT.md +++ b/docs/PLAN_IOS_CLIENT.md @@ -306,6 +306,7 @@ public struct AwayDigest: Sendable, Equatable { | `telemetryStaleTtlMs` | 30_000 | 镜像 public/tabs.ts:45 `STATUSLINE_TTL_MS`(= 服务器默认 src/config.ts:63;服务器侧 env 可覆盖——iOS 固化默认值,主机改配置时会漂移,已接受) | | `digestFadeDelay` | 8 s | T-iOS-14 digest 自动淡出 | | `titleMaxLength` | 256 | T-iOS-23 OSC 标题净化上限 | +| `pairingProbeTimeout` | 10 s | 配对探针整体 deadline(§3.4 契约裁定新增;两步探针任一步挂起超此时限 → `.timeout`) | | `maxWSMessageBytes` | 16 MiB(`16 * 1024 * 1024`) | ≥ 6 × 默认 SCROLLBACK_BYTES(2 MiB) + 帧包络。**耦合警示(写进 doc comment)**:回放单帧 ≈ SCROLLBACK_BYTES × JSON 转义系数(1–6×,控制字节→`\uXXXX`,src/protocol.ts:186);SCROLLBACK_BYTES 为服务器 env 可调且客户端运行时不可知——超限 → 不可重试 `.replayTooLarge`(§3.2 / T-iOS-9/10) | ### 3.3 `HostRegistry` @@ -356,7 +357,11 @@ public enum PairingError: Error, Equatable { // [S:hybrid] 错误分类学,逐 case tlsFailure, timeout } public func runPairingProbe(endpoint: HostEndpoint, http: any HTTPTransport, - ws: any TermTransport) async -> Result + ws: any TermTransport) async -> Result +// 【契约裁定 2026-07-04,T-iOS-8 BLOCKED 上报】原冻结签名返回 Result,但 Host 是 §3.3 +// HostRegistry 类型,APIClient 依赖边只有 WireProtocol(§1 叶子包零耦合)——签名自相矛盾。 +// 裁定:探针职责=验证 endpoint,返回 HostEndpoint;Host{id,name} 由 T-iOS-12 PairingViewModel +// 构造后入 store(id/name 本就非探针所知)。超时经 Tunables.pairingProbeTimeout(§3.2.1 新增行)。 // 探针两步:①GET /live-sessions(无 Origin,验可达+形状) ②WS attach(null)+立即 kill 往返 //(带 Origin,验 isOriginAllowed 精确匹配)。任何失败 → 映射到 PairingError,UI 内联显示。 // 注:扫码来源的 endpoint 必须先经 T-iOS-12 的"确认 host"步——用户未确认前不得调用本探针(探针①就会联网)。 @@ -585,7 +590,7 @@ W5 验收(report-only, 并行) - [ ] `hookDecision` body 形状 `{sessionId,decision,token}`;403 → 显式错误(token 过期话术) - [ ] 探针①失败分支:连接拒绝 → `hostUnreachable`;返回 HTML → `httpOkButNotWebTerminal` - [ ] 探针②失败分支:WS 401 → `originRejected(hint:…)`(hint 含 `ALLOWED_ORIGINS=://<拨号 host>[:port]`,与 App 连接的 URL 一致——不含任何 ":443 迷信") - - [ ] 探针全通 → `Result.success(Host)`;探针成功路径里 attach(null) 后必发 kill(不留孤儿会话) + - [ ] 探针全通 → `Result.success(HostEndpoint)`(契约裁定:Host 由 T-iOS-12 VM 构造,见 §3.4 注);探针成功路径里 attach(null) 后必发 kill(不留孤儿会话) - [ ] 超时(FakeClock)→ `.timeout` - **Steps(实现, GREEN)**: [ ] §3.4 签名 [ ] 服务器约束写进 doc comment(按端点精确):`hookDecision` body ≤ 4 KB(src/server.ts:503)、≤10 次/分/IP(src/server.ts:72,504-508)——P1 增量端点的限额归 T-iOS-38 - **Accept**: `swift test --package-path ios/Packages/APIClient` 全绿;覆盖率 ≥ 80% diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index b540ae2..7fd980e 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -32,7 +32,13 @@ - **T-iOS-3 WireProtocol(冻结)**: §3.1 全类型 + Tunables §3.2.1 全表;**59 tests 全绿、覆盖率 100%**(llvm-cov 226/226);300 轮 roundtrip property + 500 轮 fuzz(decodeServer 永不 crash);**跨实现实测**: Swift encoder 输出经 tsx 喂给真 `src/protocol.ts` parseClientMessage → 13 帧+mode 顶层恢复全 PASS。自此冻结。 - **T-iOS-4 TestSupport**: FakeTransport(actor,scripted failures+按连接记帧)/FakeClock(class+锁,`waitForSleepers` 确定性栅栏,零真实等待)/FakeHTTPTransport(记录 headers 供 Origin-iff-G 断言);3 smoke 全绿。 - **verify(独立 agent 复跑)**: 模拟器构建 exit 0;59+3 tests;覆盖率 100%;spike 6 tests+1 known issue;`git status` 零越界。 -- 待续: W1(T-iOS-5/6/7/8 并行)→ W2 → W3 → W4 → W5。**偏差(orchestrator 决策)**: W1+ 并行 builder 不用 git worktree(Workflow worktree 合并回主树增加失败面),改为主树直跑——文件互斥 + SwiftPM .build 锁自然串行化构建,禁 agent 碰 git;每波末独立 verify + orchestrator commit 兜底。 +- **[x] W1 叶子包(T-iOS-5/6/7/8,4 builders;编排 [5→6 串行] ∥ 7 ∥ 8——5/6 同在 SessionCore 包,主树并行会互坏编译)**: + - **T-iOS-5 ReconnectMachine+PingScheduler**: 纯 reducer,§3.2 签名逐字;backoff 1s→…→30s 封顶镜像 terminal-session.ts(连上才归零,retry/foregrounded 不重置阶梯——防狂按打穿);Ping 25s/连续 2 miss→connectionLost/取消与断线显式区分。16 测,0 真实等待,文件级覆盖 100%/95%。 + - **T-iOS-6 GateState+AwayDigest**: epoch 语义逐行镜像 terminal-session.ts:306-311(上升沿 +1、持续帧刷新 detail、下降沿不重置计数器);`canDecide(epoch:)` 防误批;affordance 映射逐字对齐 tabs.ts:345-347(绝不发 raw auto);digest 词表与 `TimelineEvent.knownClasses` 有相等性绊线测试;防御:乱序容忍、Date→ms clamp 不 trap。25 测,两文件 100%。 + - **T-iOS-7 HostRegistry**: §3.3 契约;KeychainHostStore 经 SecItemShim 缝(swift test 打 fake;真 shim kSecUseDataProtectionKeychain+AfterFirstUnlockThisDeviceOnly,属性字典单点构造+逐字单测);InMemoryHostStore 入 Sources 供 App-VM 测试。30 测,own-Sources 覆盖 88.1%。 + - **T-iOS-8 APIClient+探针**: 44 测先全绿后 **`[!] BLOCKED` 正确上报冻结契约自相矛盾**——§3.4 `runPairingProbe → Result` 中 Host 是 §3.3 HostRegistry 类型,而 APIClient 依赖边仅 WireProtocol(§1 零耦合),签名无法编译(两轮 review 均漏)。**裁定(orchestrator,三选一取 c)**: 探针职责=验证 endpoint → 返回 `Result`,Host{id,name} 由 T-iOS-12 VM 构造(id/name 本非探针所知);同时按法定路径加 `Tunables.pairingProbeTimeout`(10s,§3.2.1 新增行+绊线断言)。裁定已写回 PLAN §3.4/§3.2.1/T-iOS-8,公开包装+冒烟测试由 orchestrator 落地。Origin **iff**-G 入测试名;探针②成功后必带 Origin kill 不留孤儿;403 kill→originRejected(HTTP 侧 G 守卫也是配对必验项)。45 测,own-Sources 覆盖 98.1%。 + - **W1 验证(orchestrator 亲验)**: 5 包 178 tests 全绿(59+3+41+30+45);覆盖率(own-Sources 口径)SessionCore 99.1%/HostRegistry 88.1%/APIClient 98.1%,全过 80% 门;`git status` 全部在 Owns 内零越界。**发现归 T-iOS-16**: §9 覆盖率命令的 ignore regex 不排静态链入的依赖包源码,CI 接线时须按包过滤(本次已按正确口径亲算)。 +- 待续: W2(T-iOS-9→10,同包串行)→ W3 → W4 → W5。**偏差(orchestrator 决策,W1 起生效)**: 并行 builder 不用 git worktree(Workflow worktree 合并回主树增加失败面),改为主树直跑——文件互斥 + 同包任务串行化,禁 agent 碰 git;每波末独立 verify + orchestrator commit 兜底。 ### ✅ iOS 客户端实施计划 — `docs/PLAN_IOS_CLIENT.md`(规划完成 — 2026-07-04,main,未提交;纯文档,零代码改动) **多 agent 编排 = 一个 ultracode `Workflow`(66 agents,7 阶段)**:5 读者并行(wire contract 逐 file:line / 功能矩阵 / relay 现状 / 模板规范 / iOS 平台联网调研)→ 3 架构师独立竞标(full-native / WKWebView-hybrid / phased)→ 3 评委多视角打分(UX/工程/安全,**phased 26.5 胜出**,native 23.5、hybrid 16.5,21 条 mustSteal 嫁接)→ 综合简报 → 写手出稿(843 行)→ **4 评审交叉验证**(对照 src/ 源码逐条核实 / iOS API 联网核查 / 安全 / 计划质量)→ 每条 finding 对抗式 verify(CRITICAL/HIGH 双票)→ fixer 应用。 diff --git a/ios/Packages/APIClient/Sources/APIClient/APIClient.swift b/ios/Packages/APIClient/Sources/APIClient/APIClient.swift new file mode 100644 index 0000000..c989709 --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/APIClient.swift @@ -0,0 +1,137 @@ +import Foundation +import WireProtocol + +/// Typed client for the server's HTTP surface (frozen contract, plan §3.4). +/// +/// **Origin 铁律(安全语义,plan §3.4/§5.1)**: only the two G (state-changing) +/// endpoints stamp `Origin: endpoint.originHeader`; the four RO GETs never do. +/// Stamping lives in ONE place — `APIRoute.urlRequest(for:)` — and the value is +/// single-point derived by `HostEndpoint` (never hand-assembled). +/// +/// The server is an UNTRUSTED input source at this boundary (plan §4): bodies +/// are decoded tolerantly (malformed entries dropped), statuses are mapped to +/// explicit `APIClientError`s, and nothing here ever crashes on bad input. +public struct APIClient: Sendable { + public let endpoint: HostEndpoint + private let http: any HTTPTransport + + public init(endpoint: HostEndpoint, http: any HTTPTransport) { + self.endpoint = endpoint + self.http = http + } + + // MARK: - RO (read-only — NO Origin header) + + /// `GET /live-sessions` (src/server.ts:257-259) — the discovery list every + /// device polls (`Tunables.listPollInterval` cadence, T-iOS-13). + public func liveSessions() async throws -> [LiveSessionInfo] { + let (data, response) = try await perform(Endpoints.liveSessions()) + guard response.statusCode == HTTPStatus.ok else { + throw APIClientError.unexpectedStatus(response.statusCode) + } + return try LiveSessionInfo.decodeList(from: data) + } + + /// `GET /live-sessions/:id/preview` (src/server.ts:314-326) — ring-buffer + /// tail for a read-only thumbnail; no attach, no client registered. + public func preview(id: UUID) async throws -> SessionPreview { + let (data, response) = try await perform(Endpoints.preview(id: id)) + try Self.requireOK(response) + guard let preview = try? JSONDecoder().decode(SessionPreview.self, from: data) else { + throw APIClientError.invalidResponseBody + } + return preview + } + + /// `GET /live-sessions/:id/events` (src/server.ts:528-540) — the A4 + /// activity timeline. Decoded via WireProtocol's tolerant helper: unknown + /// `class` entries are dropped, a non-array body yields `[]` (the server + /// itself replies `[]` when timeline capture is disabled). + public func events(id: UUID) async throws -> [TimelineEvent] { + let (data, response) = try await perform(Endpoints.events(id: id)) + try Self.requireOK(response) + return TimelineEvent.decodeList(from: data) + } + + /// `GET /config/ui` (src/server.ts:609-612) — `{allowAutoMode}`. Reserved + /// for a future permission-mode switcher (plan §3.4); the plan-gate + /// three-way UI does not consume it. + public func uiConfig() async throws -> UiConfig { + let (data, response) = try await perform(Endpoints.uiConfig()) + try Self.requireOK(response) + guard let config = try? JSONDecoder().decode(UiConfig.self, from: data) else { + throw APIClientError.invalidResponseBody + } + return config + } + + // MARK: - G (state-changing — Origin required, byte-equal) + + /// `DELETE /live-sessions/:id` (src/server.ts:354-358). 403 = the Origin + /// guard rejected us (src/server.ts:332-339); 404 = session already gone. + public func killSession(id: UUID) async throws { + let (_, response) = try await perform(Endpoints.killSession(id: id)) + switch response.statusCode { + case HTTPStatus.noContent: + return + case HTTPStatus.notFound: + throw APIClientError.sessionNotFound + case HTTPStatus.forbidden: + throw APIClientError.forbidden + default: + throw APIClientError.unexpectedStatus(response.statusCode) + } + } + + /// `POST /hook/decision` (src/server.ts:503-525) — resolve a held remote + /// approval with a single-use capability `token` (arrives via push payload + /// only; NEVER persist it, plan §5.3). Server limits: body ≤ 4 KB + /// (src/server.ts:503), ≤ 10 requests/min/IP (src/server.ts:72,504-508). + /// 403 → `.decisionRejected` (token expired / mismatched / already used). + public func hookDecision(sessionId: UUID, decision: HookDecision, token: String) async throws { + let route = try Endpoints.hookDecision( + sessionId: sessionId, decision: decision, token: token + ) + let (_, response) = try await perform(route) + switch response.statusCode { + case HTTPStatus.noContent: + return + case HTTPStatus.forbidden: + throw APIClientError.decisionRejected + case HTTPStatus.tooManyRequests: + throw APIClientError.rateLimited + default: + throw APIClientError.unexpectedStatus(response.statusCode) + } + } + + // MARK: - Internals + + private func perform(_ route: APIRoute) async throws -> (Data, HTTPURLResponse) { + guard let request = route.urlRequest(for: endpoint) else { + throw APIClientError.invalidRequest + } + return try await http.send(request) + } + + /// 200 → ok; 404 → `.sessionNotFound`; anything else → `.unexpectedStatus`. + private static func requireOK(_ response: HTTPURLResponse) throws { + switch response.statusCode { + case HTTPStatus.ok: + return + case HTTPStatus.notFound: + throw APIClientError.sessionNotFound + default: + throw APIClientError.unexpectedStatus(response.statusCode) + } + } +} + +/// Named HTTP status codes used by the client (no magic numbers, plan §4). +private enum HTTPStatus { + static let ok = 200 + static let noContent = 204 + static let forbidden = 403 + static let notFound = 404 + static let tooManyRequests = 429 +} diff --git a/ios/Packages/APIClient/Sources/APIClient/Endpoints.swift b/ios/Packages/APIClient/Sources/APIClient/Endpoints.swift new file mode 100644 index 0000000..f4f93a7 --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/Endpoints.swift @@ -0,0 +1,131 @@ +import Foundation +import WireProtocol + +/// HTTP method whitelist for the six frozen endpoints (plan §3.4). +enum HTTPMethod: String, Sendable { + case get = "GET" + case post = "POST" + case delete = "DELETE" +} + +/// Whether a route mutates server state — THE security split (plan §3.4/§5.1): +/// `Origin` is stamped **iff** `.guarded`. +enum OriginPolicy: Sendable, Equatable { + /// Read-only GET — MUST NOT carry Origin. If the server ever reclassifies + /// a RO route as guarded, integration tests go red instead of passing by + /// coincidence (§3.4 铁律). + case readOnly + /// State-changing — MUST carry `Origin: endpoint.originHeader`, byte-equal; + /// the server rejects missing/foreign Origin with 403 (src/server.ts:332-339). + case guarded +} + +/// Header/content-type names used by the builder (no magic strings inline). +enum HeaderName { + static let origin = "Origin" + static let contentType = "Content-Type" +} + +enum ContentTypeValue { + static let json = "application/json" +} + +/// One buildable API route — an immutable snapshot; building never mutates. +struct APIRoute: Sendable, Equatable { + let method: HTTPMethod + let path: String + let originPolicy: OriginPolicy + let body: Data? + + /// Build the `URLRequest` against `endpoint.baseURL`'s scheme/host/port: + /// the path is REPLACED and query/fragment/credentials are dropped — the + /// same derivation philosophy as `HostEndpoint.wsURL`. Origin stamping + /// happens HERE and only here (single point; hand-stamping elsewhere is a + /// review CRITICAL, plan §5.1). + func urlRequest(for endpoint: HostEndpoint) -> URLRequest? { + guard var components = URLComponents( + url: endpoint.baseURL, resolvingAgainstBaseURL: true + ) else { return nil } + components.path = path + components.query = nil + components.fragment = nil + components.user = nil + components.password = nil + guard let url = components.url else { return nil } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + if originPolicy == .guarded { + request.setValue(endpoint.originHeader, forHTTPHeaderField: HeaderName.origin) + } + if let body { + request.httpBody = body + request.setValue(ContentTypeValue.json, forHTTPHeaderField: HeaderName.contentType) + } + return request + } +} + +/// Builders for the six frozen endpoints (plan §3.4). Route table +/// (verified against src/server.ts): +/// - RO — `GET /live-sessions` (:257) · `GET /live-sessions/:id/preview` (:314) +/// · `GET /live-sessions/:id/events` (:528) · `GET /config/ui` (:609) +/// - G — `DELETE /live-sessions/:id` (:354) · `POST /hook/decision` (:503) +enum Endpoints { + static func liveSessions() -> APIRoute { + APIRoute(method: .get, path: "/live-sessions", originPolicy: .readOnly, body: nil) + } + + static func preview(id: UUID) -> APIRoute { + APIRoute( + method: .get, path: "/live-sessions/\(pathId(id))/preview", + originPolicy: .readOnly, body: nil + ) + } + + static func events(id: UUID) -> APIRoute { + APIRoute( + method: .get, path: "/live-sessions/\(pathId(id))/events", + originPolicy: .readOnly, body: nil + ) + } + + static func uiConfig() -> APIRoute { + APIRoute(method: .get, path: "/config/ui", originPolicy: .readOnly, body: nil) + } + + static func killSession(id: UUID) -> APIRoute { + APIRoute( + method: .delete, path: "/live-sessions/\(pathId(id))", + originPolicy: .guarded, body: nil + ) + } + + /// Body is exactly `{sessionId,decision,token}`. Server-enforced limits + /// (they are the server's, not ours — documented for callers): + /// body ≤ 4 KB (src/server.ts:503) and ≤ 10 requests/min/IP + /// (src/server.ts:72,504-508). + static func hookDecision( + sessionId: UUID, decision: HookDecision, token: String + ) throws -> APIRoute { + let body = try JSONEncoder().encode(HookDecisionBody( + sessionId: pathId(sessionId), decision: decision.rawValue, token: token + )) + return APIRoute( + method: .post, path: "/hook/decision", originPolicy: .guarded, body: body + ) + } + + /// Server session ids are lowercase `crypto.randomUUID()` strings and + /// `:id` route params are matched as EXACT strings — always serialize + /// lowercase (same rule as `MessageCodec`'s attach encoding). + private static func pathId(_ id: UUID) -> String { + id.uuidString.lowercased() + } + + private struct HookDecisionBody: Encodable { + let sessionId: String + let decision: String + let token: String + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/Models.swift b/ios/Packages/APIClient/Sources/APIClient/Models.swift new file mode 100644 index 0000000..b9312bd --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/Models.swift @@ -0,0 +1,187 @@ +import Foundation +import WireProtocol + +// MARK: - LiveSessionInfo + +/// One running (or just-exited) server session from `GET /live-sessions` +/// (read-only discovery — NO Origin header, src/server.ts:257-259). Mirrors +/// `src/types.ts:246-256` field-for-field; `telemetry` is `?: … | null` there +/// and stays optional here. +/// +/// Decoding is tolerant at the UNTRUSTED server boundary (plan §4): +/// - identity/geometry (`id`/`createdAt`/`clientCount`/`exited`/`cols`/`rows`) +/// are required — an entry missing them is dropped by `decodeList`; +/// - an unrecognized `status` string maps to `.unknown` — a future server +/// status value must not make a running session invisible in the list; +/// - wrong-typed/absent `cwd`/`telemetry` degrade to `nil`, never fail the entry. +public struct LiveSessionInfo: Sendable, Equatable { + public let id: UUID + /// Server `Date.now()` at spawn (ms since epoch). + public let createdAt: Int + /// Devices currently attached (JOIN/mirror semantics, src/session/manager.ts). + public let clientCount: Int + public let status: ClaudeStatus + public let exited: Bool + public let cwd: String? + /// Current PTY size (latest-writer-wins on the server). + public let cols: Int + public let rows: Int + /// Latest statusLine telemetry, if any (B2). + public let telemetry: StatusTelemetry? + + public init( + id: UUID, + createdAt: Int, + clientCount: Int, + status: ClaudeStatus, + exited: Bool, + cwd: String?, + cols: Int, + rows: Int, + telemetry: StatusTelemetry? + ) { + self.id = id + self.createdAt = createdAt + self.clientCount = clientCount + self.status = status + self.exited = exited + self.cwd = cwd + self.cols = cols + self.rows = rows + self.telemetry = telemetry + } +} + +extension LiveSessionInfo: Decodable { + private enum CodingKeys: String, CodingKey { + case id, createdAt, clientCount, status, exited, cwd, cols, rows, telemetry + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + createdAt = try container.decode(Int.self, forKey: .createdAt) + clientCount = try container.decode(Int.self, forKey: .clientCount) + exited = try container.decode(Bool.self, forKey: .exited) + cols = try container.decode(Int.self, forKey: .cols) + rows = try container.decode(Int.self, forKey: .rows) + // Unknown/missing status → .unknown, never drop the whole entry. + let rawStatus = try? container.decode(String.self, forKey: .status) + status = rawStatus.flatMap(ClaudeStatus.init(rawValue:)) ?? .unknown + // Optional/nullable fields: wrong type degrades to nil (tolerant). + cwd = try? container.decode(String.self, forKey: .cwd) + telemetry = try? container.decode(StatusTelemetry.self, forKey: .telemetry) + } + + /// Decode a `/live-sessions` response body. + /// - Non-JSON / non-array top level → `APIClientError.invalidResponseBody` + /// (the pairing probe maps this to `httpOkButNotWebTerminal` — "端口对吗?"). + /// - Malformed elements are dropped one by one, mirroring + /// `TimelineEvent.decodeList`'s per-element tolerance. + static func decodeList(from data: Data) throws -> [LiveSessionInfo] { + guard let entries = try? JSONDecoder().decode([LossyEntry].self, from: data) else { + throw APIClientError.invalidResponseBody + } + return entries.compactMap(\.value) + } + + /// Per-element tolerance shim: a malformed element becomes `nil` instead of + /// failing the whole array decode (same pattern as WireProtocol's helper). + private struct LossyEntry: Decodable { + let value: LiveSessionInfo? + + init(from decoder: any Decoder) { + value = try? LiveSessionInfo(from: decoder) + } + } +} + +// MARK: - SessionPreview + +/// `GET /live-sessions/:id/preview` response (src/server.ts:314-326): the tail +/// of the session's ring buffer for a read-only thumbnail. `data` is opaque +/// ANSI/UTF-8 — feed it to a terminal, never parse it. Read-only → NO Origin. +public struct SessionPreview: Sendable, Equatable, Decodable { + public let id: UUID + public let cols: Int + public let rows: Int + public let data: String + + public init(id: UUID, cols: Int, rows: Int, data: String) { + self.id = id + self.cols = cols + self.rows = rows + self.data = data + } +} + +// MARK: - UiConfig + +/// `GET /config/ui` response (src/server.ts:609-612, src/types.ts:503). +/// Reserved for a future permission-mode switcher (plan §3.4) — it filters the +/// high-risk raw `auto` mode by `allowAutoMode`; the plan-gate three-way UI +/// does NOT consume this. +public struct UiConfig: Sendable, Equatable, Decodable { + public let allowAutoMode: Bool + + public init(allowAutoMode: Bool) { + self.allowAutoMode = allowAutoMode + } +} + +// MARK: - HookDecision + +/// The two verdicts `POST /hook/decision` accepts — anything else is a 400 +/// (src/server.ts:513-517). +public enum HookDecision: String, Sendable, Equatable, CaseIterable { + case allow, deny +} + +// MARK: - APIClientError + +/// Typed failures for `APIClient` calls (explicit error handling, plan §4). +/// Transport-level errors thrown by `HTTPTransport.send` propagate UNwrapped — +/// `PairingError.classify` reads their NSError shape during pairing. +public enum APIClientError: Error, Equatable, Sendable { + /// The request could not be built from the endpoint (should never happen + /// for a validated `HostEndpoint`; surfaced instead of force-unwrapping). + case invalidRequest + /// A 2xx arrived but the body is not the endpoint's shape. For + /// `/live-sessions` this is the pairing probe's "the port speaks HTTP but + /// is not web-terminal" signal. + case invalidResponseBody + /// 404 on a `/live-sessions/:id/*` route — the session is gone + /// (exited / reaped / already killed). + case sessionNotFound + /// 403 from a G route's Origin guard (src/server.ts:332-339). + case forbidden + /// 403 from `POST /hook/decision` (src/server.ts:522-525): the capability + /// token is missing / mismatched / STALE — tokens are single-use and + /// expiring by design (SEC-C1/M1). User-facing 话术 in `message`. + case decisionRejected + /// 429: `POST /hook/decision` is limited to 10 requests/min/IP + /// (src/server.ts:72,504-508 — fixed server security policy). + case rateLimited + /// Any other non-success status code. + case unexpectedStatus(Int) + + /// User-facing message (UI 层可直接展示的话术,plan §4 错误处理). + public var message: String { + switch self { + case .invalidRequest: + "无法构造请求(主机地址异常)。" + case .invalidResponseBody: + "服务器响应不是预期格式——端口对吗?" + case .sessionNotFound: + "会话已不存在(可能已退出或被清理)。" + case .forbidden: + "服务器拒绝了此来源(Origin 校验未通过)。" + case .decisionRejected: + "审批令牌已过期或已被处理——请回到终端里直接批准/拒绝。" + case .rateLimited: + "操作过于频繁(服务器限 10 次/分钟),请稍后再试。" + case .unexpectedStatus(let status): + "服务器返回了意外状态码 \(status)。" + } + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/PairingError.swift b/ios/Packages/APIClient/Sources/APIClient/PairingError.swift new file mode 100644 index 0000000..1b5035f --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/PairingError.swift @@ -0,0 +1,187 @@ +import Foundation +import WireProtocol + +/// Error taxonomy for the pairing probe (frozen contract, plan §3.4 +/// `[S:hybrid]`) — each case maps one probe failure mode to actionable UI copy +/// (T-iOS-12 renders these inline). +public enum PairingError: Error, Equatable, Sendable { + /// iOS Local Network permission denied: connects fail with POSIX + /// "Network is down" (ENETDOWN) while the target is a LAN/private host + /// (plan §5.2). UI: deep-link to 设置 → 隐私 → 本地网络. + case localNetworkDenied + /// Nothing answered (connection refused / no route / DNS failure…). + case hostUnreachable(underlying: String) + /// The port speaks HTTP but `GET /live-sessions` did not return the + /// web-terminal shape (e.g. an HTML admin page) — "端口对吗?". + case httpOkButNotWebTerminal + /// The WS upgrade (or the guarded kill round-trip) was rejected — the + /// host's Origin whitelist does not contain our dialed origin. `hint` + /// carries the exact `ALLOWED_ORIGINS=://<拨号 host>[:port]` line + /// (default ports omitted — the server normalizes both sides via + /// `new URL()`, so there is NO ":443 迷信", plan §5.1). + case originRejected(hint: String) + /// ATS blocked cleartext HTTP to a host outside the app's CIDR exception + /// list (NSURLError -1022, plan §5.2). + case atsBlocked(host: String) + /// TLS negotiation / certificate failure on an https/wss target. + case tlsFailure + /// The injected probe deadline elapsed (or the transport timed out). + case timeout +} + +extension PairingError { + /// Actionable copy for `originRejected` — always derived from the SINGLE + /// origin source (`endpoint.originHeader`), never hand-assembled. + static func originRejectedHint(for endpoint: HostEndpoint) -> String { + "服务器拒绝了这个来源。请在主机上设置 ALLOWED_ORIGINS=\(endpoint.originHeader)" + + "(与 App 连接的 URL 完全一致)后重启 web-terminal,再重试配对。" + } + + /// Classify a transport-level error thrown by `HTTPTransport.send` / + /// `TermTransport.connect` into the probe taxonomy. Walks the NSError + /// underlying chain (bounded) so wrapped POSIX causes are seen. + /// + /// - Parameter unrecognizedFallback: used by probe step ② — after step ① + /// proved the host reachable AND web-terminal-shaped, an upgrade failure + /// with no recognizable network cause is, by elimination, the server's + /// Origin 401 (its only upgrade-reject path, src/server.ts:646-651). + static func classify( + _ error: any Error, + endpoint: HostEndpoint, + unrecognizedFallback: PairingError? = nil + ) -> PairingError { + let chain = underlyingChain(of: error as NSError) + let host = endpoint.baseURL.host ?? "" + if chain.contains(where: isATSBlockError) { + return .atsBlocked(host: host) + } + if chain.contains(where: isTimeoutError) { + return .timeout + } + if chain.contains(where: isTLSError) { + return .tlsFailure + } + if chain.contains(where: isPosixNetworkDownError) { + // plan §5.2: Local-Network-denied presents as POSIX "Network is + // down" — but only claim it for LAN-class targets. + return isPrivateOrLocalHost(host) + ? .localNetworkDenied + : .hostUnreachable(underlying: describe(error)) + } + if chain.contains(where: isConnectivityError) { + return .hostUnreachable(underlying: describe(error)) + } + return unrecognizedFallback ?? .hostUnreachable(underlying: describe(error)) + } + + // MARK: - Error-shape predicates + + private static func isATSBlockError(_ error: NSError) -> Bool { + error.domain == NSURLErrorDomain + && error.code == NSURLErrorAppTransportSecurityRequiresSecureConnection + } + + private static func isTimeoutError(_ error: NSError) -> Bool { + (error.domain == NSURLErrorDomain && error.code == NSURLErrorTimedOut) + || (error.domain == NSPOSIXErrorDomain + && error.code == Int(POSIXErrorCode.ETIMEDOUT.rawValue)) + } + + private static let tlsErrorCodes: Set = [ + NSURLErrorSecureConnectionFailed, + NSURLErrorServerCertificateHasBadDate, + NSURLErrorServerCertificateUntrusted, + NSURLErrorServerCertificateHasUnknownRoot, + NSURLErrorServerCertificateNotYetValid, + NSURLErrorClientCertificateRejected, + NSURLErrorClientCertificateRequired, + ] + + private static func isTLSError(_ error: NSError) -> Bool { + error.domain == NSURLErrorDomain && tlsErrorCodes.contains(error.code) + } + + private static func isPosixNetworkDownError(_ error: NSError) -> Bool { + error.domain == NSPOSIXErrorDomain + && error.code == Int(POSIXErrorCode.ENETDOWN.rawValue) + } + + private static let connectivityURLErrorCodes: Set = [ + NSURLErrorCannotConnectToHost, + NSURLErrorCannotFindHost, + NSURLErrorDNSLookupFailed, + NSURLErrorNetworkConnectionLost, + NSURLErrorNotConnectedToInternet, + ] + + private static let connectivityPosixCodes: Set = [ + Int(POSIXErrorCode.ECONNREFUSED.rawValue), + Int(POSIXErrorCode.EHOSTUNREACH.rawValue), + Int(POSIXErrorCode.EHOSTDOWN.rawValue), + Int(POSIXErrorCode.ENETUNREACH.rawValue), + ] + + private static func isConnectivityError(_ error: NSError) -> Bool { + (error.domain == NSURLErrorDomain && connectivityURLErrorCodes.contains(error.code)) + || (error.domain == NSPOSIXErrorDomain && connectivityPosixCodes.contains(error.code)) + } + + // MARK: - Helpers + + /// Bounded walk of `NSUnderlyingErrorKey` (defensive: never trust error + /// graphs not to cycle). + private static let maxUnderlyingDepth = 8 + + private static func underlyingChain(of error: NSError) -> [NSError] { + var chain: [NSError] = [] + var current: NSError? = error + while let nsError = current, chain.count < maxUnderlyingDepth { + chain.append(nsError) + current = nsError.userInfo[NSUnderlyingErrorKey] as? NSError + } + return chain + } + + private static func describe(_ error: any Error) -> String { + (error as NSError).localizedDescription + } + + /// LAN-class targets for the `localNetworkDenied` mapping: RFC1918, + /// link-local 169.254/16, loopback, CGNAT 100.64/10 (Tailscale), and + /// mDNS `.local` names. (T-iOS-12's warning TIERS are separate logic — + /// this predicate only gates the Local-Network-permission diagnosis.) + static func isPrivateOrLocalHost(_ host: String) -> Bool { + let lowered = host.lowercased() + if lowered == "localhost" || lowered.hasSuffix(".local") { + return true + } + guard let octets = ipv4Octets(lowered) else { + return false + } + switch (octets[0], octets[1]) { + case (10, _), (127, _): // RFC1918 10/8 · loopback 127/8 + return true + case (192, 168), (169, 254): // RFC1918 192.168/16 · link-local + return true + case (172, 16...31): // RFC1918 172.16/12 + return true + case (100, 64...127): // CGNAT 100.64/10 (Tailscale) + return true + default: + return false + } + } + + private static let ipv4OctetCount = 4 + private static let ipv4OctetRange = 0...255 + + private static func ipv4Octets(_ host: String) -> [Int]? { + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == ipv4OctetCount else { return nil } + let octets = parts.compactMap { Int($0) } + guard octets.count == ipv4OctetCount, + octets.allSatisfy({ ipv4OctetRange.contains($0) }) + else { return nil } + return octets + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/PairingProbe.swift b/ios/Packages/APIClient/Sources/APIClient/PairingProbe.swift new file mode 100644 index 0000000..a9b0727 --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/PairingProbe.swift @@ -0,0 +1,164 @@ +import Foundation +import WireProtocol + +/// Two-step pairing probe (plan §3.4): +/// ① `GET /live-sessions` — NO Origin (reachability + web-terminal shape). +/// ② WS `attach(null)` → adopt `attached` → close → **immediately +/// `DELETE /live-sessions/:id` WITH Origin** (verifies `isOriginAllowed` on +/// both the upgrade and the guarded-HTTP side, and never leaks the probe's +/// orphan session). +/// +/// Callers MUST run this only after the user confirmed a scanned host +/// (T-iOS-12) — step ① already talks to the network and step ② spawns a PTY +/// on the target machine. +/// +/// CONTRACT RULING (orchestrator, 2026-07-04 — resolves the T-iOS-8 BLOCKED): +/// §3.4's original `Result` payload was self-contradictory (`Host` is +/// a §3.3 HostRegistry type; this package depends only on WireProtocol, §1/§6 +/// zero leaf-coupling). Amended §3.4: the probe's job is validating an +/// endpoint, so the success payload IS the probed `HostEndpoint`; constructing +/// `Host{id,name}` belongs to the pairing UI (T-iOS-12 PairingViewModel — +/// id/name are not the probe's to know). Public deadline comes from +/// `Tunables.pairingProbeTimeout` (§3.2.1 addition, same ruling). +/// +/// - Parameters: +/// - clock: injected for deterministic deadline tests (`FakeClock`); +/// production passes `ContinuousClock()`. +/// - timeout: overall probe deadline → `.timeout`. `nil` = no app-level +/// deadline (the transport's own timeouts still apply). The production +/// default is a UI-layer decision (T-iOS-12); a shared +/// `Tunables.pairingProbeTimeout` would be a T-iOS-3 contract addition. +func runPairingProbeCore( + endpoint: HostEndpoint, + http: any HTTPTransport, + ws: any TermTransport, + clock: any Clock, + timeout: Duration? +) async -> Result { + guard let timeout else { + return await performProbe(endpoint: endpoint, http: http, ws: ws) + } + return await withTaskGroup( + of: Result.self + ) { group in + group.addTask { + await performProbe(endpoint: endpoint, http: http, ws: ws) + } + group.addTask { + // Cancellation (probe won) also lands here; the value is discarded. + try? await clock.sleep(for: timeout, tolerance: nil) + return .failure(.timeout) + } + guard let first = await group.next() else { + return .failure(.timeout) + } + group.cancelAll() + return first + } +} + +/// Public probe entry (§3.4 as amended by the 2026-07-04 contract ruling). +/// Wall-clock deadline `Tunables.pairingProbeTimeout` on `ContinuousClock`; +/// tests drive the deterministic core via an injected `FakeClock`. +public func runPairingProbe( + endpoint: HostEndpoint, + http: any HTTPTransport, + ws: any TermTransport +) async -> Result { + await runPairingProbeCore( + endpoint: endpoint, + http: http, + ws: ws, + clock: ContinuousClock(), + timeout: Tunables.pairingProbeTimeout + ) +} + +// MARK: - Probe body + +private func performProbe( + endpoint: HostEndpoint, + http: any HTTPTransport, + ws: any TermTransport +) async -> Result { + let api = APIClient(endpoint: endpoint, http: http) + + // ① Reachability + shape. Any HTTP-level answer that isn't the + // /live-sessions array shape means "some other service" → 端口对吗? + do { + _ = try await api.liveSessions() + } catch is APIClientError { + return .failure(.httpOkButNotWebTerminal) + } catch { + return .failure(PairingError.classify(error, endpoint: endpoint)) + } + + // ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401 + // (src/server.ts:646-651), so after ① passed, an unrecognizable connect + // failure is classified as originRejected. + let connection: TransportConnection + do { + connection = try await ws.connect(to: endpoint) + } catch { + return .failure(PairingError.classify( + error, endpoint: endpoint, + unrecognizedFallback: .originRejected( + hint: PairingError.originRejectedHint(for: endpoint) + ) + )) + } + + let adoption = await adoptAttachedSession(over: connection) + await connection.close() + switch adoption { + case .failure(let failure): + return .failure(failure) + case .success(let sessionId): + return await killProbeSession(id: sessionId, api: api, endpoint: endpoint) + } +} + +/// Send `attach(null)` (explicit JSON `"sessionId":null` — the server requires +/// the key) and wait for the server-issued `attached` id, skipping any other +/// or undecodable frames (untrusted server; only `attached` matters here). +private func adoptAttachedSession( + over connection: TransportConnection +) async -> Result { + do { + try await connection.send(MessageCodec.encode(.attach(sessionId: nil, cwd: nil))) + for try await frame in connection.frames { + if case .attached(let sessionId) = MessageCodec.decodeServer(frame) { + return .success(sessionId) + } + } + // Upgrade succeeded, so this is NOT an Origin problem: a socket that + // closes/errors before speaking our protocol is not web-terminal. + return .failure(.httpOkButNotWebTerminal) + } catch { + return .failure(.httpOkButNotWebTerminal) + } +} + +/// The guarded kill round-trip is part of pairing verification itself +/// (`DELETE` exercises the HTTP-side Origin guard that `hookDecision` will +/// need later) — AND it guarantees the probe leaves no orphan session behind. +private func killProbeSession( + id: UUID, + api: APIClient, + endpoint: HostEndpoint +) async -> Result { + do { + try await api.killSession(id: id) + } catch APIClientError.sessionNotFound { + // Already gone (exited between attach and kill) — the goal state. + } catch APIClientError.forbidden { + return .failure(.originRejected( + hint: PairingError.originRejectedHint(for: endpoint) + )) + } catch let apiError as APIClientError { + return .failure(.hostUnreachable(underlying: apiError.message)) + } catch { + return .failure(PairingError.classify(error, endpoint: endpoint)) + } + return .success(endpoint) +} diff --git a/ios/Packages/APIClient/Tests/APIClientTests/ModelDecodingTests.swift b/ios/Packages/APIClient/Tests/APIClientTests/ModelDecodingTests.swift new file mode 100644 index 0000000..1a1866f --- /dev/null +++ b/ios/Packages/APIClient/Tests/APIClientTests/ModelDecodingTests.swift @@ -0,0 +1,313 @@ +import Foundation +import Testing +import TestSupport +import WireProtocol +import APIClient + +/// T-iOS-8 · 响应模型解码。服务器是**不可信输入源**(plan §4): +/// - `LiveSessionInfo` 镜像 src/types.ts:246-256,`telemetry` 可缺失/为 null; +/// - 未知 `status` → `.unknown`(新状态值不得让运行中的会话从列表里消失); +/// - 畸形条目丢弃、整体不 crash;非数组 body → 显式错误(探针的"端口对吗?"信号); +/// - `TimelineEvent` 经 WireProtocol 的 `decodeList` 帮助器,未知 `class` 该条丢弃。 +struct ModelDecodingTests { + private static let base = "http://192.168.1.5:3000" + private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b" + + private static let fullSessionJSON = """ + {"id":"\(sessionIdString)","createdAt":1720000000000,"clientCount":2,\ + "status":"working","exited":false,"cwd":"/Users/dev/proj","cols":120,"rows":40,\ + "telemetry":{"contextUsedPct":42.5,"costUsd":1.25,"linesAdded":10,"linesRemoved":2,\ + "model":"Opus","effort":"high",\ + "pr":{"number":7,"url":"https://github.com/x/y/pull/7","reviewState":"APPROVED"},\ + "rate":{"fiveHourPct":12.5,"sevenDayPct":40.0},"at":1720000005000}} + """ + + private static let noTelemetrySessionJSON = """ + {"id":"\(sessionIdString)","createdAt":1720000000000,"clientCount":0,\ + "status":"idle","exited":false,"cwd":null,"cols":80,"rows":24} + """ + + private func makeClient(_ http: FakeHTTPTransport) throws -> APIClient { + let url = try #require(URL(string: Self.base)) + let endpoint = try #require(HostEndpoint(baseURL: url)) + return APIClient(endpoint: endpoint, http: http) + } + + private func routeURL(_ path: String) throws -> URL { + try #require(URL(string: Self.base + path)) + } + + private func fetchSessions(body: String) async throws -> [LiveSessionInfo] { + let http = FakeHTTPTransport() + let client = try makeClient(http) + await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data(body.utf8)) + return try await client.liveSessions() + } + + // MARK: - LiveSessionInfo + + @Test("LiveSessionInfo 全字段样本解码(src/types.ts:246-256 形状,含完整 telemetry)") + func liveSessionInfoDecodesFullSample() async throws { + // Act + let sessions = try await fetchSessions(body: "[\(Self.fullSessionJSON)]") + + // Assert + #expect(sessions.count == 1) + let session = try #require(sessions.first) + #expect(session.id == UUID(uuidString: Self.sessionIdString)) + #expect(session.createdAt == 1_720_000_000_000) + #expect(session.clientCount == 2) + #expect(session.status == .working) + #expect(session.exited == false) + #expect(session.cwd == "/Users/dev/proj") + #expect(session.cols == 120) + #expect(session.rows == 40) + let telemetry = try #require(session.telemetry) + #expect(telemetry.contextUsedPct == 42.5) + #expect(telemetry.costUsd == 1.25) + #expect(telemetry.linesAdded == 10) + #expect(telemetry.linesRemoved == 2) + #expect(telemetry.model == "Opus") + #expect(telemetry.effort == "high") + #expect(telemetry.pr?.number == 7) + #expect(telemetry.rate?.fiveHourPct == 12.5) + #expect(telemetry.at == 1_720_000_005_000) + // 与 memberwise 构造的期望值整体相等(Equatable 全字段往返) + let expected = LiveSessionInfo( + id: try #require(UUID(uuidString: Self.sessionIdString)), + createdAt: 1_720_000_000_000, + clientCount: 2, + status: .working, + exited: false, + cwd: "/Users/dev/proj", + cols: 120, + rows: 40, + telemetry: StatusTelemetry( + contextUsedPct: 42.5, costUsd: 1.25, linesAdded: 10, linesRemoved: 2, + model: "Opus", effort: "high", + pr: PrInfo(number: 7, url: "https://github.com/x/y/pull/7", reviewState: "APPROVED"), + rate: RateInfo(fiveHourPct: 12.5, sevenDayPct: 40.0), + at: 1_720_000_005_000 + ) + ) + #expect(session == expected) + } + + @Test("LiveSessionInfo telemetry 缺失/cwd 为 null → 解码为 nil,其余字段完整") + func liveSessionInfoToleratesMissingTelemetryAndNullCwd() async throws { + // Act + let sessions = try await fetchSessions(body: "[\(Self.noTelemetrySessionJSON)]") + + // Assert + let session = try #require(sessions.first) + #expect(session.telemetry == nil) + #expect(session.cwd == nil) + #expect(session.status == .idle) + #expect(session.cols == 80) + #expect(session.rows == 24) + } + + @Test("未知 status 字符串 → .unknown(不丢弃整个会话条目)") + func unknownStatusStringMapsToUnknownWithoutDroppingEntry() async throws { + // Arrange + let body = Self.noTelemetrySessionJSON + .replacingOccurrences(of: #""status":"idle""#, with: #""status":"hyperdrive""#) + + // Act + let sessions = try await fetchSessions(body: "[\(body)]") + + // Assert + #expect(sessions.count == 1) + #expect(sessions.first?.status == .unknown) + } + + @Test("畸形条目(非对象/坏 UUID/缺必填)逐条丢弃,合法条目保留,不 crash") + func malformedEntriesAreDroppedWhileValidOnesSurvive() async throws { + // Arrange + let badUUID = Self.noTelemetrySessionJSON + .replacingOccurrences(of: Self.sessionIdString, with: "not-a-uuid") + let body = "[\(Self.fullSessionJSON),42,\(badUUID),{\"id\":\"x\"}]" + + // Act + let sessions = try await fetchSessions(body: body) + + // Assert + #expect(sessions.count == 1) + #expect(sessions.first?.id == UUID(uuidString: Self.sessionIdString)) + } + + @Test("非数组 body(HTML/对象)→ invalidResponseBody 显式错误(探针的 端口对吗 信号)") + func nonArrayBodyThrowsInvalidResponseBody() async throws { + // Act + Assert — HTML(路由器管理页之类) + await #expect(throws: APIClientError.invalidResponseBody) { + _ = try await self.fetchSessions(body: "router admin") + } + // Act + Assert — JSON 但不是数组 + await #expect(throws: APIClientError.invalidResponseBody) { + _ = try await self.fetchSessions(body: #"{"error":"nope"}"#) + } + } + + // MARK: - TimelineEvent(经 WireProtocol helper) + + @Test("TimelineEvent 未知 class 值 → 该条丢弃不 crash(服务器视为不可信)") + func unknownTimelineClassEntriesAreDroppedNotCrashed() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(http) + let body = """ + [{"at":1,"class":"tool","toolName":"Bash","label":"ran Bash"},\ + {"at":2,"class":"quantum","label":"??"},\ + {"at":3,"class":"waiting","label":"waiting for approval"}] + """ + await http.queueSuccess( + url: try routeURL("/live-sessions/\(Self.sessionIdString)/events"), + body: Data(body.utf8) + ) + + // Act + let events = try await client.events(id: try #require(UUID(uuidString: Self.sessionIdString))) + + // Assert + #expect(events.count == 2) + #expect(events.map(\.class) == ["tool", "waiting"]) + #expect(events.first?.toolName == "Bash") + } + + @Test("events 404 → sessionNotFound") + func events404ThrowsSessionNotFound() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(http) + await http.queueSuccess( + url: try routeURL("/live-sessions/\(Self.sessionIdString)/events"), + status: 404 + ) + + // Act + Assert + await #expect(throws: APIClientError.sessionNotFound) { + _ = try await client.events(id: try #require(UUID(uuidString: Self.sessionIdString))) + } + } + + // MARK: - SessionPreview / UiConfig + + @Test("SessionPreview 全字段解码(GET /live-sessions/:id/preview 形状)") + func sessionPreviewDecodesAllFields() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(http) + let body = #"{"id":"\#(Self.sessionIdString)","cols":100,"rows":30,"data":"hello"}"# + await http.queueSuccess( + url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"), + body: Data(body.utf8) + ) + + // Act + let preview = try await client.preview(id: try #require(UUID(uuidString: Self.sessionIdString))) + + // Assert + let expected = SessionPreview( + id: try #require(UUID(uuidString: Self.sessionIdString)), + cols: 100, rows: 30, data: "hello" + ) + #expect(preview == expected) + } + + @Test("preview 200 但 body 非预期形状 → invalidResponseBody") + func previewGarbageBodyThrowsInvalidResponseBody() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(http) + await http.queueSuccess( + url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"), + body: Data("not json".utf8) + ) + + // Act + Assert + await #expect(throws: APIClientError.invalidResponseBody) { + _ = try await client.preview(id: try #require(UUID(uuidString: Self.sessionIdString))) + } + } + + @Test("preview 404 → sessionNotFound") + func preview404ThrowsSessionNotFound() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(http) + await http.queueSuccess( + url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"), + status: 404 + ) + + // Act + Assert + await #expect(throws: APIClientError.sessionNotFound) { + _ = try await client.preview(id: try #require(UUID(uuidString: Self.sessionIdString))) + } + } + + @Test("UiConfig 解码 allowAutoMode(GET /config/ui,src/types.ts:503)") + func uiConfigDecodesAllowAutoMode() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(http) + await http.queueSuccess( + url: try routeURL("/config/ui"), + body: Data(#"{"allowAutoMode":true}"#.utf8) + ) + + // Act + let config = try await client.uiConfig() + + // Assert + #expect(config == UiConfig(allowAutoMode: true)) + } + + @Test("uiConfig 200 但 body 非预期形状 → invalidResponseBody") + func uiConfigGarbageBodyThrowsInvalidResponseBody() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(http) + await http.queueSuccess(url: try routeURL("/config/ui"), body: Data("[]".utf8)) + + // Act + Assert + await #expect(throws: APIClientError.invalidResponseBody) { + _ = try await client.uiConfig() + } + } + + @Test("liveSessions 非 200(500) → unexpectedStatus(500)") + func liveSessions500ThrowsUnexpectedStatus() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(http) + await http.queueSuccess(url: try routeURL("/live-sessions"), status: 500) + + // Act + Assert + await #expect(throws: APIClientError.unexpectedStatus(500)) { + _ = try await client.liveSessions() + } + } + + // MARK: - 话术 + + @Test("decisionRejected 话术明示 token 已过期/已被处理(RED 清单:403 → token 过期话术)") + func decisionRejectedMessageMentionsTokenExpiry() { + #expect(APIClientError.decisionRejected.message.contains("过期")) + } + + @Test("APIClientError 每个 case 都有非空 UI 话术(plan §4:错误处理全面显式)") + func everyAPIClientErrorCaseHasNonEmptyMessage() { + // Arrange + let allCases: [APIClientError] = [ + .invalidRequest, .invalidResponseBody, .sessionNotFound, + .forbidden, .decisionRejected, .rateLimited, .unexpectedStatus(500), + ] + + // Act + Assert + for errorCase in allCases { + #expect(!errorCase.message.isEmpty) + } + #expect(APIClientError.unexpectedStatus(500).message.contains("500")) + } +} diff --git a/ios/Packages/APIClient/Tests/APIClientTests/PairingProbeTests.swift b/ios/Packages/APIClient/Tests/APIClientTests/PairingProbeTests.swift new file mode 100644 index 0000000..2b4f5cf --- /dev/null +++ b/ios/Packages/APIClient/Tests/APIClientTests/PairingProbeTests.swift @@ -0,0 +1,387 @@ +import Foundation +import Testing +import TestSupport +import WireProtocol +@testable import APIClient + +/// T-iOS-8 · 两步配对探针(plan §3.4): +/// ① `GET /live-sessions`(无 Origin,验可达 + 形状) +/// ② WS attach(null) → adopt attached → **立即 DELETE kill(带 Origin,不留孤儿会话)**。 +/// 每种失败模式逐项映射 `PairingError`;超时经注入 clock/deadline(FakeClock,零真实等待)。 +/// +/// 注:§3.4 经 2026-07-04 契约裁定改为返回 `Result` +///(Host{id,name} 由 T-iOS-12 VM 构造)。失败分支经 `@testable` 测内部核心 +/// `runPairingProbeCore`(可注入 FakeClock,零真实等待);公开包装 +/// `runPairingProbe`(ContinuousClock + Tunables.pairingProbeTimeout)另有 +/// 全通冒烟测试兜签名与默认 deadline 接线。 +struct PairingProbeTests { + private static let base = "http://192.168.1.5:3000" + private static let sessionIdString = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + private static let attachedFrame = + #"{"type":"attached","sessionId":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"}"# + + private struct Fixture { + let endpoint: HostEndpoint + let http: FakeHTTPTransport + let ws: FakeTransport + let liveSessionsURL: URL + let killURL: URL + } + + private func makeFixture(base: String = PairingProbeTests.base) throws -> Fixture { + let baseURL = try #require(URL(string: base)) + let endpoint = try #require(HostEndpoint(baseURL: baseURL)) + return Fixture( + endpoint: endpoint, + http: FakeHTTPTransport(), + ws: FakeTransport(), + liveSessionsURL: try #require(URL(string: base + "/live-sessions")), + killURL: try #require(URL(string: base + "/live-sessions/\(Self.sessionIdString)")) + ) + } + + /// 非超时用例统一 timeout: nil —— 探针不进入 race,全路径确定性完成。 + private func runProbe(_ fixture: Fixture, timeout: Duration? = nil, + clock: FakeClock = FakeClock()) async -> Result { + await runPairingProbeCore( + endpoint: fixture.endpoint, http: fixture.http, ws: fixture.ws, + clock: clock, timeout: timeout + ) + } + + // MARK: - 探针①失败分支 + + @Test("探针①连接拒绝 → hostUnreachable,且不触碰 WS(不发任何升级请求)") + func stepOneConnectionRefusedMapsToHostUnreachable() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueFailure( + url: fixture.liveSessionsURL, error: URLError(.cannotConnectToHost) + ) + + // Act + let result = await runProbe(fixture) + + // Assert + guard case .failure(.hostUnreachable) = result else { + Issue.record("expected hostUnreachable, got \(result)") + return + } + #expect(await fixture.ws.connectAttempts.isEmpty) + } + + @Test("探针①返回 HTML → httpOkButNotWebTerminal(端口对吗?)") + func stepOneHTMLBodyMapsToNotWebTerminal() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess( + url: fixture.liveSessionsURL, + body: Data("router admin".utf8) + ) + + // Act + Assert + #expect(await runProbe(fixture) == .failure(.httpOkButNotWebTerminal)) + } + + @Test("探针① 404 → httpOkButNotWebTerminal(HTTP 有应答但不是 web-terminal)") + func stepOne404MapsToNotWebTerminal() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess(url: fixture.liveSessionsURL, status: 404) + + // Act + Assert + #expect(await runProbe(fixture) == .failure(.httpOkButNotWebTerminal)) + } + + @Test("探针① ATS 拦明文(-1022) → atsBlocked(host)") + func stepOneATSBlockMapsToAtsBlocked() async throws { + // Arrange + let fixture = try makeFixture() + let ats = NSError( + domain: NSURLErrorDomain, + code: NSURLErrorAppTransportSecurityRequiresSecureConnection + ) + await fixture.http.queueFailure(url: fixture.liveSessionsURL, error: ats) + + // Act + Assert + #expect(await runProbe(fixture) == .failure(.atsBlocked(host: "192.168.1.5"))) + } + + @Test("探针① POSIX Network-down + LAN IP → localNetworkDenied(本地网络权限被拒)") + func stepOnePosixNetworkDownOnLANMapsToLocalNetworkDenied() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueFailure( + url: fixture.liveSessionsURL, error: Self.networkDownError() + ) + + // Act + Assert + #expect(await runProbe(fixture) == .failure(.localNetworkDenied)) + } + + @Test("探针① POSIX Network-down + 公网 host → hostUnreachable(不误报本地网络权限)") + func stepOnePosixNetworkDownOnPublicHostMapsToHostUnreachable() async throws { + // Arrange + let publicBase = "http://203.0.113.7:3000" + let fixture = try makeFixture(base: publicBase) + await fixture.http.queueFailure( + url: fixture.liveSessionsURL, error: Self.networkDownError() + ) + + // Act + let result = await runProbe(fixture) + + // Assert + guard case .failure(.hostUnreachable) = result else { + Issue.record("expected hostUnreachable, got \(result)") + return + } + } + + @Test("探针① TLS 失败(-1200) → tlsFailure;URLSession 层超时(-1001) → timeout") + func stepOneTLSAndURLTimeoutClassification() async throws { + // Arrange — TLS + let tlsFixture = try makeFixture() + await tlsFixture.http.queueFailure( + url: tlsFixture.liveSessionsURL, error: URLError(.secureConnectionFailed) + ) + // Arrange — URLSession 超时 + let timeoutFixture = try makeFixture() + await timeoutFixture.http.queueFailure( + url: timeoutFixture.liveSessionsURL, error: URLError(.timedOut) + ) + + // Act + Assert + #expect(await runProbe(tlsFixture) == .failure(.tlsFailure)) + #expect(await runProbe(timeoutFixture) == .failure(.timeout)) + } + + // MARK: - 探针②失败分支 + + @Test("探针② WS 升级被拒(401) → originRejected,hint 含 ALLOWED_ORIGINS=<拨号 origin>") + func stepTwoUpgradeRejectionMapsToOriginRejectedWithActionableHint() async throws { + // Arrange — ① 通过,② 升级失败(401 在传输层是无特定形状的连接错误) + let fixture = try makeFixture() + await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8)) + await fixture.ws.scriptConnectFailure() + + // Act + let result = await runProbe(fixture) + + // Assert + guard case .failure(.originRejected(let hint)) = result else { + Issue.record("expected originRejected, got \(result)") + return + } + #expect(hint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000")) + #expect(!hint.contains(":443")) + } + + @Test("originRejected hint 对 https 默认端口不写 :443(与拨号 URL 一致,无端口迷信)") + func originRejectedHintOmitsDefaultPortForHTTPS() async throws { + // Arrange + let fixture = try makeFixture(base: "https://mac.tailnet.ts.net") + await fixture.http.queueSuccess( + url: try #require(URL(string: "https://mac.tailnet.ts.net/live-sessions")), + body: Data("[]".utf8) + ) + await fixture.ws.scriptConnectFailure() + + // Act + let result = await runProbe(fixture) + + // Assert + guard case .failure(.originRejected(let hint)) = result else { + Issue.record("expected originRejected, got \(result)") + return + } + #expect(hint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net")) + #expect(!hint.contains(":443")) + } + + @Test("探针②流在 attached 前结束 → httpOkButNotWebTerminal(升级已成功,非 Origin 问题)") + func stepTwoStreamEndingBeforeAttachedMapsToNotWebTerminal() async throws { + // Arrange — 预先入队的 finish 会在 connect 时立刻灌入新连接 + let fixture = try makeFixture() + await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8)) + await fixture.ws.finishFrames() + + // Act + Assert + #expect(await runProbe(fixture) == .failure(.httpOkButNotWebTerminal)) + } + + // MARK: - 全通 + kill 往返 + + @Test("探针全通 → success,attach 首帧 sessionId 为显式 null,attached 后必发带 Origin 的 kill(不留孤儿会话)") + func fullProbeSuccessKillsProbeSessionWithOrigin() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8)) + await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 204) + await fixture.ws.emit(frame: Self.attachedFrame) + + // Act + let result = await runProbe(fixture) + + // Assert — 成功载荷 + #expect(result == .success(fixture.endpoint)) + // Assert — attach(null) 是唯一 WS 帧,sessionId 键必在且为 null + let sentFrames = await fixture.ws.sentFrames + #expect(sentFrames.count == 1) + let frame = try #require(sentFrames.first) + let object = try #require( + try JSONSerialization.jsonObject(with: Data(frame.utf8)) as? [String: Any] + ) + #expect(object["type"] as? String == "attach") + #expect(object["sessionId"] is NSNull) + // Assert — kill 在 attached 之后立刻发出,带逐字符 Origin + let requests = await fixture.http.recordedRequests + #expect(requests.count == 2) + let kill = try #require(requests.last) + #expect(kill.httpMethod == "DELETE") + #expect(kill.url == fixture.killURL) + #expect(kill.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader) + // Assert — WS 已关闭(探针不持连接) + #expect(await fixture.ws.closeCallCount == 1) + } + + @Test("公开包装 runPairingProbe:全通 → success(endpoint)(§3.4 裁定签名;默认 deadline 走 Tunables.pairingProbeTimeout,快路径不触发)") + func publicWrapperHappyPathReturnsEndpoint() async throws { + // Arrange — 与全通用例同布景;fakes 即时应答,ContinuousClock race 不等待 + let fixture = try makeFixture() + await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8)) + await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 204) + await fixture.ws.emit(frame: Self.attachedFrame) + + // Act + let result = await runPairingProbe( + endpoint: fixture.endpoint, http: fixture.http, ws: fixture.ws + ) + + // Assert + #expect(result == .success(fixture.endpoint)) + #expect(await fixture.ws.closeCallCount == 1) + } + + @Test("attached 前收到 output 帧仍成功(不可信服务器容忍:跳过非 attached 帧)") + func outputFrameBeforeAttachedIsSkippedAndProbeSucceeds() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8)) + await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 204) + await fixture.ws.emit(frame: #"{"type":"output","data":"\u001b[0mreplay"}"#) + await fixture.ws.emit(frame: Self.attachedFrame) + + // Act + Assert + #expect(await runProbe(fixture) == .success(fixture.endpoint)) + } + + @Test("kill 被 G 守卫拒(403) → originRejected(HTTP 侧 Origin 校验也是配对必验项)") + func killRejectedByOriginGuardMapsToOriginRejected() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8)) + await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 403) + await fixture.ws.emit(frame: Self.attachedFrame) + + // Act + let result = await runProbe(fixture) + + // Assert + guard case .failure(.originRejected) = result else { + Issue.record("expected originRejected, got \(result)") + return + } + } + + @Test("kill 返回 404(会话已自行退出) → 仍算全通(目标态就是会话不存在)") + func killReturning404StillCountsAsSuccess() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8)) + await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 404) + await fixture.ws.emit(frame: Self.attachedFrame) + + // Act + Assert + #expect(await runProbe(fixture) == .success(fixture.endpoint)) + } + + // MARK: - classify 分支(POSIX 形状) + + @Test("POSIX ETIMEDOUT → timeout;POSIX ECONNREFUSED → hostUnreachable") + func posixTimeoutAndRefusedClassification() async throws { + // Arrange + let timeoutFixture = try makeFixture() + await timeoutFixture.http.queueFailure( + url: timeoutFixture.liveSessionsURL, + error: NSError(domain: NSPOSIXErrorDomain, code: Int(POSIXErrorCode.ETIMEDOUT.rawValue)) + ) + let refusedFixture = try makeFixture() + await refusedFixture.http.queueFailure( + url: refusedFixture.liveSessionsURL, + error: NSError(domain: NSPOSIXErrorDomain, code: Int(POSIXErrorCode.ECONNREFUSED.rawValue)) + ) + + // Act + let timeoutResult = await runProbe(timeoutFixture) + let refusedResult = await runProbe(refusedFixture) + + // Assert + #expect(timeoutResult == .failure(.timeout)) + guard case .failure(.hostUnreachable) = refusedResult else { + Issue.record("expected hostUnreachable, got \(refusedResult)") + return + } + } + + @Test("LAN/私网 host 判定表:RFC1918+link-local+loopback+CGNAT+.local 为真,公网/畸形为假") + func privateOrLocalHostPredicateTable() { + // Assert — LAN 级(localNetworkDenied 诊断适用) + let lanHosts = [ + "localhost", "mac-mini.local", "10.0.0.5", "172.20.10.1", + "192.168.0.9", "169.254.1.1", "100.100.1.1", "127.0.0.1", + ] + for host in lanHosts { + #expect(PairingError.isPrivateOrLocalHost(host), "\(host) 应判为私网级") + } + // Assert — 非 LAN(不得误报本地网络权限) + let publicHosts = [ + "8.8.8.8", "203.0.113.7", "mac.tailnet.ts.net", + "256.1.1.1", "172.32.0.1", "100.128.0.1", "1.2.3", + ] + for host in publicHosts { + #expect(!PairingError.isPrivateOrLocalHost(host), "\(host) 不应判为私网级") + } + } + + // MARK: - 超时(注入 FakeClock,零真实等待) + + @Test("超时:探针卡在等待 attached 时,注入的 FakeClock 到期 → .timeout") + func probeTimesOutViaInjectedClockDeadline() async throws { + // Arrange — ① 通过;② 连接成功但服务器永不回 attached → 探针挂起 + let fixture = try makeFixture() + await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8)) + let clock = FakeClock() + let probeTimeout: Duration = .seconds(10) + + // Act — 等 deadline sleeper 停好(确定性栅栏),再推进时钟 + async let result = runProbe(fixture, timeout: probeTimeout, clock: clock) + await clock.waitForSleepers(count: 1) + clock.advance(by: probeTimeout) + + // Assert + #expect(await result == .failure(.timeout)) + } + + // MARK: - fixtures + + private static func networkDownError() -> NSError { + let posixDown = NSError( + domain: NSPOSIXErrorDomain, code: Int(POSIXErrorCode.ENETDOWN.rawValue) + ) + return NSError( + domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet, + userInfo: [NSUnderlyingErrorKey: posixDown] + ) + } +} diff --git a/ios/Packages/APIClient/Tests/APIClientTests/RequestBuilderTests.swift b/ios/Packages/APIClient/Tests/APIClientTests/RequestBuilderTests.swift new file mode 100644 index 0000000..aae4348 --- /dev/null +++ b/ios/Packages/APIClient/Tests/APIClientTests/RequestBuilderTests.swift @@ -0,0 +1,250 @@ +import Foundation +import Testing +import TestSupport +import WireProtocol +import APIClient + +/// T-iOS-8 · Origin-iff-G 铁律(plan §3.4/§5.1,安全语义): +/// `Origin` header 出现**当且仅当(iff)** G(变更)端点。 +/// RO GET(liveSessions/preview/events/uiConfig)一律不带 Origin; +/// G(killSession/hookDecision)必带且**逐字符等于** `endpoint.originHeader`。 +/// 这样服务器一旦把某 RO 端点改成 G,集成测试立刻红,而不是靠巧合通过。 +struct RequestBuilderTests { + private static let base = "http://192.168.1.5:3000" + private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b" + private static let emptyArrayBody = Data("[]".utf8) + private static let previewBody = Data( + #"{"id":"0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b","cols":80,"rows":24,"data":"hi"}"#.utf8 + ) + private static let uiConfigBody = Data(#"{"allowAutoMode":false}"#.utf8) + + private func makeEndpoint(_ base: String = RequestBuilderTests.base) throws -> HostEndpoint { + let url = try #require(URL(string: base)) + return try #require(HostEndpoint(baseURL: url)) + } + + private func routeURL(_ path: String, base: String = RequestBuilderTests.base) throws -> URL { + try #require(URL(string: base + path)) + } + + private func makeSessionId() throws -> UUID { + try #require(UUID(uuidString: Self.sessionIdString)) + } + + // MARK: - RO 侧(iff-G:无 Origin) + + @Test("Origin iff-G(RO 侧):liveSessions/preview/events/uiConfig 四个 RO GET 一律不带 Origin") + func readOnlyEndpointsNeverCarryOriginHeader() async throws { + // Arrange + let endpoint = try makeEndpoint() + let http = FakeHTTPTransport() + let client = APIClient(endpoint: endpoint, http: http) + let id = try makeSessionId() + await http.queueSuccess(url: try routeURL("/live-sessions"), body: Self.emptyArrayBody) + await http.queueSuccess( + url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"), + body: Self.previewBody + ) + await http.queueSuccess( + url: try routeURL("/live-sessions/\(Self.sessionIdString)/events"), + body: Self.emptyArrayBody + ) + await http.queueSuccess(url: try routeURL("/config/ui"), body: Self.uiConfigBody) + + // Act + _ = try await client.liveSessions() + _ = try await client.preview(id: id) + _ = try await client.events(id: id) + _ = try await client.uiConfig() + + // Assert + let requests = await http.recordedRequests + #expect(requests.count == 4) + for request in requests { + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Origin") == nil) + } + } + + // MARK: - G 侧(iff-G:必带 Origin,逐字符相等) + + @Test("Origin iff-G(G 侧):killSession 为 DELETE /live-sessions/:id(小写 UUID),Origin 逐字符等于 endpoint.originHeader") + func killSessionCarriesByteEqualOriginHeader() async throws { + // Arrange + let endpoint = try makeEndpoint() + let http = FakeHTTPTransport() + let client = APIClient(endpoint: endpoint, http: http) + let id = try makeSessionId() + let url = try routeURL("/live-sessions/\(Self.sessionIdString)") + await http.queueSuccess(method: "DELETE", url: url, status: 204) + + // Act + try await client.killSession(id: id) + + // Assert + let requests = await http.recordedRequests + #expect(requests.count == 1) + let request = try #require(requests.first) + #expect(request.httpMethod == "DELETE") + #expect(request.url == url) // 路径用小写 UUID —— 服务器按字符串精确匹配 + #expect(request.value(forHTTPHeaderField: "Origin") == endpoint.originHeader) + } + + @Test("Origin iff-G(G 侧):hookDecision 为 POST /hook/decision,Origin 逐字符等于 endpoint.originHeader") + func hookDecisionCarriesByteEqualOriginHeader() async throws { + // Arrange + let endpoint = try makeEndpoint() + let http = FakeHTTPTransport() + let client = APIClient(endpoint: endpoint, http: http) + let url = try routeURL("/hook/decision") + await http.queueSuccess(method: "POST", url: url, status: 204) + + // Act + try await client.hookDecision(sessionId: try makeSessionId(), decision: .allow, token: "tok-1") + + // Assert + let request = try #require(await http.recordedRequests.first) + #expect(request.httpMethod == "POST") + #expect(request.url == url) + #expect(request.value(forHTTPHeaderField: "Origin") == endpoint.originHeader) + } + + @Test("G 端点 Origin 对 https 默认端口省略 :443(无端口迷信,plan §5.1)") + func httpsDefaultPortOriginOmitsPort443() async throws { + // Arrange + let base = "https://mac.tailnet.ts.net" + let endpoint = try makeEndpoint(base) + let http = FakeHTTPTransport() + let client = APIClient(endpoint: endpoint, http: http) + let url = try routeURL("/live-sessions/\(Self.sessionIdString)", base: base) + await http.queueSuccess(method: "DELETE", url: url, status: 204) + + // Act + try await client.killSession(id: try makeSessionId()) + + // Assert + let origin = try #require( + await http.recordedRequests.first?.value(forHTTPHeaderField: "Origin") + ) + #expect(origin == "https://mac.tailnet.ts.net") + #expect(!origin.contains(":443")) + } + + // MARK: - hookDecision body 形状与错误映射 + + @Test("hookDecision body 形状恰为 {sessionId,decision,token} 且 Content-Type 为 JSON(服务器限 4KB body、10 次/分/IP)") + func hookDecisionBodyShapeIsExactlySessionIdDecisionToken() async throws { + // Arrange + let endpoint = try makeEndpoint() + let http = FakeHTTPTransport() + let client = APIClient(endpoint: endpoint, http: http) + await http.queueSuccess(method: "POST", url: try routeURL("/hook/decision"), status: 204) + + // Act + try await client.hookDecision(sessionId: try makeSessionId(), decision: .allow, token: "tok-1") + + // Assert + let request = try #require(await http.recordedRequests.first) + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + let body = try #require(request.httpBody) + let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(Set(object.keys) == Set(["sessionId", "decision", "token"])) + #expect(object["sessionId"] as? String == Self.sessionIdString) // 小写 UUID + #expect(object["decision"] as? String == "allow") + #expect(object["token"] as? String == "tok-1") + } + + @Test("hookDecision 403 → decisionRejected 显式错误(token 已过期/已被处理,src/server.ts:522-525)") + func hookDecision403ThrowsExplicitTokenRejectedError() async throws { + // Arrange + let endpoint = try makeEndpoint() + let http = FakeHTTPTransport() + let client = APIClient(endpoint: endpoint, http: http) + await http.queueSuccess(method: "POST", url: try routeURL("/hook/decision"), status: 403) + + // Act + Assert + await #expect(throws: APIClientError.decisionRejected) { + try await client.hookDecision( + sessionId: try self.makeSessionId(), decision: .deny, token: "expired" + ) + } + } + + @Test("hookDecision 429 → rateLimited(≤10 次/分/IP,src/server.ts:72)") + func hookDecision429ThrowsRateLimited() async throws { + // Arrange + let endpoint = try makeEndpoint() + let http = FakeHTTPTransport() + let client = APIClient(endpoint: endpoint, http: http) + await http.queueSuccess(method: "POST", url: try routeURL("/hook/decision"), status: 429) + + // Act + Assert + await #expect(throws: APIClientError.rateLimited) { + try await client.hookDecision( + sessionId: try self.makeSessionId(), decision: .allow, token: "tok" + ) + } + } + + @Test("hookDecision 400(非法 body) → unexpectedStatus(400)") + func hookDecision400ThrowsUnexpectedStatus() async throws { + // Arrange + let endpoint = try makeEndpoint() + let http = FakeHTTPTransport() + let client = APIClient(endpoint: endpoint, http: http) + await http.queueSuccess(method: "POST", url: try routeURL("/hook/decision"), status: 400) + + // Act + Assert + await #expect(throws: APIClientError.unexpectedStatus(400)) { + try await client.hookDecision( + sessionId: try self.makeSessionId(), decision: .allow, token: "tok" + ) + } + } + + @Test("killSession 意外状态码(500) → unexpectedStatus(500)") + func killSession500ThrowsUnexpectedStatus() async throws { + // Arrange + let endpoint = try makeEndpoint() + let http = FakeHTTPTransport() + let client = APIClient(endpoint: endpoint, http: http) + let url = try routeURL("/live-sessions/\(Self.sessionIdString)") + await http.queueSuccess(method: "DELETE", url: url, status: 500) + + // Act + Assert + await #expect(throws: APIClientError.unexpectedStatus(500)) { + try await client.killSession(id: try self.makeSessionId()) + } + } + + @Test("killSession 404 → sessionNotFound(会话已退出/被清理)") + func killSession404ThrowsSessionNotFound() async throws { + // Arrange + let endpoint = try makeEndpoint() + let http = FakeHTTPTransport() + let client = APIClient(endpoint: endpoint, http: http) + let url = try routeURL("/live-sessions/\(Self.sessionIdString)") + await http.queueSuccess(method: "DELETE", url: url, status: 404) + + // Act + Assert + await #expect(throws: APIClientError.sessionNotFound) { + try await client.killSession(id: try self.makeSessionId()) + } + } + + @Test("baseURL 带尾斜杠时请求 URL 归一化(不产生 //live-sessions)") + func trailingSlashBaseURLNormalizesRequestPath() async throws { + // Arrange + let endpoint = try makeEndpoint("http://192.168.1.5:3000/") + let http = FakeHTTPTransport() + let client = APIClient(endpoint: endpoint, http: http) + await http.queueSuccess(url: try routeURL("/live-sessions"), body: Self.emptyArrayBody) + + // Act + _ = try await client.liveSessions() + + // Assert + let request = try #require(await http.recordedRequests.first) + #expect(request.url?.absoluteString == "http://192.168.1.5:3000/live-sessions") + } +} diff --git a/ios/Packages/HostRegistry/Sources/HostRegistry/Host.swift b/ios/Packages/HostRegistry/Sources/HostRegistry/Host.swift new file mode 100644 index 0000000..8dd3206 --- /dev/null +++ b/ios/Packages/HostRegistry/Sources/HostRegistry/Host.swift @@ -0,0 +1,23 @@ +import Foundation +import WireProtocol + +/// A paired web-terminal host (frozen contract, plan §3.3). Immutable +/// snapshot: identity (`id`) and dial info (`endpoint`) are fixed at creation; +/// "renaming" a host means upserting a new value with the same `id`. +/// +/// Note: on macOS this shadows `Foundation.Host` (NSHost) inside this module; +/// downstream test/app targets that import Foundation should alias +/// `typealias Host = HostRegistry.Host` or qualify. +public struct Host: Sendable, Equatable, Codable, Identifiable { + public let id: UUID + public let name: String + /// Single point of Origin/wsURL derivation — from WireProtocol, never + /// redeclared here (plan §6 rule 1). + public let endpoint: HostEndpoint + + public init(id: UUID, name: String, endpoint: HostEndpoint) { + self.id = id + self.name = name + self.endpoint = endpoint + } +} diff --git a/ios/Packages/HostRegistry/Sources/HostRegistry/HostStore.swift b/ios/Packages/HostRegistry/Sources/HostRegistry/HostStore.swift new file mode 100644 index 0000000..f2a9018 --- /dev/null +++ b/ios/Packages/HostRegistry/Sources/HostRegistry/HostStore.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Frozen contract (plan §3.3). Implementations: `KeychainHostStore` (real) +/// and `InMemoryHostStore` (in-Sources double for this package's tests and +/// the App-layer VM tests). Immutable style: mutations return the NEW +/// collection instead of mutating shared state in place. +public protocol HostStore: Sendable { + func loadAll() async throws -> [Host] + /// Insert, or replace the host with the same `id` (position preserved). + /// Returns the new collection. + func upsert(_ host: Host) async throws -> [Host] + /// Removing an unknown `id` is an explicit no-op: returns the unchanged + /// collection, never throws for "not found". + func remove(id: UUID) async throws -> [Host] +} + +// Pure collection transforms shared by all HostStore implementations (DRY); +// never mutate `self`, always return a fresh array. +extension [Host] { + func upserting(_ host: Host) -> [Host] { + contains(where: { $0.id == host.id }) + ? map { $0.id == host.id ? host : $0 } + : self + [host] + } + + func removing(id: UUID) -> [Host] { + filter { $0.id != id } + } +} diff --git a/ios/Packages/HostRegistry/Sources/HostRegistry/InMemoryHostStore.swift b/ios/Packages/HostRegistry/Sources/HostRegistry/InMemoryHostStore.swift new file mode 100644 index 0000000..b8d490e --- /dev/null +++ b/ios/Packages/HostRegistry/Sources/HostRegistry/InMemoryHostStore.swift @@ -0,0 +1,32 @@ +import Foundation + +/// In-memory `HostStore`. Lives in Sources (not Tests) on purpose — plan +/// T-iOS-7: it doubles for the keychain store in this package's contract +/// tests AND in the App layer's ViewModel tests (which import HostRegistry). +/// +/// Actor-isolated so concurrent upserts/removes serialize; the state is a +/// value-type snapshot replaced wholesale on every change (no in-place +/// mutation of shared references). +public actor InMemoryHostStore: HostStore { + private var hosts: [Host] + + public init(hosts: [Host] = []) { + self.hosts = hosts + } + + public func loadAll() async throws -> [Host] { + hosts + } + + public func upsert(_ host: Host) async throws -> [Host] { + let updated = hosts.upserting(host) + hosts = updated + return updated + } + + public func remove(id: UUID) async throws -> [Host] { + let updated = hosts.removing(id: id) + hosts = updated + return updated + } +} diff --git a/ios/Packages/HostRegistry/Sources/HostRegistry/KeychainHostStore.swift b/ios/Packages/HostRegistry/Sources/HostRegistry/KeychainHostStore.swift new file mode 100644 index 0000000..13fc5c5 --- /dev/null +++ b/ios/Packages/HostRegistry/Sources/HostRegistry/KeychainHostStore.swift @@ -0,0 +1,114 @@ +import Foundation +import Security + +/// Explicit error taxonomy for keychain-backed persistence (plan T-iOS-7 +/// "错误码显式映射"). `errSecDuplicateItem` and `errSecItemNotFound` are +/// BRANCHES (add→update fallback / empty list / idempotent delete), never +/// surfaced as errors. +public enum KeychainHostStoreError: Error, Equatable { + /// -34018 `errSecMissingEntitlement`: the data-protection keychain is + /// unavailable to unsigned binaries (e.g. `swift test`) — a signal to + /// inject a fake `SecItemShim`, not a user-facing failure. + case missingEntitlement + /// Stored payload is not a decodable `[Host]` — treated as an explicit + /// error, never a crash (defensive at the storage boundary). + case corruptedData + /// `[Host]` failed to JSON-encode (practically unreachable; kept for a + /// complete, explicit taxonomy on the write path). + case encodingFailed + case unexpectedStatus(OSStatus) + + init(status: OSStatus) { + self = status == errSecMissingEntitlement ? .missingEntitlement : .unexpectedStatus(status) + } +} + +/// Keychain-backed `HostStore`: the whole host list is ONE generic-password +/// item (JSON `[Host]`) under `service`/`account`. An actor so the +/// read-modify-write in `upsert`/`remove` serializes. +/// +/// All SecItem dictionaries come from `KeychainItemSpec` — the §5.3 single +/// source; building an attribute dict anywhere else is a review CRITICAL. +public actor KeychainHostStore: HostStore { + public static let defaultService = "com.yaojia.webterm.host-registry" + public static let defaultAccount = "hosts" + + private let shim: any SecItemShim + private let spec: KeychainItemSpec + + public init( + shim: any SecItemShim = LiveSecItemShim(), + service: String = KeychainHostStore.defaultService, + account: String = KeychainHostStore.defaultAccount + ) { + self.shim = shim + self.spec = KeychainItemSpec(service: service, account: account) + } + + public func loadAll() async throws -> [Host] { + try readHosts() + } + + public func upsert(_ host: Host) async throws -> [Host] { + let updated = try readHosts().upserting(host) + try persist(updated) + return updated + } + + public func remove(id: UUID) async throws -> [Host] { + let current = try readHosts() + let updated = current.removing(id: id) + guard updated.count != current.count else { + return current // explicit no-op: unknown id, nothing persisted + } + try persist(updated) + return updated + } + + // MARK: - Keychain I/O (dictionaries built solely by KeychainItemSpec) + + private func readHosts() throws -> [Host] { + let (status, data) = shim.copyMatching(spec.copyQuery()) + if status == errSecItemNotFound { return [] } + guard status == errSecSuccess else { throw KeychainHostStoreError(status: status) } + guard let data, let hosts = try? JSONDecoder().decode([Host].self, from: data) else { + throw KeychainHostStoreError.corruptedData + } + return hosts + } + + private func persist(_ hosts: [Host]) throws { + if hosts.isEmpty { + try deleteItem() + return + } + let data = try encodeHosts(hosts) + let addStatus = shim.add(spec.addAttributes(data: data)) + if addStatus == errSecSuccess { return } + guard addStatus == errSecDuplicateItem else { + throw KeychainHostStoreError(status: addStatus) + } + let updateStatus = shim.update( + query: spec.baseQuery(), + attributesToUpdate: spec.updateAttributes(data: data) + ) + guard updateStatus == errSecSuccess else { + throw KeychainHostStoreError(status: updateStatus) + } + } + + private func deleteItem() throws { + let status = shim.delete(spec.baseQuery()) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainHostStoreError(status: status) + } + } + + private func encodeHosts(_ hosts: [Host]) throws -> Data { + do { + return try JSONEncoder().encode(hosts) + } catch { + throw KeychainHostStoreError.encodingFailed + } + } +} diff --git a/ios/Packages/HostRegistry/Sources/HostRegistry/LastSessionStore.swift b/ios/Packages/HostRegistry/Sources/HostRegistry/LastSessionStore.swift new file mode 100644 index 0000000..cd83c8e --- /dev/null +++ b/ios/Packages/HostRegistry/Sources/HostRegistry/LastSessionStore.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Frozen contract (plan §3.3). NON-SECRET per-host UI state only — anything +/// sensitive belongs in `KeychainHostStore` (§5.3 split: Keychain = secrets, +/// UserDefaults = prefs). +public protocol LastSessionStore: Sendable { + func lastSessionId(host: UUID) -> UUID? + func setLastSessionId(_ id: UUID?, host: UUID) +} + +/// UserDefaults-backed implementation with an injectable suite for tests. +/// `@unchecked Sendable`: `UserDefaults` carries no Sendable annotation in +/// the SDK but is documented thread-safe ("NSUserDefaults is thread-safe"), +/// and this wrapper adds no mutable state of its own. +public struct UserDefaultsLastSessionStore: LastSessionStore, @unchecked Sendable { + private static let keyPrefix = "lastSessionId." + + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + public func lastSessionId(host: UUID) -> UUID? { + guard let raw = defaults.string(forKey: Self.key(host)) else { return nil } + // Stored value crosses a storage boundary — validate, never trust: + // garbage reads back as nil instead of crashing or misrouting. + return UUID(uuidString: raw) + } + + public func setLastSessionId(_ id: UUID?, host: UUID) { + guard let id else { + defaults.removeObject(forKey: Self.key(host)) + return + } + defaults.set(id.uuidString, forKey: Self.key(host)) + } + + private static func key(_ host: UUID) -> String { + keyPrefix + host.uuidString + } +} diff --git a/ios/Packages/HostRegistry/Sources/HostRegistry/SecItemShim.swift b/ios/Packages/HostRegistry/Sources/HostRegistry/SecItemShim.swift new file mode 100644 index 0000000..8402d43 --- /dev/null +++ b/ios/Packages/HostRegistry/Sources/HostRegistry/SecItemShim.swift @@ -0,0 +1,97 @@ +import Foundation +import Security + +/// Testability seam over the four `SecItem*` C calls (plan §3.3 / T-iOS-7). +/// +/// Why a seam: unsigned `swift test` binaries get `errSecMissingEntitlement` +/// (-34018) from the data-protection keychain, so unit tests inject a fake +/// conforming to this protocol and assert the store's dictionaries verbatim; +/// the live shim only runs inside the signed simulator/app pass (T-iOS-15/16). +public protocol SecItemShim: Sendable { + func add(_ attributes: [String: any Sendable]) -> OSStatus + func update(query: [String: any Sendable], + attributesToUpdate: [String: any Sendable]) -> OSStatus + func copyMatching(_ query: [String: any Sendable]) -> (status: OSStatus, data: Data?) + func delete(_ query: [String: any Sendable]) -> OSStatus +} + +/// Live passthrough to Security.framework. Deliberately logic-free: every +/// attribute — including the §5.3-mandated protection keys — is built by +/// `KeychainItemSpec` below, so there is exactly ONE place to audit and to +/// unit-test. Adding attribute logic here would split that single source. +public struct LiveSecItemShim: SecItemShim { + public init() {} + + public func add(_ attributes: [String: any Sendable]) -> OSStatus { + SecItemAdd(attributes as [String: Any] as CFDictionary, nil) + } + + public func update(query: [String: any Sendable], + attributesToUpdate: [String: any Sendable]) -> OSStatus { + SecItemUpdate(query as [String: Any] as CFDictionary, + attributesToUpdate as [String: Any] as CFDictionary) + } + + public func copyMatching(_ query: [String: any Sendable]) -> (status: OSStatus, data: Data?) { + var result: CFTypeRef? + let status = SecItemCopyMatching(query as [String: Any] as CFDictionary, &result) + return (status, result as? Data) + } + + public func delete(_ query: [String: any Sendable]) -> OSStatus { + SecItemDelete(query as [String: Any] as CFDictionary) + } +} + +/// §5.3 single source of truth for every keychain dictionary the store sends +/// through the shim. One wrong attribute here = credentials leave the device +/// with backups — KeychainHostStoreTests asserts these dictionaries verbatim +/// (exact key sets), and the signed simulator pass (T-iOS-15/16) verifies the +/// real item's attributes end-to-end. +struct KeychainItemSpec: Sendable { + let service: String + let account: String + + /// Identifies the item in every call. `kSecUseDataProtectionKeychain` + /// pins the iOS-style data-protection keychain on macOS too (on iOS it + /// is implicit); omitting it would silently fall back to the legacy + /// file keychain on Mac and void the §5.3 semantics. + func baseQuery() -> [String: any Sendable] { + [ + kSecClass as String: kSecClassGenericPassword as String, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecUseDataProtectionKeychain as String: true, + ] + } + + func copyQuery() -> [String: any Sendable] { + merged(baseQuery(), [ + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne as String, + ]) + } + + /// `AfterFirstUnlockThisDeviceOnly`: readable in background after first + /// unlock (reconnect/UI needs), never migrates to another device or into + /// backups; no `kSecAttrSynchronizable` → never iCloud-synced (§5.3). + func addAttributes(data: Data) -> [String: any Sendable] { + merged(baseQuery(), updateAttributes(data: data)) + } + + /// Update re-asserts `kSecAttrAccessible` so a pre-existing item can + /// never retain a weaker protection class than the current policy. + func updateAttributes(data: Data) -> [String: any Sendable] { + [ + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String, + kSecValueData as String: data, + ] + } + + private func merged( + _ base: [String: any Sendable], + _ extra: [String: any Sendable] + ) -> [String: any Sendable] { + base.merging(extra) { _, new in new } + } +} diff --git a/ios/Packages/HostRegistry/Tests/HostRegistryTests/FakeSecItemShim.swift b/ios/Packages/HostRegistry/Tests/HostRegistryTests/FakeSecItemShim.swift new file mode 100644 index 0000000..57f1ed5 --- /dev/null +++ b/ios/Packages/HostRegistry/Tests/HostRegistryTests/FakeSecItemShim.swift @@ -0,0 +1,97 @@ +import Foundation +import Security +import os +import HostRegistry + +/// In-memory keychain double behind the `SecItemShim` seam (plan §3.3): +/// unsigned `swift test` binaries get errSecMissingEntitlement (-34018) from +/// the real data-protection keychain, so store logic is tested against this. +/// +/// Behavioral single-slot model (the store uses exactly one service+account): +/// - add: duplicate if a value is stored; otherwise stores kSecValueData +/// - update: itemNotFound when empty; otherwise replaces the stored value +/// - copyMatching: itemNotFound when empty; otherwise returns the stored value +/// - delete: itemNotFound when empty; otherwise clears +/// +/// `force*` knobs override the next status/result (forced statuses do NOT +/// touch storage — the fake stays dumb). All calls are recorded verbatim so +/// tests can assert the §5.3 attribute dictionaries character-for-character. +final class FakeSecItemShim: SecItemShim, Sendable { + struct UpdateCall: Sendable { + let query: [String: any Sendable] + let attributes: [String: any Sendable] + } + + private struct State: Sendable { + var storedData: Data? + var addCalls: [[String: any Sendable]] = [] + var updateCalls: [UpdateCall] = [] + var copyCalls: [[String: any Sendable]] = [] + var deleteCalls: [[String: any Sendable]] = [] + var forcedAddStatus: OSStatus? + var forcedUpdateStatus: OSStatus? + var forcedCopyStatus: OSStatus? + var forcedDeleteStatus: OSStatus? + var forcedCopyData: Data? + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + // MARK: - SecItemShim + + func add(_ attributes: [String: any Sendable]) -> OSStatus { + state.withLock { s in + s.addCalls.append(attributes) + if let forced = s.forcedAddStatus { return forced } + guard s.storedData == nil else { return errSecDuplicateItem } + s.storedData = attributes[kSecValueData as String] as? Data + return errSecSuccess + } + } + + func update(query: [String: any Sendable], + attributesToUpdate: [String: any Sendable]) -> OSStatus { + state.withLock { s in + s.updateCalls.append(UpdateCall(query: query, attributes: attributesToUpdate)) + if let forced = s.forcedUpdateStatus { return forced } + guard s.storedData != nil else { return errSecItemNotFound } + s.storedData = attributesToUpdate[kSecValueData as String] as? Data + return errSecSuccess + } + } + + func copyMatching(_ query: [String: any Sendable]) -> (status: OSStatus, data: Data?) { + state.withLock { s in + s.copyCalls.append(query) + if let forced = s.forcedCopyStatus { return (forced, nil) } + if let forcedData = s.forcedCopyData { return (errSecSuccess, forcedData) } + guard let data = s.storedData else { return (errSecItemNotFound, nil) } + return (errSecSuccess, data) + } + } + + func delete(_ query: [String: any Sendable]) -> OSStatus { + state.withLock { s in + s.deleteCalls.append(query) + if let forced = s.forcedDeleteStatus { return forced } + guard s.storedData != nil else { return errSecItemNotFound } + s.storedData = nil + return errSecSuccess + } + } + + // MARK: - Test controls + + func forceAddStatus(_ status: OSStatus) { state.withLock { $0.forcedAddStatus = status } } + func forceUpdateStatus(_ status: OSStatus) { state.withLock { $0.forcedUpdateStatus = status } } + func forceCopyStatus(_ status: OSStatus) { state.withLock { $0.forcedCopyStatus = status } } + func forceDeleteStatus(_ status: OSStatus) { state.withLock { $0.forcedDeleteStatus = status } } + func forceCopyData(_ data: Data) { state.withLock { $0.forcedCopyData = data } } + + // MARK: - Recorded calls + + var recordedAdds: [[String: any Sendable]] { state.withLock { $0.addCalls } } + var recordedUpdates: [UpdateCall] { state.withLock { $0.updateCalls } } + var recordedCopies: [[String: any Sendable]] { state.withLock { $0.copyCalls } } + var recordedDeletes: [[String: any Sendable]] { state.withLock { $0.deleteCalls } } +} diff --git a/ios/Packages/HostRegistry/Tests/HostRegistryTests/HostTests.swift b/ios/Packages/HostRegistry/Tests/HostRegistryTests/HostTests.swift new file mode 100644 index 0000000..8b9b1c5 --- /dev/null +++ b/ios/Packages/HostRegistry/Tests/HostRegistryTests/HostTests.swift @@ -0,0 +1,28 @@ +import Foundation +import Testing +import WireProtocol +import HostRegistry + +@Test("Host Codable 往返:id/name/endpoint 全保真(originHeader 重派生一致)") +func hostCodableRoundTripPreservesAllFields() throws { + // Arrange + let host = Fixtures.makeHost(name: "studio", urlString: "https://mac.ts.net:8443") + + // Act + let data = try JSONEncoder().encode(host) + let decoded = try JSONDecoder().decode(Host.self, from: data) + + // Assert + #expect(decoded == host) + #expect(decoded.endpoint.originHeader == "https://mac.ts.net:8443") +} + +@Test("Identifiable:Host.id 即协议 id") +func hostIdentifiableIdIsItsOwnId() { + // Arrange / Act + let host = Fixtures.makeHost() + + // Assert + let identifiable: any Identifiable = host + #expect(identifiable.id == host.id) +} diff --git a/ios/Packages/HostRegistry/Tests/HostRegistryTests/InMemoryHostStoreTests.swift b/ios/Packages/HostRegistry/Tests/HostRegistryTests/InMemoryHostStoreTests.swift new file mode 100644 index 0000000..8dae7dd --- /dev/null +++ b/ios/Packages/HostRegistry/Tests/HostRegistryTests/InMemoryHostStoreTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing +import HostRegistry + +// HostStore protocol contract, tested against the in-Sources InMemory double +// (plan T-iOS-7: "用 InMemory 替身测协议契约"). + +@Test("upsert 新 host:返回的新集合含它,loadAll 一致") +func upsertNewHostAppearsInReturnedCollectionAndLoadAll() async throws { + // Arrange + let store = InMemoryHostStore() + let host = Fixtures.makeHost() + + // Act + let result = try await store.upsert(host) + + // Assert + #expect(result == [host]) + #expect(try await store.loadAll() == [host]) +} + +@Test("同 id 再 upsert:替换不重复,且保持原位置") +func upsertSameIdReplacesWithoutDuplicatingAndKeepsPosition() async throws { + // Arrange + let first = Fixtures.makeHost(name: "old-name") + let second = Fixtures.makeHost(name: "other", urlString: "https://mac.ts.net") + let store = InMemoryHostStore(hosts: [first, second]) + let renamed = Host(id: first.id, name: "new-name", endpoint: first.endpoint) + + // Act + let result = try await store.upsert(renamed) + + // Assert + #expect(result == [renamed, second]) + #expect(try await store.loadAll() == [renamed, second]) +} + +@Test("remove 已存在 id:集合不再含它") +func removeExistingIdDropsItFromCollection() async throws { + // Arrange + let keep = Fixtures.makeHost(name: "keep") + let doomed = Fixtures.makeHost(name: "doomed", urlString: "http://10.0.0.7:3000") + let store = InMemoryHostStore(hosts: [keep, doomed]) + + // Act + let result = try await store.remove(id: doomed.id) + + // Assert + #expect(result == [keep]) + #expect(try await store.loadAll() == [keep]) +} + +@Test("remove 不存在的 id:集合不变、不 throw(显式返回原集合)") +func removeUnknownIdReturnsUnchangedCollectionWithoutThrowing() async throws { + // Arrange + let host = Fixtures.makeHost() + let store = InMemoryHostStore(hosts: [host]) + + // Act + let result = try await store.remove(id: UUID()) + + // Assert + #expect(result == [host]) + #expect(try await store.loadAll() == [host]) +} + +@Test("init 种子集合:loadAll 原样返回") +func initSeedsCollectionVerbatim() async throws { + // Arrange + let hosts = [Fixtures.makeHost(name: "a"), Fixtures.makeHost(name: "b")] + + // Act + let store = InMemoryHostStore(hosts: hosts) + + // Assert + #expect(try await store.loadAll() == hosts) +} diff --git a/ios/Packages/HostRegistry/Tests/HostRegistryTests/KeychainHostStoreTests.swift b/ios/Packages/HostRegistry/Tests/HostRegistryTests/KeychainHostStoreTests.swift new file mode 100644 index 0000000..7277bc0 --- /dev/null +++ b/ios/Packages/HostRegistry/Tests/HostRegistryTests/KeychainHostStoreTests.swift @@ -0,0 +1,292 @@ +import Foundation +import Security +import Testing +import HostRegistry + +// KeychainHostStore logic against the fake SecItemShim (plan T-iOS-7: +// add/update/delete 走对分支、错误码显式映射;真 keychain 归 xcodebuild 模拟器测试)。 + +// MARK: - Branch selection + +@Test("upsert 新 host:走 add 分支,不碰 update") +func upsertNewHostTakesAddBranchOnly() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + + // Act + let result = try await store.upsert(host) + + // Assert + #expect(result == [host]) + #expect(shim.recordedAdds.count == 1) + #expect(shim.recordedUpdates.isEmpty) + #expect(try await store.loadAll() == [host]) +} + +@Test("已有条目再 upsert:add 撞 errSecDuplicateItem → 走 update 分支") +func upsertOntoExistingItemFallsBackToUpdateBranch() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let first = Fixtures.makeHost(name: "first") + _ = try await store.upsert(first) + let second = Fixtures.makeHost(name: "second", urlString: "http://10.0.0.9:3000") + + // Act + let result = try await store.upsert(second) + + // Assert + #expect(result == [first, second]) + #expect(shim.recordedUpdates.count == 1) + #expect(try await store.loadAll() == [first, second]) +} + +@Test("同 id 再 upsert:替换不重复(keychain 持久化后仍只一条)") +func upsertSameIdReplacesInsteadOfDuplicating() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost(name: "old") + _ = try await store.upsert(host) + let renamed = Host(id: host.id, name: "new", endpoint: host.endpoint) + + // Act + let result = try await store.upsert(renamed) + + // Assert + #expect(result == [renamed]) + #expect(try await store.loadAll() == [renamed]) +} + +@Test("remove 到空集合:走 delete 分支清掉整个 item") +func removeLastHostTakesDeleteBranch() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + + // Act + let result = try await store.remove(id: host.id) + + // Assert + #expect(result.isEmpty) + #expect(shim.recordedDeletes.count == 1) + #expect(try await store.loadAll().isEmpty) +} + +@Test("remove 不存在的 id:集合不变、不 throw、零持久化调用") +func removeUnknownIdIsExplicitNoOpWithoutPersisting() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + let addsBefore = shim.recordedAdds.count + let updatesBefore = shim.recordedUpdates.count + + // Act + let result = try await store.remove(id: UUID()) + + // Assert + #expect(result == [host]) + #expect(shim.recordedAdds.count == addsBefore) + #expect(shim.recordedUpdates.count == updatesBefore) + #expect(shim.recordedDeletes.isEmpty) +} + +@Test("空 keychain loadAll:errSecItemNotFound 映射为空集合而非错误") +func loadAllMapsItemNotFoundToEmptyCollection() async throws { + // Arrange + let store = KeychainHostStore(shim: FakeSecItemShim()) + + // Act / Assert + #expect(try await store.loadAll().isEmpty) +} + +@Test("delete 撞 errSecItemNotFound:视为成功(幂等删除)") +func deleteItemNotFoundIsTreatedAsSuccess() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + shim.forceDeleteStatus(errSecItemNotFound) + + // Act + let result = try await store.remove(id: host.id) + + // Assert + #expect(result.isEmpty) +} + +@Test("持久化跨实例:同一 shim 上的新 store 读回全部 host") +func persistedHostsSurviveAcrossStoreInstances() async throws { + // Arrange + let shim = FakeSecItemShim() + let a = Fixtures.makeHost(name: "a") + let b = Fixtures.makeHost(name: "b", urlString: "https://mac.ts.net") + let writer = KeychainHostStore(shim: shim) + _ = try await writer.upsert(a) + _ = try await writer.upsert(b) + + // Act + let reader = KeychainHostStore(shim: shim) + + // Assert + #expect(try await reader.loadAll() == [a, b]) +} + +// MARK: - Explicit error-code mapping + +@Test("add 撞 -34018 errSecMissingEntitlement:映射为 .missingEntitlement") +func addMissingEntitlementMapsToTypedError() async { + // Arrange + let shim = FakeSecItemShim() + shim.forceAddStatus(errSecMissingEntitlement) + let store = KeychainHostStore(shim: shim) + + // Act / Assert + await #expect(throws: KeychainHostStoreError.missingEntitlement) { + _ = try await store.upsert(Fixtures.makeHost()) + } +} + +@Test("copyMatching 意外错误码:映射为 .unexpectedStatus(原码)") +func copyUnexpectedStatusMapsToTypedErrorWithCode() async { + // Arrange + let shim = FakeSecItemShim() + shim.forceCopyStatus(errSecInteractionNotAllowed) + let store = KeychainHostStore(shim: shim) + + // Act / Assert + await #expect(throws: KeychainHostStoreError.unexpectedStatus(errSecInteractionNotAllowed)) { + _ = try await store.loadAll() + } +} + +@Test("update 分支失败:映射为 .unexpectedStatus(原码)") +func updateFailureMapsToTypedErrorWithCode() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + _ = try await store.upsert(Fixtures.makeHost()) + shim.forceUpdateStatus(errSecAuthFailed) + + // Act / Assert + await #expect(throws: KeychainHostStoreError.unexpectedStatus(errSecAuthFailed)) { + _ = try await store.upsert(Fixtures.makeHost(name: "second")) + } +} + +@Test("delete 分支意外错误码:映射为 .unexpectedStatus(原码)") +func deleteFailureMapsToTypedErrorWithCode() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + shim.forceDeleteStatus(errSecAuthFailed) + + // Act / Assert + await #expect(throws: KeychainHostStoreError.unexpectedStatus(errSecAuthFailed)) { + _ = try await store.remove(id: host.id) + } +} + +@Test("keychain 里的数据不是合法 [Host] JSON:映射为 .corruptedData,不 crash") +func corruptKeychainPayloadMapsToTypedError() async { + // Arrange + let shim = FakeSecItemShim() + shim.forceCopyData(Data("not-json".utf8)) + let store = KeychainHostStore(shim: shim) + + // Act / Assert + await #expect(throws: KeychainHostStoreError.corruptedData) { + _ = try await store.loadAll() + } +} + +// MARK: - §5.3 attribute dictionaries, asserted verbatim + +@Test("§5.3 add 属性字典逐字校验:data-protection + AfterFirstUnlockThisDeviceOnly,无 synchronizable,无多余键") +func addAttributeDictionaryIsExactlyThePolicySet() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim, service: "svc.test", account: "acct.test") + + // Act + _ = try await store.upsert(Fixtures.makeHost()) + + // Assert + let attrs = try #require(shim.recordedAdds.first) + #expect(attrs[kSecClass as String] as? String == kSecClassGenericPassword as String) + #expect(attrs[kSecAttrService as String] as? String == "svc.test") + #expect(attrs[kSecAttrAccount as String] as? String == "acct.test") + #expect(attrs[kSecUseDataProtectionKeychain as String] as? Bool == true) + #expect(attrs[kSecAttrAccessible as String] as? String + == kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String) + #expect(attrs[kSecAttrSynchronizable as String] == nil) + #expect(attrs[kSecValueData as String] is Data) + #expect(attrs.count == 6) // 白名单外零多余键 —— verbatim 断言 +} + +@Test("§5.3 update 分支字典:query 带 data-protection 不带数据;attributes 重申 accessible + 新数据") +func updateDictionariesReassertProtectionPolicy() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + _ = try await store.upsert(Fixtures.makeHost(name: "first")) + + // Act + _ = try await store.upsert(Fixtures.makeHost(name: "second", urlString: "http://10.1.1.1:3000")) + + // Assert + let call = try #require(shim.recordedUpdates.first) + #expect(call.query[kSecClass as String] as? String == kSecClassGenericPassword as String) + #expect(call.query[kSecUseDataProtectionKeychain as String] as? Bool == true) + #expect(call.query[kSecValueData as String] == nil) + #expect(call.query.count == 4) // class + service + account + data-protection + #expect(call.attributes[kSecAttrAccessible as String] as? String + == kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String) + #expect(call.attributes[kSecValueData as String] is Data) + #expect(call.attributes.count == 2) +} + +@Test("§5.3 copy 查询字典:base + returnData + matchLimitOne,共 6 键") +func copyQueryDictionaryIsExactlyThePolicySet() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + + // Act + _ = try await store.loadAll() + + // Assert + let query = try #require(shim.recordedCopies.first) + #expect(query[kSecClass as String] as? String == kSecClassGenericPassword as String) + #expect(query[kSecUseDataProtectionKeychain as String] as? Bool == true) + #expect(query[kSecReturnData as String] as? Bool == true) + #expect(query[kSecMatchLimit as String] as? String == kSecMatchLimitOne as String) + #expect(query.count == 6) +} + +@Test("§5.3 delete 查询字典:带 data-protection 的 base query,共 4 键") +func deleteQueryDictionaryIsExactlyTheBaseQuery() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + + // Act + _ = try await store.remove(id: host.id) + + // Assert + let query = try #require(shim.recordedDeletes.first) + #expect(query[kSecClass as String] as? String == kSecClassGenericPassword as String) + #expect(query[kSecUseDataProtectionKeychain as String] as? Bool == true) + #expect(query.count == 4) +} diff --git a/ios/Packages/HostRegistry/Tests/HostRegistryTests/LastSessionStoreTests.swift b/ios/Packages/HostRegistry/Tests/HostRegistryTests/LastSessionStoreTests.swift new file mode 100644 index 0000000..c0b9957 --- /dev/null +++ b/ios/Packages/HostRegistry/Tests/HostRegistryTests/LastSessionStoreTests.swift @@ -0,0 +1,91 @@ +import Foundation +import Testing +import HostRegistry + +// UserDefaultsLastSessionStore over an injectable, per-test-unique suite +// (plan §3.3: 非机密;lastSessionId 存取/清除)。 + +@Test("lastSessionId 存取:set 后读回同一 UUID") +func setThenGetReturnsSameSessionId() throws { + // Arrange + let suiteName = Fixtures.makeSuiteName() + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = UserDefaultsLastSessionStore(defaults: defaults) + let host = UUID() + let session = UUID() + + // Act + store.setLastSessionId(session, host: host) + + // Assert + #expect(store.lastSessionId(host: host) == session) +} + +@Test("未设置过的 host:lastSessionId 返回 nil") +func unknownHostReturnsNil() throws { + // Arrange + let suiteName = Fixtures.makeSuiteName() + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = UserDefaultsLastSessionStore(defaults: defaults) + + // Act / Assert + #expect(store.lastSessionId(host: UUID()) == nil) +} + +@Test("set nil:清除既有 lastSessionId") +func settingNilClearsStoredSessionId() throws { + // Arrange + let suiteName = Fixtures.makeSuiteName() + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = UserDefaultsLastSessionStore(defaults: defaults) + let host = UUID() + store.setLastSessionId(UUID(), host: host) + + // Act + store.setLastSessionId(nil, host: host) + + // Assert + #expect(store.lastSessionId(host: host) == nil) +} + +@Test("不同 host 键隔离:互不覆盖") +func distinctHostsDoNotCollide() throws { + // Arrange + let suiteName = Fixtures.makeSuiteName() + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = UserDefaultsLastSessionStore(defaults: defaults) + let hostA = UUID() + let hostB = UUID() + let sessionA = UUID() + let sessionB = UUID() + + // Act + store.setLastSessionId(sessionA, host: hostA) + store.setLastSessionId(sessionB, host: hostB) + + // Assert + #expect(store.lastSessionId(host: hostA) == sessionA) + #expect(store.lastSessionId(host: hostB) == sessionB) +} + +@Test("UserDefaults 中的非 UUID 垃圾值:读回 nil 而非 crash(防御性解析)") +func garbageStoredValueReadsBackAsNil() throws { + // Arrange — discover the store's key via the suite's own domain, then poison it + let suiteName = Fixtures.makeSuiteName() + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = UserDefaultsLastSessionStore(defaults: defaults) + let host = UUID() + store.setLastSessionId(UUID(), host: host) + let key = try #require(defaults.persistentDomain(forName: suiteName)?.keys.first) + + // Act + defaults.set("not-a-uuid", forKey: key) + + // Assert + #expect(store.lastSessionId(host: host) == nil) +} diff --git a/ios/Packages/HostRegistry/Tests/HostRegistryTests/TestFixtures.swift b/ios/Packages/HostRegistry/Tests/HostRegistryTests/TestFixtures.swift new file mode 100644 index 0000000..20957a0 --- /dev/null +++ b/ios/Packages/HostRegistry/Tests/HostRegistryTests/TestFixtures.swift @@ -0,0 +1,28 @@ +import Foundation +import WireProtocol +import HostRegistry + +/// On macOS `Foundation.Host` (NSHost) shadows the package type in test +/// files that import Foundation — pin resolution for the whole test target. +typealias Host = HostRegistry.Host + +/// Shared fixtures for HostRegistryTests. Pure helpers only — no state. +enum Fixtures { + /// Builds a valid Host; traps loudly if the fixture URL is malformed + /// (a broken fixture must fail the suite, not silently skip assertions). + static func makeHost( + name: String = "mac-studio", + urlString: String = "http://192.168.1.5:3000", + id: UUID = UUID() + ) -> Host { + guard let url = URL(string: urlString), let endpoint = HostEndpoint(baseURL: url) else { + fatalError("test fixture URL invalid: \(urlString)") + } + return Host(id: id, name: name, endpoint: endpoint) + } + + /// Unique UserDefaults suite name per test — isolates parallel tests. + static func makeSuiteName() -> String { + "HostRegistryTests." + UUID().uuidString + } +} diff --git a/ios/Packages/SessionCore/Sources/SessionCore/AwayDigest.swift b/ios/Packages/SessionCore/Sources/SessionCore/AwayDigest.swift new file mode 100644 index 0000000..2917d45 --- /dev/null +++ b/ios/Packages/SessionCore/Sources/SessionCore/AwayDigest.swift @@ -0,0 +1,80 @@ +import Foundation +import WireProtocol + +/// The server's `TimelineClass` vocabulary (src/types.ts:423, produced by +/// src/session/timeline.ts CLASS_MAP). Kept in lockstep with +/// `TimelineEvent.knownClasses` (WireProtocol) — a sync test asserts equality. +/// Internal (not public API): consumers see counts, not class strings. +enum TimelineClassName { + static let tool = "tool" + static let waiting = "waiting" + static let done = "done" + static let stuck = "stuck" + static let user = "user" +} + +/// "What happened while I was away" summary (T-iOS-6, plan §3.2 — frozen +/// shape). Reduced once after a reconnect from the session's activity timeline +/// (`GET /live-sessions/:id/events`, src/types.ts:428-433) and rendered above +/// the terminal (T-iOS-14). +public struct AwayDigest: Sendable, Equatable { + /// Events with class `tool` at/after `since` (PreToolUse + PostToolUse). + public let toolRuns: Int + /// Events with class `waiting` (approval requests / notifications). + public let waitingCount: Int + /// Any `done` event seen (Stop / SessionEnd). + public let sawDone: Bool + /// Any `stuck` event seen (A5 silent-too-long derivation). + public let sawStuck: Bool + /// The newest events (post-`since`), oldest→newest, truncated to `limit`. + public let recent: [TimelineEvent] + + /// The all-zero digest: nothing happened → UI skips rendering entirely. + public static let empty = AwayDigest( + toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: false, recent: []) + + public init( + toolRuns: Int, waitingCount: Int, sawDone: Bool, sawStuck: Bool, + recent: [TimelineEvent] + ) { + self.toolRuns = toolRuns + self.waitingCount = waitingCount + self.sawDone = sawDone + self.sawStuck = sawStuck + self.recent = recent + } + + /// True when there is nothing to show (the UI's "don't render" signal). + public var isEmpty: Bool { self == .empty } + + /// Folds a timeline into a digest. PURE function — no I/O, no clock. + /// + /// - `events`: `/events` entries (untrusted server input; order not assumed + /// — entries are stably re-ordered by `at` before truncation). + /// - `since`: the moment the user left; strictly-earlier events are + /// excluded, an event AT the instant still counts. + /// - `limit`: max `recent` entries kept (newest win); `<= 0` keeps none. + public static func reduce(events: [TimelineEvent], since: Date, limit: Int) -> AwayDigest { + let sinceMs = clampedEpochMs(since) + let away = events + .filter { $0.at >= sinceMs } + .sorted { $0.at < $1.at } + guard !away.isEmpty else { return .empty } + + return AwayDigest( + toolRuns: away.filter { $0.class == TimelineClassName.tool }.count, + waitingCount: away.filter { $0.class == TimelineClassName.waiting }.count, + sawDone: away.contains { $0.class == TimelineClassName.done }, + sawStuck: away.contains { $0.class == TimelineClassName.stuck }, + recent: Array(away.suffix(max(0, limit)))) + } + + /// `Date` → epoch-ms `Int`, clamped: `Int(_: Double)` TRAPS on overflow, and + /// `since` is caller input (e.g. `.distantFuture`) — clamp instead of crash. + private static func clampedEpochMs(_ date: Date) -> Int { + let ms = date.timeIntervalSince1970 * 1_000 + guard ms < Double(Int.max) else { return .max } + guard ms > Double(Int.min) else { return .min } + return Int(ms) + } +} diff --git a/ios/Packages/SessionCore/Sources/SessionCore/GateState.swift b/ios/Packages/SessionCore/Sources/SessionCore/GateState.swift new file mode 100644 index 0000000..f6b43ab --- /dev/null +++ b/ios/Packages/SessionCore/Sources/SessionCore/GateState.swift @@ -0,0 +1,119 @@ +import WireProtocol + +/// One held permission gate as the UI should render it (T-iOS-6, plan §3.2 — +/// frozen shape). Produced by `GateTracker` from `status` frames; `nil` gate +/// (via `SessionEvent.gate(nil)`) means "gate lifted". +/// +/// `epoch` is the stale-decision guard (mirrors the web client's +/// `pendingEpochValue`, public/terminal-session.ts:98-102): it increments only +/// on a pending false→true rising edge, and every approve/reject carries the +/// epoch it was tapped against — a stale epoch is dropped so a slow tap can +/// never approve the NEXT gate (防误批新 gate). +public struct GateState: Sendable, Equatable { + /// `plan` → three-way affordances; `tool` → two-way (public/tabs.ts:334-350). + public let kind: GateKind + /// Server-supplied context (tool name for tool gates); untrusted display text. + public let detail: String? + /// Rising-edge counter; see type doc. + public let epoch: Int + + public init(kind: GateKind, detail: String?, epoch: Int) { + self.kind = kind + self.detail = detail + self.epoch = epoch + } +} + +extension GateState { + /// One user-facing gate action. Pure affordance DATA — display strings live + /// in the App layer; the wire mapping mirrors public/tabs.ts:334-350. + public enum Affordance: Sendable, Equatable { + /// Plan gate: "Approve + Auto" → `approve(mode: .acceptEdits)`. + case approveAuto + /// Plan gate: "Approve + Review" → `approve(mode: .default)`. + case approveReview + /// Plan gate: "Keep Planning" → `reject` (stays in plan mode). + case keepPlanning + /// Tool gate: "Approve" → `approve(mode: nil)`. + case approve + /// Tool gate: "Reject" → `reject`. + case reject + + /// The exact wire message the web client sends for this affordance + /// (public/tabs.ts:345-347: plan three-way only ever sends + /// acceptEdits / default / reject — never raw `auto`, plan §3.1 note). + public var clientMessage: ClientMessage { + switch self { + case .approveAuto: return .approve(mode: .acceptEdits) + case .approveReview: return .approve(mode: .default) + case .keepPlanning: return .reject + case .approve: return .approve(mode: nil) + case .reject: return .reject + } + } + } + + /// The action set for this gate: plan → three-way, tool → two-way. + public var affordances: [Affordance] { + switch kind { + case .plan: return [.approveAuto, .approveReview, .keepPlanning] + case .tool: return [.approve, .reject] + } + } +} + +/// Pure reducer that folds `status` frames' `(pending, gate, detail)` into the +/// current `GateState?` (T-iOS-6). Mirrors public/terminal-session.ts:306-311: +/// +/// - pending false→true rising edge → epoch +1, new gate held; +/// - sustained pending → SAME epoch, kind/detail refresh from the latest frame +/// (the server is the source of truth; reattach re-sync stays correct); +/// - pending true→false falling edge → gate cleared, epoch counter retained; +/// - `gate == nil` while pending → treated as a tool gate, because an +/// unrecognized gate value decodes as nil (ServerMessage) and the pending +/// signal must never be lost (web: `gate ?? null` → tool buttons). +/// +/// PURE state machine: `reduce` never mutates the receiver — it returns a new +/// immutable snapshot. No I/O, no clock; the caller (SessionEngine, T-iOS-10) +/// feeds frames in and emits `SessionEvent.gate(current)` on change. +public struct GateTracker: Sendable, Equatable { + public static let initial = GateTracker(current: nil, lastEpoch: 0) + + /// The gate currently held server-side, or `nil` when none. + public let current: GateState? + + /// Highest epoch ever issued; NOT reset on falling edges so decisions + /// against a resolved gate can never match a later one. + private let lastEpoch: Int + + private init(current: GateState?, lastEpoch: Int) { + self.current = current + self.lastEpoch = lastEpoch + } + + /// Feeds one `status` frame's gate-relevant fields; returns the next snapshot. + public func reduce(pending: Bool, gate: GateKind?, detail: String?) -> GateTracker { + guard pending else { + // Falling edge (or still idle): gate lifted, epoch counter retained. + return GateTracker(current: nil, lastEpoch: lastEpoch) + } + let kind = gate ?? .tool + if let held = current { + // Sustained pending: same epoch, refresh kind/detail from the frame. + let refreshed = GateState(kind: kind, detail: detail, epoch: held.epoch) + return GateTracker(current: refreshed, lastEpoch: lastEpoch) + } + // Rising edge: mint the next epoch (the ONLY place it increments). + let nextEpoch = lastEpoch + 1 + let risen = GateState(kind: kind, detail: detail, epoch: nextEpoch) + return GateTracker(current: risen, lastEpoch: nextEpoch) + } + + /// True iff a decision tapped against `epoch` may be sent NOW: a gate must + /// be held and its epoch must match. Stale epoch or no gate → drop the + /// decision (防误批新 gate). + public func canDecide(epoch: Int) -> Bool { + guard let held = current else { return false } + return held.epoch == epoch + } +} diff --git a/ios/Packages/SessionCore/Sources/SessionCore/PingScheduler.swift b/ios/Packages/SessionCore/Sources/SessionCore/PingScheduler.swift new file mode 100644 index 0000000..1be12bc --- /dev/null +++ b/ios/Packages/SessionCore/Sources/SessionCore/PingScheduler.swift @@ -0,0 +1,62 @@ +import WireProtocol + +/// WS keep-alive pacer (T-iOS-5, plan §3.2 / §3.2.1). +/// +/// `URLSessionWebSocketTask` has NO automatic ping (plan §1), so without this +/// a half-dead connection "looks connected" forever. `run` sends an explicit +/// ping every `interval` (default `Tunables.pingInterval` = 25 s) on the +/// injected clock. +/// +/// Miss policy (`Tunables.pongMissLimit` = 2): one missed pong is tolerated; +/// the moment `pongMissLimit` CONSECUTIVE pings go unanswered the connection +/// is declared dead and `run` returns `.connectionLost` — the caller +/// (SessionEngine, T-iOS-10) then feeds `.disconnected` into +/// `ReconnectMachine`. Any answered ping resets the consecutive-miss counter. +/// +/// Zero real timers: the clock is injected (`any Clock`), so tests +/// drive a `FakeClock` and never wait wall-clock time. +public struct PingScheduler: Sendable { + /// How `run` ended. + public enum Outcome: Sendable, Equatable { + /// `Tunables.pongMissLimit` consecutive pings went unanswered — treat + /// the transport as disconnected. + case connectionLost + /// The surrounding task was cancelled (normal teardown, e.g. explicit + /// close or transport swap) — NOT a connectivity verdict. + case cancelled + } + + /// Ping period. Frozen default: `Tunables.pingInterval` (25 s). + public let interval: Duration + + public init(interval: Duration = Tunables.pingInterval) { + self.interval = interval + } + + /// Loops until the connection dies or the task is cancelled: sleep one + /// `interval` on `clock`, then await `sendPing`. + /// + /// `sendPing` returns `true` iff the pong arrived — the transport adapter + /// owns ping/pong resolution (e.g. racing `URLSessionWebSocketTask`'s + /// `pongReceiveHandler` against its own deadline, T-iOS-9); `false` = miss. + public func run( + clock: any Clock, + sendPing: @Sendable () async -> Bool + ) async -> Outcome { + var consecutiveMisses = 0 + while true { + do { + try await clock.sleep(for: interval, tolerance: nil) + } catch { + // Clock.sleep only throws CancellationError (FakeClock mirrors + // ContinuousClock semantics) — normal teardown, not a failure. + return .cancelled + } + let isPongReceived = await sendPing() + consecutiveMisses = isPongReceived ? 0 : consecutiveMisses + 1 + if consecutiveMisses >= Tunables.pongMissLimit { + return .connectionLost + } + } + } +} diff --git a/ios/Packages/SessionCore/Sources/SessionCore/ReconnectMachine.swift b/ios/Packages/SessionCore/Sources/SessionCore/ReconnectMachine.swift new file mode 100644 index 0000000..b4f3136 --- /dev/null +++ b/ios/Packages/SessionCore/Sources/SessionCore/ReconnectMachine.swift @@ -0,0 +1,70 @@ +/// Pure reconnect back-off reducer (T-iOS-5, plan §3.2). +/// +/// Mirrors the web client's behaviour in `public/terminal-session.ts`: +/// - line 106: first retry delay is 1 s, +/// - lines 352-361 (`scheduleReconnect`): the CURRENT delay is scheduled, +/// then doubled and capped at 30 s (`Math.min(delay * 2, 30_000)`), +/// - line 248: a successful open resets the delay to 1 s. +/// Ladder: 1s → 2s → 4s → 8s → 16s → 30s → 30s … +/// +/// PURE state machine: `reduce` never mutates the receiver — it returns a new +/// immutable snapshot plus the single side-effect the caller (SessionEngine, +/// T-iOS-10) must perform. No clock, no timers here; timing lives with the +/// caller, which is what makes this fully testable with zero real waits. +public struct ReconnectMachine: Sendable, Equatable { + /// First retry delay after a disconnect + /// (mirrors `public/terminal-session.ts:106` — `reconnectDelay = 1000`). + static let initialRetryDelay: Duration = .seconds(1) + + /// Back-off ceiling + /// (mirrors `public/terminal-session.ts:356` — `Math.min(delay * 2, 30_000)`). + static let maxRetryDelay: Duration = .seconds(30) + + /// Doubling factor per failed attempt (same mirror line as the ceiling). + static let backoffMultiplier = 2 + + public enum Input: Sendable, Equatable { + case connected + case disconnected + case retryTimerFired + case foregrounded + case userRetry + } + + public enum Effect: Sendable, Equatable { + case connectNow + case scheduleRetry(after: Duration) + case none + } + + public static let initial = ReconnectMachine(nextRetryDelay: initialRetryDelay) + + /// Delay the NEXT `.disconnected` will schedule. + private let nextRetryDelay: Duration + + private init(nextRetryDelay: Duration) { + self.nextRetryDelay = nextRetryDelay + } + + /// Feeds one input and returns `(next snapshot, effect to perform)`. + /// + /// - `.connected`: back-off resets to the initial 1 s ladder rung; no effect. + /// - `.disconnected`: schedule a retry after the current delay; the next + /// snapshot carries the doubled (capped) delay. + /// - `.retryTimerFired` / `.foregrounded` / `.userRetry`: connect NOW. + /// Foreground/user-initiated attempts deliberately do NOT reset the + /// ladder — only a successful `.connected` does, so mashing retry can + /// never turn back-off into hammering. + public func reduce(_ input: Input) -> (ReconnectMachine, Effect) { + switch input { + case .connected: + return (.initial, .none) + case .disconnected: + let doubled = nextRetryDelay * Self.backoffMultiplier + let next = ReconnectMachine(nextRetryDelay: min(doubled, Self.maxRetryDelay)) + return (next, .scheduleRetry(after: nextRetryDelay)) + case .retryTimerFired, .foregrounded, .userRetry: + return (self, .connectNow) + } + } +} diff --git a/ios/Packages/SessionCore/Tests/SessionCoreTests/AwayDigestTests.swift b/ios/Packages/SessionCore/Tests/SessionCoreTests/AwayDigestTests.swift new file mode 100644 index 0000000..07ff41b --- /dev/null +++ b/ios/Packages/SessionCore/Tests/SessionCoreTests/AwayDigestTests.swift @@ -0,0 +1,205 @@ +import Foundation +import Testing +import WireProtocol +@testable import SessionCore + +/// T-iOS-6 · AwayDigest — "what happened while I was away" reducer (plan §3.2). +/// Input is the `GET /live-sessions/:id/events` timeline (src/types.ts:428-433); +/// class vocabulary is the server's `TimelineClass` union (src/types.ts:423, +/// produced by src/session/timeline.ts CLASS_MAP): tool/waiting/done/stuck/user. +@Suite("AwayDigest") +struct AwayDigestTests { + // MARK: - helpers + + /// Chronology anchor: all test events sit at `leftAt + offset` ms. + private static let leftAt = Date(timeIntervalSince1970: 1_000) + private static let leftAtMs = 1_000_000 + private static let defaultLimit = 10 + + private static func event( + atOffsetMs offset: Int, + class className: String, + toolName: String? = nil, + label: String = "activity" + ) -> TimelineEvent { + TimelineEvent(at: leftAtMs + offset, class: className, toolName: toolName, label: label) + } + + // MARK: - counting + + @Test("counts tool runs, waiting events, and done across the event list") + func countsToolWaitingAndDoneAcrossEvents() { + // Arrange: 3 tool + 1 waiting + 1 done (task spec's canonical example). + let events = [ + Self.event(atOffsetMs: 1, class: "tool", toolName: "Bash", label: "ran Bash"), + Self.event(atOffsetMs: 2, class: "tool", toolName: "Edit", label: "edited with Edit"), + Self.event(atOffsetMs: 3, class: "waiting", label: "waiting for approval"), + Self.event(atOffsetMs: 4, class: "tool", toolName: "Write", label: "edited with Write"), + Self.event(atOffsetMs: 5, class: "done", label: "done"), + ] + + // Act + let digest = AwayDigest.reduce(events: events, since: Self.leftAt, limit: Self.defaultLimit) + + // Assert + #expect(digest.toolRuns == 3) + #expect(digest.waitingCount == 1) + #expect(digest.sawDone == true) + #expect(digest.sawStuck == false) + } + + @Test("a stuck event sets sawStuck") + func stuckEventSetsSawStuck() { + // Arrange + let events = [Self.event(atOffsetMs: 1, class: "stuck", label: "stuck")] + + // Act + let digest = AwayDigest.reduce(events: events, since: Self.leftAt, limit: Self.defaultLimit) + + // Assert + #expect(digest.sawStuck == true) + #expect(digest.sawDone == false) + } + + @Test("user events appear in recent but count toward no metric") + func userEventsAppearInRecentButCountNothing() { + // Arrange + let userEvent = Self.event(atOffsetMs: 1, class: "user", label: "user input") + + // Act + let digest = AwayDigest.reduce(events: [userEvent], since: Self.leftAt, limit: Self.defaultLimit) + + // Assert + #expect(digest.toolRuns == 0) + #expect(digest.waitingCount == 0) + #expect(digest.sawDone == false) + #expect(digest.sawStuck == false) + #expect(digest.recent == [userEvent]) + } + + // MARK: - since filter + + @Test("events strictly earlier than since are excluded from counts and recent") + func eventsBeforeSinceAreExcluded() { + // Arrange: one stale tool run before leaving, one fresh after. + let stale = Self.event(atOffsetMs: -1, class: "tool", label: "ran Bash") + let boundary = Self.event(atOffsetMs: 0, class: "waiting", label: "waiting for approval") + let fresh = Self.event(atOffsetMs: 1, class: "tool", label: "ran Edit") + + // Act + let digest = AwayDigest.reduce( + events: [stale, boundary, fresh], since: Self.leftAt, limit: Self.defaultLimit) + + // Assert: strictly-earlier excluded; the exact leave instant still counts. + #expect(digest.toolRuns == 1) + #expect(digest.waitingCount == 1) + #expect(digest.recent == [boundary, fresh]) + } + + @Test("all events before since yield the all-zero digest") + func allEventsBeforeSinceYieldAllZeroDigest() { + // Arrange + let events = [ + Self.event(atOffsetMs: -2, class: "tool", label: "ran Bash"), + Self.event(atOffsetMs: -1, class: "done", label: "done"), + ] + + // Act + let digest = AwayDigest.reduce(events: events, since: Self.leftAt, limit: Self.defaultLimit) + + // Assert: UI can skip rendering entirely. + #expect(digest == .empty) + #expect(digest.isEmpty == true) + } + + // MARK: - limit / recent + + @Test("limit truncates recent to the most recent events in chronological order") + func limitTruncatesRecentToMostRecentEvents() { + // Arrange + let events = (1...5).map { Self.event(atOffsetMs: $0, class: "tool", label: "ran \($0)") } + + // Act + let digest = AwayDigest.reduce(events: events, since: Self.leftAt, limit: 2) + + // Assert: newest two, still oldest→newest. + #expect(digest.recent == [events[3], events[4]]) + #expect(digest.toolRuns == 5) + } + + @Test("out-of-order server input is ordered by timestamp before truncating recent") + func outOfOrderEventsAreOrderedByTimestampForRecent() { + // Arrange: the server is an untrusted input source — order is not a given. + let oldest = Self.event(atOffsetMs: 1, class: "tool", label: "ran 1") + let middle = Self.event(atOffsetMs: 2, class: "waiting", label: "waiting") + let newest = Self.event(atOffsetMs: 3, class: "done", label: "done") + + // Act + let digest = AwayDigest.reduce( + events: [newest, oldest, middle], since: Self.leftAt, limit: 2) + + // Assert + #expect(digest.recent == [middle, newest]) + } + + @Test("zero or negative limit yields empty recent while counts still compute") + func zeroOrNegativeLimitYieldsEmptyRecentButCountsStillComputed() { + // Arrange + let events = [Self.event(atOffsetMs: 1, class: "tool", label: "ran Bash")] + + // Act + let zero = AwayDigest.reduce(events: events, since: Self.leftAt, limit: 0) + let negative = AwayDigest.reduce(events: events, since: Self.leftAt, limit: -3) + + // Assert + #expect(zero.recent.isEmpty) + #expect(zero.toolRuns == 1) + #expect(negative.recent.isEmpty) + #expect(negative.toolRuns == 1) + } + + // MARK: - empty / extreme inputs + + @Test("empty events yield the all-zero digest") + func emptyEventsYieldAllZeroDigest() { + // Arrange / Act + let digest = AwayDigest.reduce(events: [], since: Self.leftAt, limit: Self.defaultLimit) + + // Assert + #expect(digest == AwayDigest( + toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: false, recent: [])) + #expect(digest.isEmpty == true) + } + + @Test("extreme since dates never crash the ms conversion") + func extremeSinceDatesDoNotCrash() { + // Arrange + let events = [Self.event(atOffsetMs: 1, class: "tool", label: "ran Bash")] + + // Act + let future = AwayDigest.reduce(events: events, since: .distantFuture, limit: 5) + let past = AwayDigest.reduce(events: events, since: .distantPast, limit: 5) + let overflow = AwayDigest.reduce( + events: events, since: Date(timeIntervalSince1970: .greatestFiniteMagnitude), limit: 5) + + // Assert + #expect(future.isEmpty == true) + #expect(past.toolRuns == 1) + #expect(overflow.isEmpty == true) + } + + // MARK: - vocabulary sync + + @Test("digest class vocabulary stays in sync with WireProtocol's known classes") + func classVocabularyStaysInSyncWithWireProtocol() { + // Arrange: the local names must be exactly the server's TimelineClass + // union as frozen in WireProtocol (src/types.ts:423). + let local: Set = [ + TimelineClassName.tool, TimelineClassName.waiting, TimelineClassName.done, + TimelineClassName.stuck, TimelineClassName.user, + ] + + // Act / Assert + #expect(local == TimelineEvent.knownClasses) + } +} diff --git a/ios/Packages/SessionCore/Tests/SessionCoreTests/GateStateTests.swift b/ios/Packages/SessionCore/Tests/SessionCoreTests/GateStateTests.swift new file mode 100644 index 0000000..ea2c102 --- /dev/null +++ b/ios/Packages/SessionCore/Tests/SessionCoreTests/GateStateTests.swift @@ -0,0 +1,175 @@ +import Testing +import WireProtocol +@testable import SessionCore + +/// T-iOS-6 · GateState + GateTracker — permission-gate reducer (plan §3.2). +/// Mirrors the web client's stale-gate guard (public/terminal-session.ts:94-311): +/// epoch increments ONLY on a pending false→true rising edge; a decision carrying +/// a stale epoch is dropped so a slow tap can never approve the NEXT gate. +@Suite("GateState + GateTracker") +struct GateStateTests { + // MARK: - epoch semantics (rising edge only) + + @Test("pending false→true rising edge creates a gate with epoch +1") + func risingEdgeCreatesGateWithIncrementedEpoch() { + // Arrange + let tracker = GateTracker.initial + + // Act + let next = tracker.reduce(pending: true, gate: .tool, detail: "Bash") + + // Assert + #expect(tracker.current == nil) + #expect(next.current == GateState(kind: .tool, detail: "Bash", epoch: 1)) + } + + @Test("sustained pending frames keep the same epoch") + func sustainedPendingKeepsSameEpoch() { + // Arrange + let held = GateTracker.initial.reduce(pending: true, gate: .tool, detail: "Bash") + + // Act: two continuation frames while the same gate stays held. + let afterOne = held.reduce(pending: true, gate: .tool, detail: "Bash") + let afterTwo = afterOne.reduce(pending: true, gate: .tool, detail: "Bash") + + // Assert + #expect(afterOne.current?.epoch == 1) + #expect(afterTwo.current?.epoch == 1) + } + + @Test("continuation frames refresh detail from the latest frame without bumping epoch") + func sustainedPendingRefreshesDetailFromLatestFrame() { + // Arrange (mirrors terminal-session.ts:310 — pendingTool follows msg.detail) + let held = GateTracker.initial.reduce(pending: true, gate: .tool, detail: "Bash") + + // Act + let refreshed = held.reduce(pending: true, gate: .tool, detail: "Edit") + + // Assert + #expect(refreshed.current == GateState(kind: .tool, detail: "Edit", epoch: 1)) + } + + @Test("pending true→false falling edge clears the gate") + func fallingEdgeClearsGate() { + // Arrange + let held = GateTracker.initial.reduce(pending: true, gate: .tool, detail: "Bash") + + // Act + let cleared = held.reduce(pending: false, gate: nil, detail: nil) + + // Assert + #expect(cleared.current == nil) + } + + @Test("a second rising edge increments the epoch again") + func secondRisingEdgeIncrementsEpochAgain() { + // Arrange: gate 1 held, then resolved. + let cleared = GateTracker.initial + .reduce(pending: true, gate: .tool, detail: "Bash") + .reduce(pending: false, gate: nil, detail: nil) + + // Act: gate 2 arrives. + let second = cleared.reduce(pending: true, gate: .plan, detail: nil) + + // Assert + #expect(second.current == GateState(kind: .plan, detail: nil, epoch: 2)) + } + + // MARK: - stale-epoch decisions are dropped (防误批新 gate) + + @Test("a decision carrying a stale epoch is dropped") + func staleEpochDecisionIsDropped() { + // Arrange: gate 1 resolved, gate 2 now held (epoch 2). + let tracker = GateTracker.initial + .reduce(pending: true, gate: .tool, detail: "Bash") + .reduce(pending: false, gate: nil, detail: nil) + .reduce(pending: true, gate: .tool, detail: "Write") + + // Act / Assert: the user's slow tap against gate 1 must NOT approve gate 2. + #expect(tracker.canDecide(epoch: 1) == false) + } + + @Test("a decision carrying the current epoch is accepted") + func currentEpochDecisionIsAccepted() { + // Arrange + let tracker = GateTracker.initial + .reduce(pending: true, gate: .tool, detail: "Bash") + .reduce(pending: false, gate: nil, detail: nil) + .reduce(pending: true, gate: .tool, detail: "Write") + + // Act / Assert + #expect(tracker.canDecide(epoch: 2) == true) + } + + @Test("a decision when no gate is held is dropped even with the last-issued epoch") + func decisionWithNoGateHeldIsDropped() { + // Arrange: gate 1 was resolved server-side before the tap landed. + let cleared = GateTracker.initial + .reduce(pending: true, gate: .tool, detail: "Bash") + .reduce(pending: false, gate: nil, detail: nil) + + // Act / Assert + #expect(cleared.canDecide(epoch: 1) == false) + } + + // MARK: - affordances (plan → three-way, tool → two-way) + + @Test("plan gate offers the three-way affordance set") + func planGateOffersThreeWayAffordances() { + // Arrange + let gate = GateState(kind: .plan, detail: nil, epoch: 1) + + // Act / Assert (mirrors public/tabs.ts:343-350) + #expect(gate.affordances == [.approveAuto, .approveReview, .keepPlanning]) + } + + @Test("tool gate offers the two-way affordance set") + func toolGateOffersTwoWayAffordances() { + // Arrange + let gate = GateState(kind: .tool, detail: "Bash", epoch: 1) + + // Act / Assert (mirrors public/tabs.ts:334-339) + #expect(gate.affordances == [.approve, .reject]) + } + + @Test("pending frame without a gate kind defaults to a tool gate") + func pendingWithoutGateKindDefaultsToToolGate() { + // Arrange: an unrecognized/absent gate decodes as nil (ServerMessage) — + // the pending signal must never be lost (web: gate ?? null → tool buttons). + let tracker = GateTracker.initial + + // Act + let held = tracker.reduce(pending: true, gate: nil, detail: "Bash") + + // Assert + #expect(held.current?.kind == .tool) + #expect(held.current?.affordances == [.approve, .reject]) + } + + @Test("affordances map to the exact wire messages the web client sends") + func affordancesMapToWireMessagesMirroringWebClient() { + // Arrange / Act / Assert (public/tabs.ts:345-347: acceptEdits / default / reject) + #expect(GateState.Affordance.approveAuto.clientMessage == .approve(mode: .acceptEdits)) + #expect(GateState.Affordance.approveReview.clientMessage == .approve(mode: .default)) + #expect(GateState.Affordance.keepPlanning.clientMessage == .reject) + #expect(GateState.Affordance.approve.clientMessage == .approve(mode: nil)) + #expect(GateState.Affordance.reject.clientMessage == .reject) + } + + // MARK: - purity + + @Test("reduce is pure: same input gives same output and never mutates the receiver") + func reduceIsPureAndDoesNotMutateReceiver() { + // Arrange + let tracker = GateTracker.initial + let snapshotBefore = tracker + + // Act: reduce twice with the identical input on the SAME value. + let first = tracker.reduce(pending: true, gate: .plan, detail: "d") + let second = tracker.reduce(pending: true, gate: .plan, detail: "d") + + // Assert + #expect(first == second) + #expect(tracker == snapshotBefore) + } +} diff --git a/ios/Packages/SessionCore/Tests/SessionCoreTests/PingSchedulerTests.swift b/ios/Packages/SessionCore/Tests/SessionCoreTests/PingSchedulerTests.swift new file mode 100644 index 0000000..a76ceb9 --- /dev/null +++ b/ios/Packages/SessionCore/Tests/SessionCoreTests/PingSchedulerTests.swift @@ -0,0 +1,186 @@ +import Testing +import TestSupport +import WireProtocol +@testable import SessionCore + +/// T-iOS-5 · PingScheduler — keep-alive pacer (plan §3.2 / §3.2.1). +/// 25s ping cadence (`Tunables.pingInterval`); 1 missed pong tolerated, +/// `Tunables.pongMissLimit` (2) CONSECUTIVE misses → connection declared lost. +/// All timing on FakeClock — zero real waits. +@Suite("PingScheduler") +struct PingSchedulerTests { + /// Scripted pong ledger: pops one result per ping, records the call count. + /// Runs out of script → answers `true` (pong received). + private actor PongScript { + private var results: [Bool] + private(set) var pingCount = 0 + + init(_ results: [Bool]) { + self.results = results + } + + func nextResult() -> Bool { + pingCount += 1 + guard !results.isEmpty else { return true } + return results.removeFirst() + } + } + + /// Starts `run` on a FakeClock and parks it at its first sleep. + private static func startScheduler( + clock: FakeClock, + script: PongScript, + interval: Duration = Tunables.pingInterval + ) async -> Task { + let scheduler = PingScheduler(interval: interval) + let task = Task { + await scheduler.run(clock: clock) { await script.nextResult() } + } + await clock.waitForSleepers(count: 1) + return task + } + + /// Fires one ping tick: advance past the interval, then wait until the + /// scheduler is parked in its NEXT sleep (proof the tick fully processed). + private static func fireTickAndAwaitNextSleep(clock: FakeClock) async { + clock.advance(by: Tunables.pingInterval) + await clock.waitForSleepers(count: 1) + } + + @Test("default interval is Tunables.pingInterval (25s)") + func defaultIntervalIsTunablesPingInterval() { + // Arrange / Act + let scheduler = PingScheduler() + + // Assert + #expect(scheduler.interval == Tunables.pingInterval) + #expect(scheduler.interval == .seconds(25)) + } + + @Test("sends one ping per interval tick while pongs keep arriving") + func sendsOnePingPerIntervalTickWhenPongsArrive() async { + // Arrange + let clock = FakeClock() + let script = PongScript([true, true, true]) + let task = await Self.startScheduler(clock: clock, script: script) + + // Act: three full interval ticks. + await Self.fireTickAndAwaitNextSleep(clock: clock) + await Self.fireTickAndAwaitNextSleep(clock: clock) + await Self.fireTickAndAwaitNextSleep(clock: clock) + + // Assert: exactly one ping per tick, none before the first interval. + #expect(await script.pingCount == 3) + task.cancel() + #expect(await task.value == .cancelled) + } + + @Test("does not ping before the first interval has elapsed") + func doesNotPingBeforeFirstIntervalElapses() async { + // Arrange + let clock = FakeClock() + let script = PongScript([]) + let task = await Self.startScheduler(clock: clock, script: script) + + // Act: advance just short of the interval. + clock.advance(by: Tunables.pingInterval - .seconds(1)) + + // Assert: still parked, zero pings sent. + #expect(clock.pendingSleeperCount == 1) + #expect(await script.pingCount == 0) + task.cancel() + #expect(await task.value == .cancelled) + } + + @Test("tolerates a single missed pong and keeps the connection alive") + func toleratesSingleMissedPongAndKeepsPinging() async { + // Arrange: miss one pong, then recover. + let clock = FakeClock() + let script = PongScript([false, true]) + let task = await Self.startScheduler(clock: clock, script: script) + + // Act: tick #1 misses — scheduler must survive and park again; + // tick #2 gets its pong. + await Self.fireTickAndAwaitNextSleep(clock: clock) + await Self.fireTickAndAwaitNextSleep(clock: clock) + + // Assert: both pings sent, run still alive (it re-parked after each). + #expect(await script.pingCount == 2) + task.cancel() + #expect(await task.value == .cancelled) + } + + @Test("two consecutive missed pongs signal connectionLost") + func twoConsecutiveMissedPongsSignalConnectionLost() async { + // Arrange + let clock = FakeClock() + let script = PongScript([false, false]) + let task = await Self.startScheduler(clock: clock, script: script) + + // Act: miss #1 (tolerated) … + await Self.fireTickAndAwaitNextSleep(clock: clock) + // … miss #2 == Tunables.pongMissLimit → run must finish by itself. + clock.advance(by: Tunables.pingInterval) + + // Assert + #expect(await task.value == .connectionLost) + #expect(await script.pingCount == Tunables.pongMissLimit) + + // Assert: a dead scheduler never pings again. + clock.advance(by: Tunables.pingInterval) + #expect(await script.pingCount == Tunables.pongMissLimit) + } + + @Test("a pong between misses resets the counter so non-consecutive misses never disconnect") + func pongBetweenMissesResetsConsecutiveMissCounter() async { + // Arrange: miss, pong, miss, pong — never two misses in a row. + let clock = FakeClock() + let script = PongScript([false, true, false, true]) + let task = await Self.startScheduler(clock: clock, script: script) + + // Act + for _ in 0..<4 { + await Self.fireTickAndAwaitNextSleep(clock: clock) + } + + // Assert: still alive after 4 ticks (it re-parked after the last one). + #expect(await script.pingCount == 4) + task.cancel() + #expect(await task.value == .cancelled) + } + + @Test("cancellation while sleeping ends run as cancelled, not connectionLost") + func cancellationWhileSleepingEndsRunAsCancelled() async { + // Arrange + let clock = FakeClock() + let script = PongScript([]) + let task = await Self.startScheduler(clock: clock, script: script) + + // Act: tear down mid-sleep (normal close path). + task.cancel() + + // Assert + #expect(await task.value == .cancelled) + #expect(await script.pingCount == 0) + } + + @Test("honors a custom interval instead of the default") + func honorsCustomIntervalInsteadOfDefault() async { + // Arrange + let customInterval: Duration = .seconds(5) + let clock = FakeClock() + let script = PongScript([true]) + let task = await Self.startScheduler( + clock: clock, script: script, interval: customInterval + ) + + // Act: one custom-interval tick. + clock.advance(by: customInterval) + await clock.waitForSleepers(count: 1) + + // Assert + #expect(await script.pingCount == 1) + task.cancel() + #expect(await task.value == .cancelled) + } +} diff --git a/ios/Packages/SessionCore/Tests/SessionCoreTests/ReconnectMachineTests.swift b/ios/Packages/SessionCore/Tests/SessionCoreTests/ReconnectMachineTests.swift new file mode 100644 index 0000000..1216671 --- /dev/null +++ b/ios/Packages/SessionCore/Tests/SessionCoreTests/ReconnectMachineTests.swift @@ -0,0 +1,148 @@ +import Testing +@testable import SessionCore + +/// T-iOS-5 · ReconnectMachine — pure back-off reducer (plan §3.2). +/// Mirrors public/terminal-session.ts:106,248,352-361: retry delays +/// 1s → 2s → 4s → 8s → 16s → 30s (capped), reset to 1s on `connected`. +@Suite("ReconnectMachine") +struct ReconnectMachineTests { + /// Delay ladder the web client produces (public/terminal-session.ts:356). + private static let expectedBackoffLadder: [Duration] = [ + .seconds(1), .seconds(2), .seconds(4), .seconds(8), + .seconds(16), .seconds(30), .seconds(30), + ] + + /// Drives `count` disconnect→timer-fired cycles from `.initial` and + /// collects every `.scheduleRetry(after:)` delay along the way. + private static func scheduledDelays(disconnectCount: Int) -> [Duration] { + var machine = ReconnectMachine.initial + var delays: [Duration] = [] + for _ in 0.. ReconnectMachine { + var machine = ReconnectMachine.initial + for _ in 0..