feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests.
This commit is contained in:
187
ios/App/WebTermTests/ResumeHistoryViewModelTests.swift
Normal file
187
ios/App/WebTermTests/ResumeHistoryViewModelTests.swift
Normal file
@@ -0,0 +1,187 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-32 · `claude --resume <id>` 历史(`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 <id>` 且以 \\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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user