T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM) T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field) T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed) T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites) Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
434 lines
18 KiB
Swift
434 lines
18 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 {
|
||
PairingViewModel(store: store, probe: { await script.invoke($0) })
|
||
}
|
||
|
||
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: { await runPairingProbe(endpoint: $0, http: http, ws: ws) }
|
||
)
|
||
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,
|
||
["端口"]),
|
||
(PairingError.originRejected(hint: "在主机加 ALLOWED_ORIGINS=http://192.168.1.5:3000"),
|
||
PairingViewModel.RecoveryAction.retry,
|
||
["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")
|
||
}
|
||
}
|