diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index bb27e7601c5c..70152f3fc4b1 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -233,9 +233,14 @@ jobs: XCODEBUILD_ARGS+=(test) selected_tests_passed_despite_xcodebuild_status() { local log_path="$1" + # The negative grep must catch BOTH XCTest failure output and Swift + # Testing failure output (✘ markers): the final "Failing tests:" + # section is not always flushed into the tee'd log, and without the + # ✘ patterns a real Swift Testing failure exiting 65 was + # misclassified as a runner cleanup failure and turned the job green. grep -Eq "Test Suite 'Selected tests' passed|Test Suite 'cmuxUITests' passed" "$log_path" && grep -Eq "Executed [1-9][0-9]* tests, with 0 failures \\(0 unexpected\\)" "$log_path" && - ! grep -Eq "Test Suite '.*' failed|Test Case '.*' failed|Assertion Failure|Failing tests:|with [1-9][0-9]* failures|with [0-9]+ failures \\([1-9][0-9]* unexpected\\)" "$log_path" + ! grep -Eq "Test Suite '.*' failed|Test Case '.*' failed|Assertion Failure|Failing tests:|with [1-9][0-9]* failures|with [0-9]+ failures \\([1-9][0-9]* unexpected\\)|✘ Test|✘ Suite" "$log_path" } for attempt in 1 2; do LOG_PATH="/tmp/cmux-ios-${{ matrix.family }}-attempt-${attempt}.log" diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index f577a8654425..b69bc7b1ef34 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -528,11 +528,11 @@ final class TerminalOutputCollector { } @MainActor -@Test func manualHostPairingUsesNetworkRouteForPrivateLANIPWithStackAuth() async throws { - let responses = ScriptedTransportResponses([ - try rpcErrorFrame(code: "method_not_found", message: "unknown method"), - try rpcWorkspaceListFrame(workspaceID: "lan-workspace", title: "LAN Workspace"), - ]) +@Test func manualHostPairingRejectsPrivateLANIPWithoutSendingStackToken() async throws { + // Plain private-LAN routes are dialed over unencrypted TCP, so + // routeAllowsStackAuth excludes them: pairing must fail before any RPC + // (and the Stack bearer token) leaves the device. + let responses = ScriptedTransportResponses([]) let runtime = testRuntime( supportedRouteKinds: [.tailscale], transportFactory: ScriptedTransportFactory(responses: responses), @@ -543,30 +543,20 @@ final class TerminalOutputCollector { store.signIn() await store.connectManualHost(name: "Studio LAN", host: " 192.168.1.77 ", port: 15432) - let route = try #require(store.activeRoute) - #expect(store.phase == .workspaces) - #expect(store.connectionState == .connected) - #expect(store.connectionError == nil) - #expect(store.connectedHostName == "Studio LAN") - if case let .hostPort(host, port) = route.endpoint { - #expect(host == "192.168.1.77") - #expect(port == 15432) - } else { - Issue.record("manual LAN route should use host/port") - } - #expect(route.kind == .tailscale) - let requests = try await responses.sentRequests() - #expect(requests.map(\.method) == ["mobile.attach_ticket.create", "workspace.list"]) - #expect(requests.allSatisfy { $0.stackAccessToken == "stack-token-for-lan" }) - #expect(requests.allSatisfy { $0.attachToken == nil }) + #expect(store.phase == .pairing) + #expect(store.connectionState == .disconnected) + #expect(store.activeTicket == nil) + #expect(store.activeRoute == nil) + #expect(store.connectionError == "This pairing route is not allowed. Enter a host and port, or pair with a QR/link from that computer.") + #expect(try await responses.sentRequests().isEmpty) } @MainActor -@Test func manualHostPairingUsesNetworkRouteForLocalDNSNameWithStackAuth() async throws { - let responses = ScriptedTransportResponses([ - try rpcErrorFrame(code: "method_not_found", message: "unknown method"), - try rpcWorkspaceListFrame(workspaceID: "local-dns-workspace", title: "Local DNS Workspace"), - ]) +@Test func manualHostPairingRejectsLocalDNSNameWithoutSendingStackToken() async throws { + // `.local`/Bonjour hosts are dialed over unencrypted TCP, so + // routeAllowsStackAuth excludes them: pairing must fail before any RPC + // (and the Stack bearer token) leaves the device. + let responses = ScriptedTransportResponses([]) let runtime = testRuntime( supportedRouteKinds: [.tailscale], transportFactory: ScriptedTransportFactory(responses: responses), @@ -577,26 +567,19 @@ final class TerminalOutputCollector { store.signIn() await store.connectManualHost(name: "", host: "devbox.local", port: 61234) - let route = try #require(store.activeRoute) - #expect(store.phase == .workspaces) - #expect(store.connectionState == .connected) - #expect(store.connectionError == nil) - #expect(store.connectedHostName == "devbox.local") - if case let .hostPort(host, port) = route.endpoint { - #expect(host == "devbox.local") - #expect(port == 61234) - } else { - Issue.record("manual local DNS route should use host/port") - } - #expect(route.kind == .tailscale) - let requests = try await responses.sentRequests() - #expect(requests.map(\.method) == ["mobile.attach_ticket.create", "workspace.list"]) - #expect(requests.allSatisfy { $0.stackAccessToken == "stack-token-for-local-dns" }) - #expect(requests.allSatisfy { $0.attachToken == nil }) + #expect(store.phase == .pairing) + #expect(store.connectionState == .disconnected) + #expect(store.activeTicket == nil) + #expect(store.activeRoute == nil) + #expect(store.connectionError == "This pairing route is not allowed. Enter a host and port, or pair with a QR/link from that computer.") + #expect(try await responses.sentRequests().isEmpty) } @MainActor -@Test func manualHostPairingProbesLANHostForAttachTicketBeforeStackAuthFallback() async throws { +@Test func manualHostPairingProbesTailscaleHostForAttachTicketBeforeStackAuthFallback() async throws { + // A trusted (Tailscale) manual host is probed for a real attach ticket + // first; an older Mac that does not implement the probe method falls back + // to a synthetic ticket and Stack-authenticated workspace.list. let responses = ScriptedTransportResponses([ try rpcErrorFrame(code: "method_not_found", message: "unknown method"), try rpcWorkspaceListFrame(workspaceID: "manual-workspace", title: "Manual Workspace"), @@ -609,12 +592,12 @@ final class TerminalOutputCollector { let store = CMUXMobileShellStore.preview(runtime: runtime) store.signIn() - await store.connectManualHost(name: "Studio LAN", host: "192.168.1.77", port: 15432) + await store.connectManualHost(name: "Work Mac", host: "100.71.210.41", port: 15432) #expect(store.phase == .workspaces) #expect(store.connectionState == .connected) #expect(store.connectionError == nil) - #expect(store.connectedHostName == "Studio LAN") + #expect(store.connectedHostName == "Work Mac") let requests = try await responses.sentRequests() #expect(requests.map(\.method) == ["mobile.attach_ticket.create", "workspace.list"]) #expect(requests.allSatisfy { $0.stackAccessToken == "stack-token-for-fallback" }) @@ -1375,11 +1358,11 @@ final class TerminalOutputCollector { } @MainActor -@Test func manualHostPairingUsesNetworkRouteForDefaultPortLANHostWithStackAuth() async throws { - let responses = ScriptedTransportResponses([ - try rpcErrorFrame(code: "method_not_found", message: "unknown method"), - try rpcWorkspaceListFrame(workspaceID: "default-port-lan-workspace", title: "Default Port LAN Workspace"), - ]) +@Test func manualHostPairingRejectsDefaultPortLANHostWithoutSendingStackToken() async throws { + // Same encrypted-routes-only contract as the explicit-port LAN test, on + // the default host port: no RPC (and no Stack bearer token) may leave the + // device for a plain-TCP private-LAN route. + let responses = ScriptedTransportResponses([]) let runtime = testRuntime( supportedRouteKinds: [.tailscale], transportFactory: ScriptedTransportFactory(responses: responses), @@ -1390,16 +1373,12 @@ final class TerminalOutputCollector { store.signIn() await store.connectManualHost(name: "Work Mac", host: "192.168.1.77", port: CmxMobileDefaults.defaultHostPort) - #expect(store.phase == .workspaces) - #expect(store.connectionState == .connected) - #expect(store.connectionError == nil) - #expect(store.connectedHostName == "Work Mac") - let route = try #require(store.activeRoute) - #expect(route.kind == .tailscale) - let requests = try await responses.sentRequests() - #expect(requests.map(\.method) == ["mobile.attach_ticket.create", "workspace.list"]) - #expect(requests.allSatisfy { $0.stackAccessToken == "stack-token-for-default-lan" }) - #expect(requests.allSatisfy { $0.attachToken == nil }) + #expect(store.phase == .pairing) + #expect(store.connectionState == .disconnected) + #expect(store.activeTicket == nil) + #expect(store.activeRoute == nil) + #expect(store.connectionError == "This pairing route is not allowed. Enter a host and port, or pair with a QR/link from that computer.") + #expect(try await responses.sentRequests().isEmpty) } @MainActor