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.
100 lines
3.6 KiB
Swift
100 lines
3.6 KiB
Swift
import APIClient
|
||
import Foundation
|
||
import HostRegistry
|
||
import TestSupport
|
||
import Testing
|
||
import WireProtocol
|
||
@testable import WebTerm
|
||
|
||
/// T-iOS-32(C2)· 历史会话恢复的**发射端**:`ProjectsViewModel.requestResumeClaude`
|
||
/// 必须走与"在此仓库开新会话"完全相同的 `attach(null, cwd)` 缝,只把 bootstrap 换成
|
||
/// `claude --resume <id>\r`,并在铸造 request 之前拦下不可信的 cwd / 会话 id。
|
||
@MainActor
|
||
@Suite("ProjectResumeLaunch")
|
||
struct ProjectResumeLaunchTests {
|
||
private nonisolated static let base = "http://192.168.1.5:3000"
|
||
|
||
private func makeViewModel() throws -> ProjectsViewModel {
|
||
let baseURL = try #require(URL(string: Self.base))
|
||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||
let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
|
||
return ProjectsViewModel(host: host, http: FakeHTTPTransport())
|
||
}
|
||
|
||
@Test("合法 cwd + 合法 id → OpenRequest 携带 `claude --resume <id>\\r`")
|
||
func resumeBuildsBootstrapInput() throws {
|
||
let viewModel = try makeViewModel()
|
||
let sessionId = "3f2b1c4d-0000-4000-8000-000000000001"
|
||
|
||
viewModel.requestResumeClaude(cwd: "/repos/web-terminal", sessionId: sessionId)
|
||
|
||
let request = try #require(viewModel.openRequest)
|
||
#expect(request.cwd == "/repos/web-terminal")
|
||
#expect(request.bootstrapInput == "claude --resume \(sessionId)\r")
|
||
#expect(viewModel.openErrorMessage == nil)
|
||
}
|
||
|
||
@Test("相对 cwd → 拒绝铸造(与 requestOpenClaude 同款纪律)")
|
||
func relativeCwdIsRejected() throws {
|
||
let viewModel = try makeViewModel()
|
||
|
||
viewModel.requestResumeClaude(cwd: "repos/web-terminal", sessionId: "abc")
|
||
|
||
#expect(viewModel.openRequest == nil)
|
||
#expect(viewModel.openErrorMessage != nil)
|
||
}
|
||
|
||
@Test("危险会话 id → 拒绝铸造(绝不把它送进 PTY 命令行)")
|
||
func injectionIdIsRejected() throws {
|
||
let viewModel = try makeViewModel()
|
||
|
||
viewModel.requestResumeClaude(
|
||
cwd: "/repos/web-terminal", sessionId: "abc; rm -rf ~"
|
||
)
|
||
|
||
#expect(viewModel.openRequest == nil)
|
||
#expect(viewModel.openErrorMessage == ResumeCopy.notResumable)
|
||
}
|
||
|
||
@Test("普通开会话仍是 `claude\\r`(恢复路径没有改动既有行为)")
|
||
func plainOpenIsUnchanged() throws {
|
||
let viewModel = try makeViewModel()
|
||
|
||
viewModel.requestOpenClaude(cwd: "/repos/web-terminal")
|
||
|
||
#expect(viewModel.openRequest?.bootstrapInput == ProjectLaunch.claudeBootstrapInput)
|
||
}
|
||
}
|
||
|
||
/// C2 · PR 链接白名单(`PrStatus.url` 是服务器字节 —— 交给 `openURL` 等于让远端
|
||
/// 决定要唤起哪个 App)。
|
||
@Suite("PrLink")
|
||
struct PrLinkTests {
|
||
@Test("https + github.com(含子域)→ 可点")
|
||
func githubHttpsIsAllowed() {
|
||
#expect(PrLink.url(from: "https://github.com/o/r/pull/7") != nil)
|
||
#expect(PrLink.url(from: "https://www.github.com/o/r/pull/7") != nil)
|
||
}
|
||
|
||
@Test(
|
||
"其余一律拒绝:http、自定义 scheme、非 github 主机、伪造后缀、nil",
|
||
arguments: [
|
||
"http://github.com/o/r/pull/7",
|
||
"javascript:alert(1)",
|
||
"webterm://join?id=1",
|
||
"https://evil.com/o/r/pull/7",
|
||
"https://github.com.evil.com/o/r/pull/7",
|
||
"not a url at all",
|
||
"",
|
||
]
|
||
)
|
||
func everythingElseIsRejected(raw: String) {
|
||
#expect(PrLink.url(from: raw) == nil, "\(raw) 不应被接受")
|
||
}
|
||
|
||
@Test("nil URL(gh 降级时没有 url 字段)→ nil")
|
||
func nilIsRejected() {
|
||
#expect(PrLink.url(from: nil) == nil)
|
||
}
|
||
}
|