import APIClient import Foundation import Testing @testable import WebTerm /// T-iOS-32 · `claude --resume ` 历史(`GET /sessions`)的 App 层归约。 /// /// RED 清单见 `docs/plans/ios-completion.md` §4(E 28–35)。安全重点:会话 id 是 /// **不可信服务器字节**,而它会被拼进一条真的送进 PTY 的命令行 —— 因此白名单校验 /// 是本任务的核心用例(web 侧 `public/tabs.ts:918` 缺这一层)。 @MainActor @Suite("ResumeHistoryViewModel") struct ResumeHistoryViewModelTests { private static let projectPath = "/repos/web-terminal" private nonisolated static func session( id: String = "3f2b1c4d-0000-4000-8000-000000000001", cwd: String, mtimeMs: Double = 1_785_390_645_813.5 ) -> HistorySession { HistorySession( id: id, cwd: cwd, project: "web-terminal", mtimeMs: mtimeMs, preview: "修一下 CJK locale" ) } // MARK: - 过滤(28–30) @Test("只保留 cwd 落在本项目内的会话(含仓库根与子目录/worktree)") func keepsOnlySessionsInsideProject() { let inside = Self.session(id: "a1", cwd: Self.projectPath) let nested = Self.session(id: "a2", cwd: Self.projectPath + "/.claude/worktrees/x") let outside = Self.session(id: "a3", cwd: "/repos/other") let candidates = ResumeHistoryViewModel.candidates( from: [inside, nested, outside], projectPath: Self.projectPath ) #expect(candidates.map(\.id) == ["a1", "a2"]) } @Test("前缀不得半路匹配:/repos/web-terminal-old 不属于 /repos/web-terminal") func siblingPrefixIsNotInside() { let sibling = Self.session(id: "a1", cwd: "/repos/web-terminal-old") let candidates = ResumeHistoryViewModel.candidates( from: [sibling], projectPath: Self.projectPath ) #expect(candidates.isEmpty) } @Test("服务器顺序(mtime 新→旧)原样保留,客户端不重排") func serverOrderIsPreserved() { let older = Self.session(id: "old", cwd: Self.projectPath, mtimeMs: 1_000) let newer = Self.session(id: "new", cwd: Self.projectPath, mtimeMs: 9_000) let candidates = ResumeHistoryViewModel.candidates( from: [newer, older], projectPath: Self.projectPath ) #expect(candidates.map(\.id) == ["new", "old"]) } @Test("cwd 非绝对路径 → 不可恢复(Validation.isAbsoluteCwd 纪律)") func relativeCwdIsFilteredOut() { let relative = Self.session(id: "a1", cwd: "repos/web-terminal") let candidates = ResumeHistoryViewModel.candidates( from: [relative], projectPath: "repos/web-terminal" ) #expect(candidates.isEmpty) } // MARK: - phase(31–32) @Test("空结果 → .empty(不是 .failed:服务器无历史时正常回 [])") func emptyListIsNotFailure() async { let vm = ResumeHistoryViewModel(projectPath: Self.projectPath, fetch: { [] }) await vm.load() #expect(vm.phase == .empty) } @Test("过滤后为空(有历史但都不属于本仓库)→ 同样 .empty") func allFilteredOutIsEmpty() async { let vm = ResumeHistoryViewModel( projectPath: Self.projectPath, fetch: { [Self.session(id: "a1", cwd: "/repos/other")] } ) await vm.load() #expect(vm.phase == .empty) } @Test("加载失败 → .failed(可重试),重试成功 → .loaded") func failureIsRetryable() async { let flag = ResumeFailOnceFlag() let payload = Self.session(id: "a1", cwd: Self.projectPath) let vm = ResumeHistoryViewModel(projectPath: Self.projectPath, fetch: { if await flag.consumeShouldFail() { throw APIClientError.gitDataUnavailable } return [payload] }) await vm.load() guard case .failed = vm.phase else { Issue.record("应为 .failed,实际 \(vm.phase)") return } await vm.load() #expect(vm.phase == .loaded(ResumeHistoryViewModel.candidates( from: [payload], projectPath: Self.projectPath ))) } // MARK: - 命令合成(33–34,安全) @Test( "危险 id 绝不拼进 PTY 命令行(注入白名单)", arguments: [ "abc; rm -rf ~", "abc `id`", "abc$(id)", "abc\nwhoami", "abc id", "abc'x'", "abc\"x\"", "abc|x", "abc&x", "abc>x", "../../etc/passwd", "", ] ) func dangerousIdsAreRejected(sessionId: String) { #expect( ProjectResumeCommand.bootstrapInput(sessionId: sessionId) == nil, "\(sessionId) 不应被接受" ) } @Test("超长 id(> 128)拒绝") func overlongIdRejected() { let long = String(repeating: "a", count: 129) #expect(ProjectResumeCommand.bootstrapInput(sessionId: long) == nil) } @Test("合法 id → `claude --resume ` 且以 \\r(0x0D)结尾,绝不是 \\n") func validIdBuildsCarriageReturnCommand() throws { let id = "3f2b1c4d-0000-4000-8000-000000000001" let input = try #require(ProjectResumeCommand.bootstrapInput(sessionId: id)) #expect(input == "claude --resume \(id)\r") #expect(input.hasSuffix("\r")) #expect(!input.contains("\n")) } @Test("非法 id 的历史行仍显示,但 canResume == false(不提供恢复按钮)") func rowWithBadIdIsNotResumable() { let bad = Self.session(id: "abc; rm -rf ~", cwd: Self.projectPath) let candidates = ResumeHistoryViewModel.candidates( from: [bad], projectPath: Self.projectPath ) #expect(candidates.count == 1) #expect(!candidates[0].canResume) } @Test("合法行 canResume == true,且携带会话自己的 cwd(worktree 会话回到 worktree)") func resumableRowCarriesItsOwnCwd() throws { let nestedCwd = Self.projectPath + "/.claude/worktrees/x" let session = Self.session(id: "a1", cwd: nestedCwd) let candidate = try #require(ResumeHistoryViewModel.candidates( from: [session], projectPath: Self.projectPath ).first) #expect(candidate.canResume) #expect(candidate.cwd == nestedCwd) } } /// 一次性失败开关(@Sendable fetch 闭包里的可变状态 → actor 隔离)。 private actor ResumeFailOnceFlag { private var shouldFail = true func consumeShouldFail() -> Bool { defer { shouldFail = false } return shouldFail } }