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] /// Empty `results` = always succeed with the probed endpoint. init(results: [Result] = []) { self.results = results } func invoke(_ endpoint: HostEndpoint) -> Result { 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") } }