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.
447 lines
19 KiB
Swift
447 lines
19 KiB
Swift
import APIClient
|
||
import Foundation
|
||
import HostRegistry
|
||
import TestSupport
|
||
import Testing
|
||
import WireProtocol
|
||
@testable import WebTerm
|
||
|
||
/// T-iOS-12 · PairingViewModel (plan §7 / §5.4). Probe LOGIC is T-iOS-8's
|
||
/// domain — these tests cover the state mapping around it:
|
||
/// scan/manual input → confirm gate (ZERO network before the user says go) →
|
||
/// probe → Host into the store + navigate signal, error taxonomy → copy +
|
||
/// action, and the §5.4 warning tiers.
|
||
///
|
||
/// Determinism: the probe is injected as a closure; scripted results come from
|
||
/// an actor-backed `ProbeScript` (also the non-invocation counter). The
|
||
/// end-to-end test runs the REAL `runPairingProbe` over `FakeHTTPTransport` +
|
||
/// `FakeTransport` — zero real network, zero real waits.
|
||
@MainActor
|
||
@Suite("PairingViewModel")
|
||
struct PairingViewModelTests {
|
||
// MARK: - Probe script (scripted results + invocation recording)
|
||
|
||
private actor ProbeScript {
|
||
private(set) var calls: [HostEndpoint] = []
|
||
private var results: [Result<HostEndpoint, PairingError>]
|
||
|
||
/// Empty `results` = always succeed with the probed endpoint.
|
||
init(results: [Result<HostEndpoint, PairingError>] = []) {
|
||
self.results = results
|
||
}
|
||
|
||
func invoke(_ endpoint: HostEndpoint) -> Result<HostEndpoint, PairingError> {
|
||
calls = calls + [endpoint]
|
||
guard let next = results.first else { return .success(endpoint) }
|
||
results = Array(results.dropFirst())
|
||
return next
|
||
}
|
||
}
|
||
|
||
private func makeViewModel(
|
||
store: any HostStore = InMemoryHostStore(),
|
||
script: ProbeScript
|
||
) -> PairingViewModel {
|
||
// C1 · `Probe` is now a value carrying the three capabilities; the
|
||
// token/push ones keep their zero-config defaults for these tests.
|
||
PairingViewModel(
|
||
store: store,
|
||
probe: PairingViewModel.Probe(verifyHost: { endpoint, _ in
|
||
await script.invoke(endpoint)
|
||
})
|
||
)
|
||
}
|
||
|
||
private struct StoreFailure: Error {}
|
||
|
||
private actor ThrowingHostStore: HostStore {
|
||
func loadAll() async throws -> [HostRegistry.Host] { [] }
|
||
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
|
||
throw StoreFailure()
|
||
}
|
||
func remove(id: UUID) async throws -> [HostRegistry.Host] { [] }
|
||
}
|
||
|
||
// MARK: - Scan → confirm gate → REAL two-step probe → store + navigate
|
||
|
||
@Test("scan shows the HostEndpoint-parsed address, no network until confirm; then probe → Host into store + navigate signal")
|
||
func scanConfirmGateThenRealProbePairsHost() async throws {
|
||
// Arrange: REAL runPairingProbe over fakes — the strongest proof that
|
||
// (a) nothing touches the wire before the user confirms and (b) the
|
||
// production closure wiring is exercised end to end.
|
||
let http = FakeHTTPTransport()
|
||
let ws = FakeTransport()
|
||
let store = InMemoryHostStore()
|
||
let viewModel = PairingViewModel(
|
||
store: store,
|
||
probe: PairingViewModel.Probe(verifyHost: { endpoint, token in
|
||
await runPairingProbe(
|
||
endpoint: endpoint, http: http, ws: ws, accessToken: token?.rawValue
|
||
)
|
||
})
|
||
)
|
||
let base = "http://192.168.1.5:3000"
|
||
let probeSessionId = "1b671a64-40d5-491e-99b0-da01ff1f3341"
|
||
// Script the full happy path UP FRONT — if the VM probed before
|
||
// confirm, recordedRequests would already be non-empty below.
|
||
await http.queueSuccess(
|
||
url: try #require(URL(string: "\(base)/live-sessions")),
|
||
body: Data("[]".utf8)
|
||
)
|
||
await ws.emit(frame: #"{"type":"attached","sessionId":"\#(probeSessionId)"}"#)
|
||
await http.queueSuccess(
|
||
method: "DELETE",
|
||
url: try #require(URL(string: "\(base)/live-sessions/\(probeSessionId)")),
|
||
status: 204
|
||
)
|
||
|
||
// Act: scan the web UI QR payload (public/qr.ts encodes location.origin).
|
||
viewModel.handleScannedCode(base)
|
||
|
||
// Assert: confirm state shows the single-point-derived address and the
|
||
// scanned host has seen ZERO network traffic (probe ① would GET, probe
|
||
// ② would spawn a PTY on the target — untrusted scan input, plan §5).
|
||
guard case .confirming(let pending) = viewModel.phase else {
|
||
Issue.record("expected .confirming, got \(viewModel.phase)")
|
||
return
|
||
}
|
||
#expect(pending.displayAddress == base)
|
||
#expect(pending.endpoint.originHeader == base)
|
||
#expect(pending.warning == .plaintextLAN)
|
||
#expect(await http.recordedRequests.isEmpty)
|
||
#expect(await ws.connectAttempts.isEmpty)
|
||
#expect(viewModel.pairedHost == nil)
|
||
|
||
// Act: name the host, then confirm — ONLY now may the probe run.
|
||
viewModel.hostName = "书房 Mac"
|
||
await viewModel.confirmConnect()
|
||
|
||
// Assert: paired + navigate signal; Host{id,name} constructed by the
|
||
// VM (§3.4 contract ruling) and upserted into the store.
|
||
guard case .paired(let host) = viewModel.phase else {
|
||
Issue.record("expected .paired, got \(viewModel.phase)")
|
||
return
|
||
}
|
||
#expect(host.name == "书房 Mac")
|
||
#expect(host.endpoint == pending.endpoint)
|
||
#expect(viewModel.pairedHost == host)
|
||
#expect(try await store.loadAll() == [host])
|
||
|
||
// Assert: exactly the two probe HTTP calls, in order, Origin stamped
|
||
// iff guarded (§3.4 铁律) — and one WS attach round-trip, closed.
|
||
let requests = await http.recordedRequests
|
||
#expect(requests.map(\.httpMethod) == ["GET", "DELETE"])
|
||
#expect(requests[0].value(forHTTPHeaderField: "Origin") == nil)
|
||
#expect(requests[1].value(forHTTPHeaderField: "Origin") == base)
|
||
#expect(await ws.connectAttempts.count == 1)
|
||
#expect(await ws.closeCallCount == 1)
|
||
|
||
// Assert: a stray scan cannot preempt a finished pairing.
|
||
viewModel.handleScannedCode("http://10.0.0.9:3000")
|
||
#expect(viewModel.phase == .paired(host))
|
||
}
|
||
|
||
// MARK: - Input boundary: scan payloads are untrusted external input
|
||
|
||
@Test("non-http(s) scan payloads are rejected with copy and zero probe calls", arguments: [
|
||
"ftp://192.168.1.5:3000",
|
||
"ws://192.168.1.5:3000",
|
||
"javascript:alert(1)",
|
||
"WIFI:S:mynet;T:WPA;P:hunter2;;",
|
||
"",
|
||
])
|
||
func scanRejectsNonHTTPPayloads(payload: String) async throws {
|
||
// Arrange
|
||
let script = ProbeScript()
|
||
let viewModel = makeViewModel(script: script)
|
||
|
||
// Act
|
||
viewModel.handleScannedCode(payload)
|
||
|
||
// Assert: stays idle, inline rejection copy, probe never invoked.
|
||
#expect(viewModel.phase == .idle)
|
||
#expect(viewModel.inputRejection == PairingCopy.scanRejected)
|
||
#expect(await script.calls.isEmpty)
|
||
}
|
||
|
||
// MARK: - Manual entry (documented decision: reuses the confirm state)
|
||
|
||
@Test("manual entry reuses the confirm state; a bare host:port gets the http:// convenience prefix", arguments: [
|
||
("http://192.168.1.5:3000", "http://192.168.1.5:3000"),
|
||
("192.168.1.5:3000", "http://192.168.1.5:3000"),
|
||
("https://mac.tail1234.ts.net", "https://mac.tail1234.ts.net"),
|
||
])
|
||
func manualEntryEntersConfirmState(input: String, expectedOrigin: String) async throws {
|
||
// Arrange
|
||
let script = ProbeScript()
|
||
let viewModel = makeViewModel(script: script)
|
||
|
||
// Act
|
||
viewModel.submitManualURL(input)
|
||
|
||
// Assert: SAME confirm state as the scan path (uniform §5.4 warning
|
||
// surface — documented T-iOS-12 decision), still zero probe calls.
|
||
guard case .confirming(let pending) = viewModel.phase else {
|
||
Issue.record("expected .confirming for \(input), got \(viewModel.phase)")
|
||
return
|
||
}
|
||
#expect(pending.endpoint.originHeader == expectedOrigin)
|
||
#expect(await script.calls.isEmpty)
|
||
}
|
||
|
||
@Test("unparseable manual input is rejected with copy", arguments: [
|
||
"", " ", "://nope", "http://",
|
||
])
|
||
func manualEntryRejectsUnparseableInput(input: String) async throws {
|
||
// Arrange
|
||
let script = ProbeScript()
|
||
let viewModel = makeViewModel(script: script)
|
||
|
||
// Act
|
||
viewModel.submitManualURL(input)
|
||
|
||
// Assert
|
||
#expect(viewModel.phase == .idle)
|
||
#expect(viewModel.inputRejection == PairingCopy.manualRejected)
|
||
#expect(await script.calls.isEmpty)
|
||
}
|
||
|
||
// MARK: - §5.4 warning tiers (shown on the confirm page)
|
||
|
||
@Test("warning tiers follow the §5.4 table", arguments: [
|
||
// public host → strongest BLOCKING warning, http AND https alike
|
||
("http://203.0.113.7:3000", PairingViewModel.SecurityWarning.publicHostBlocking),
|
||
("https://example.com", PairingViewModel.SecurityWarning.publicHostBlocking),
|
||
// ws:// to RFC1918 / link-local / .local → non-blocking plaintext notice
|
||
("http://192.168.1.5:3000", PairingViewModel.SecurityWarning.plaintextLAN),
|
||
("http://10.1.2.3:3000", PairingViewModel.SecurityWarning.plaintextLAN),
|
||
("http://172.20.10.2:3000", PairingViewModel.SecurityWarning.plaintextLAN),
|
||
("http://169.254.10.2:3000", PairingViewModel.SecurityWarning.plaintextLAN),
|
||
("http://mymac.local:3000", PairingViewModel.SecurityWarning.plaintextLAN),
|
||
// Tailscale (100.64/10 CGNAT or MagicDNS *.ts.net) → no plaintext
|
||
// warning (WireGuard already encrypts); positive badge instead
|
||
("http://100.101.102.103:3000", PairingViewModel.SecurityWarning.tailscaleEncrypted),
|
||
("http://mac.tail1234.ts.net:3000", PairingViewModel.SecurityWarning.tailscaleEncrypted),
|
||
// loopback → none; https to a private-class host → none
|
||
("http://127.0.0.1:3000", PairingViewModel.SecurityWarning.none),
|
||
("http://localhost:3000", PairingViewModel.SecurityWarning.none),
|
||
("https://192.168.1.5:3000", PairingViewModel.SecurityWarning.none),
|
||
("https://mac.tail1234.ts.net", PairingViewModel.SecurityWarning.none),
|
||
])
|
||
func warningTiersFollowTable(url: String, expected: PairingViewModel.SecurityWarning) throws {
|
||
// Arrange
|
||
let baseURL = try #require(URL(string: url))
|
||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||
|
||
// Act & Assert
|
||
#expect(PairingViewModel.warning(for: endpoint) == expected)
|
||
}
|
||
|
||
@Test("public-host blocking warning requires explicit acknowledgement before any probe")
|
||
func blockingWarningGatesTheProbe() async throws {
|
||
// Arrange
|
||
let script = ProbeScript()
|
||
let viewModel = makeViewModel(script: script)
|
||
viewModel.handleScannedCode("http://203.0.113.7:3000")
|
||
guard case .confirming(let pending) = viewModel.phase else {
|
||
Issue.record("expected .confirming, got \(viewModel.phase)")
|
||
return
|
||
}
|
||
#expect(pending.warning == .publicHostBlocking)
|
||
|
||
// Act: confirm WITHOUT acknowledging the risk.
|
||
await viewModel.confirmConnect()
|
||
|
||
// Assert: no probe, still confirming, the UI is told to demand the ack.
|
||
#expect(await script.calls.isEmpty)
|
||
#expect(viewModel.phase == .confirming(pending))
|
||
#expect(viewModel.needsPublicRiskAcknowledgement)
|
||
|
||
// Act: explicit acknowledgement, then confirm again.
|
||
viewModel.hasAcknowledgedPublicRisk = true
|
||
await viewModel.confirmConnect()
|
||
|
||
// Assert: probe ran exactly once and pairing completed.
|
||
#expect(await script.calls.count == 1)
|
||
guard case .paired = viewModel.phase else {
|
||
Issue.record("expected .paired, got \(viewModel.phase)")
|
||
return
|
||
}
|
||
}
|
||
|
||
// MARK: - PairingError taxonomy → inline copy + recovery action
|
||
|
||
@Test("every PairingError maps to actionable copy and the right recovery action", arguments: [
|
||
(PairingError.localNetworkDenied,
|
||
PairingViewModel.RecoveryAction.openLocalNetworkSettings,
|
||
["本地网络", "设置"]),
|
||
(PairingError.hostUnreachable(underlying: "Connection refused"),
|
||
PairingViewModel.RecoveryAction.retry,
|
||
["Connection refused"]),
|
||
(PairingError.httpOkButNotWebTerminal,
|
||
PairingViewModel.RecoveryAction.retry,
|
||
["端口"]),
|
||
// C1 · 401 is ambiguous (Origin OR access token, same status), so the
|
||
// primary offered remedy is now the token prompt; retry stays on screen.
|
||
(PairingError.originRejected(hint: "在主机加 ALLOWED_ORIGINS=http://192.168.1.5:3000"),
|
||
PairingViewModel.RecoveryAction.enterAccessToken,
|
||
["ALLOWED_ORIGINS=http://192.168.1.5:3000"]),
|
||
(PairingError.atsBlocked(host: "198.18.0.1"),
|
||
PairingViewModel.RecoveryAction.retry,
|
||
["ATS", "198.18.0.1", "tailscale serve", "例外"]),
|
||
(PairingError.tlsFailure,
|
||
PairingViewModel.RecoveryAction.retry,
|
||
["TLS"]),
|
||
(PairingError.timeout,
|
||
PairingViewModel.RecoveryAction.retry,
|
||
["超时"]),
|
||
])
|
||
func pairingErrorMapsToCopyAndAction(
|
||
error: PairingError,
|
||
expectedAction: PairingViewModel.RecoveryAction,
|
||
requiredFragments: [String]
|
||
) async throws {
|
||
// Arrange: private-class host so no blocking-warning gate interferes.
|
||
let script = ProbeScript(results: [.failure(error)])
|
||
let viewModel = makeViewModel(script: script)
|
||
viewModel.handleScannedCode("http://192.168.1.5:3000")
|
||
|
||
// Act
|
||
await viewModel.confirmConnect()
|
||
|
||
// Assert
|
||
guard case .failed(_, let failure) = viewModel.phase else {
|
||
Issue.record("expected .failed for \(error), got \(viewModel.phase)")
|
||
return
|
||
}
|
||
#expect(failure.action == expectedAction)
|
||
#expect(!failure.message.isEmpty)
|
||
for fragment in requiredFragments {
|
||
#expect(failure.message.contains(fragment),
|
||
"copy for \(error) must contain \(fragment)")
|
||
}
|
||
}
|
||
|
||
@Test("originRejected surfaces the probe's hint VERBATIM as the whole message")
|
||
func originRejectedHintIsVerbatim() async throws {
|
||
// Arrange: the hint the probe derives from endpoint.originHeader is
|
||
// already the complete actionable copy — never rewrap or re-derive it.
|
||
let hint = "服务器拒绝了这个来源。请在主机上设置 ALLOWED_ORIGINS=http://192.168.1.5:3000"
|
||
+ "(与 App 连接的 URL 完全一致)后重启 web-terminal,再重试配对。"
|
||
let script = ProbeScript(results: [.failure(.originRejected(hint: hint))])
|
||
let viewModel = makeViewModel(script: script)
|
||
viewModel.handleScannedCode("http://192.168.1.5:3000")
|
||
|
||
// Act
|
||
await viewModel.confirmConnect()
|
||
|
||
// Assert
|
||
guard case .failed(_, let failure) = viewModel.phase else {
|
||
Issue.record("expected .failed, got \(viewModel.phase)")
|
||
return
|
||
}
|
||
#expect(failure.message == hint)
|
||
}
|
||
|
||
// MARK: - Retry / cancel
|
||
|
||
@Test("retry re-runs the probe against the same endpoint and can succeed")
|
||
func retryRerunsProbeAfterFailure() async throws {
|
||
// Arrange: first probe times out, second succeeds.
|
||
let script = ProbeScript(results: [.failure(.timeout)])
|
||
let store = InMemoryHostStore()
|
||
let viewModel = makeViewModel(store: store, script: script)
|
||
viewModel.handleScannedCode("http://192.168.1.5:3000")
|
||
await viewModel.confirmConnect()
|
||
guard case .failed = viewModel.phase else {
|
||
Issue.record("expected .failed, got \(viewModel.phase)")
|
||
return
|
||
}
|
||
|
||
// Act
|
||
await viewModel.retry()
|
||
|
||
// Assert: two probe calls, same endpoint, pairing completed.
|
||
let calls = await script.calls
|
||
#expect(calls.count == 2)
|
||
#expect(calls.first == calls.last)
|
||
guard case .paired(let host) = viewModel.phase else {
|
||
Issue.record("expected .paired, got \(viewModel.phase)")
|
||
return
|
||
}
|
||
#expect(try await store.loadAll() == [host])
|
||
}
|
||
|
||
@Test("cancel returns to idle without ever probing")
|
||
func cancelReturnsToIdleWithoutProbe() async throws {
|
||
// Arrange
|
||
let script = ProbeScript()
|
||
let viewModel = makeViewModel(script: script)
|
||
viewModel.handleScannedCode("http://192.168.1.5:3000")
|
||
|
||
// Act
|
||
viewModel.cancel()
|
||
|
||
// Assert
|
||
#expect(viewModel.phase == .idle)
|
||
#expect(viewModel.inputRejection == nil)
|
||
#expect(await script.calls.isEmpty)
|
||
}
|
||
|
||
// MARK: - Store failure is explicit, never silent
|
||
|
||
@Test("a store failure after a successful probe surfaces an explicit retryable error")
|
||
func storeFailureSurfacesExplicitError() async throws {
|
||
// Arrange
|
||
let script = ProbeScript()
|
||
let viewModel = makeViewModel(store: ThrowingHostStore(), script: script)
|
||
viewModel.handleScannedCode("http://192.168.1.5:3000")
|
||
|
||
// Act
|
||
await viewModel.confirmConnect()
|
||
|
||
// Assert: failed with the dedicated copy; no navigate signal.
|
||
guard case .failed(_, let failure) = viewModel.phase else {
|
||
Issue.record("expected .failed, got \(viewModel.phase)")
|
||
return
|
||
}
|
||
#expect(failure.message == PairingCopy.storeFailed)
|
||
#expect(failure.action == .retry)
|
||
#expect(viewModel.pairedHost == nil)
|
||
}
|
||
|
||
// MARK: - Host naming
|
||
|
||
@Test("host name defaults to the endpoint host and user names are trimmed")
|
||
func hostNameDefaultsAndTrims() async throws {
|
||
// Arrange & Act: untouched name → default = endpoint host.
|
||
let script = ProbeScript()
|
||
let storeA = InMemoryHostStore()
|
||
let viewModelA = makeViewModel(store: storeA, script: script)
|
||
viewModelA.handleScannedCode("http://192.168.1.5:3000")
|
||
#expect(viewModelA.hostName == "192.168.1.5")
|
||
await viewModelA.confirmConnect()
|
||
|
||
// Assert
|
||
guard case .paired(let defaultNamed) = viewModelA.phase else {
|
||
Issue.record("expected .paired, got \(viewModelA.phase)")
|
||
return
|
||
}
|
||
#expect(defaultNamed.name == "192.168.1.5")
|
||
|
||
// Arrange & Act: user-typed name is trimmed before storing.
|
||
let storeB = InMemoryHostStore()
|
||
let viewModelB = makeViewModel(store: storeB, script: script)
|
||
viewModelB.handleScannedCode("http://192.168.1.5:3000")
|
||
viewModelB.hostName = " 书房 Mac "
|
||
await viewModelB.confirmConnect()
|
||
|
||
// Assert
|
||
guard case .paired(let userNamed) = viewModelB.phase else {
|
||
Issue.record("expected .paired, got \(viewModelB.phase)")
|
||
return
|
||
}
|
||
#expect(userNamed.name == "书房 Mac")
|
||
}
|
||
}
|