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.
188 lines
6.5 KiB
Swift
188 lines
6.5 KiB
Swift
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
|
||
}
|
||
}
|