Files
openclaw/apps/ios/Tests/RootTabsSourceGuardTests+GatewaySupport.swift
Peter Steinberger 938d41014f feat(mobile): default pairing to full node access (#105928)
* feat(mobile): default pairing to full node access

* chore: leave release notes to release workflow

* refactor(mobile): bound pairing helpers

* chore(i18n): refresh mobile access inventory

* chore(ci): lower mobile pairing LOC baseline

* fix(mobile): complete pairing validation artifacts

* ci: remove stale transcript target export baseline

* fix(ios): wrap access upgrade guidance

* fix(ios): preserve localized access key

* fix(ci): dedupe read-only SQLite guardrail

* fix(mobile): secure full-access node setup
2026-07-13 02:30:45 -07:00

327 lines
22 KiB
Swift

import Foundation
import Testing
extension RootTabsSourceGuardTests {
static func assertGatewaySettingsSurfaceGuards() throws {
let settingsSource = try String(contentsOf: self.settingsProTabSourceURL(), encoding: .utf8)
let sectionsSource = try String(contentsOf: self.settingsProTabSectionsSourceURL(), encoding: .utf8)
let actionsSource = try String(contentsOf: self.settingsProTabActionsSourceURL(), encoding: .utf8)
let trustSource = try String(contentsOf: self.gatewayTrustPromptAlertSourceURL(), encoding: .utf8)
let controllerSource = try self.gatewayConnectionControllerSource()
let rootSource = try String(contentsOf: self.rootTabsSourceURL(), encoding: .utf8)
let scannerSource = try String(contentsOf: self.qrScannerSourceURL(), encoding: .utf8)
let settingsScannerSheet = try self.extract(
settingsSource,
from: "isPresented: self.$showQRScanner,",
to: ".sheet(isPresented: self.$showNotificationRelayDisclosure)")
let settingsOnDismiss = try #require(settingsScannerSheet.range(of: "onDismiss: {"))
let settingsProcessing = try #require(settingsScannerSheet.range(of: "self.processQueuedScannerResult()"))
let settingsContent = try #require(settingsScannerSheet.range(of: "content: {"))
let settingsPendingSetupHandler = try self.extract(
actionsSource,
from: "func applyGatewaySetupLink(_ link: GatewayConnectDeepLink)",
to: "@discardableResult\n func applySetupCode(attemptID: UUID)")
let settingsScannerCancel = try #require(
settingsPendingSetupHandler.range(of: "self.scannerResultHandoff.cancel()"))
let settingsSetupStaging = try #require(
settingsPendingSetupHandler.range(of: "self.stagedGatewaySetupLink = link"))
let scannerMake = try self.extract(
scannerSource,
from: "func makeUIViewController",
to: "func updateUIViewController")
let scannerLifecycle = try self.extract(
scannerSource,
from: "final class QRScannerContainerViewController",
to: "final class Coordinator")
let scannerDelivery = try self.extract(
scannerSource,
from: "private func deliver(_ result: QRScannerResult",
to: "func dataScanner(_: DataScannerViewController, didRemove")
let stopScanning = try #require(scannerDelivery.range(of: "scanner.stopScanning()"))
let deliverResult = try #require(scannerDelivery.range(of: "self.parent.onResult(result)"))
let activeProblemToast = try self.extract(
rootSource,
from: "private var activeGatewayProblemToast: GatewayConnectionProblem?",
to: "private var gatewayToastAnimation: Animation?")
let gatewaySetupSource = try self.extract(
rootSource,
from: "private func maybeOpenSettingsForGatewaySetup()",
to: "private func maybeRequestLocalNetworkAccess")
let consumedGatewaySetup = try #require(
gatewaySetupSource.range(of: "appModel.consumePendingGatewaySetupLink()"))
let onboardingSetupOwnerGuard = try #require(
gatewaySetupSource.range(of: "guard !self.showOnboarding else { return }"))
let deliveredGatewaySetup = try #require(
gatewaySetupSource.range(of: "self.gatewaySetupRequest = GatewaySetupRequest"))
#expect(scannerSource.contains("static let defaultSettlingNanoseconds: UInt64 = 1_200_000_000"))
#expect(scannerSource.contains("QRScannerContainerViewController(coordinator: context.coordinator)"))
#expect(!scannerMake.contains("startScanning()"))
#expect(scannerLifecycle.contains("override func viewDidAppear"))
#expect(scannerLifecycle.contains("try self.scanner.startScanning()"))
#expect(scannerLifecycle.contains("override func viewWillDisappear"))
#expect(scannerLifecycle.contains("self.stopScannerCapture()"))
#expect(sectionsSource.contains("var gatewayDestination: some View"))
#expect(sectionsSource.contains("This phone has limited Gateway access."))
#expect(sectionsSource.contains("Use a secure wss:// or Tailscale Serve Gateway"))
#expect(sectionsSource.contains("Label(\"Scan Full-Access Code\""))
#expect(sectionsSource.contains("self.gatewayActions"))
#expect(sectionsSource.contains("self.manualGatewayCard"))
#expect(sectionsSource.contains("self.gatewaySetupCard"))
#expect(sectionsSource.contains("self.gatewayAdvancedCard"))
// Pairing stays reachable without scrolling: nav-bar scanner button on the
// gateway route plus a status-card hero while nothing is paired. The hero
// honors the same connect lock as the other scanner entry points.
#expect(sectionsSource.contains("if route == .gateway {"))
#expect(sectionsSource.contains(
"let showScanHero = self.gatewayNeedsPairing && self.connectingGateway == nil"))
#expect(sectionsSource.contains("actionTitle: showScanHero ? \"Scan QR to Pair\" : nil"))
#expect(sectionsSource.contains("title: \"Reconnect\""))
#expect(sectionsSource.contains("Task { await self.reconnectGateway() }"))
#expect(sectionsSource.contains("title: \"Diagnose\""))
#expect(sectionsSource.contains("Task { await self.runDiagnostics() }"))
#expect(sectionsSource.contains("title: \"Scan QR\""))
#expect(sectionsSource.contains("self.openGatewayQRScanner()"))
#expect(sectionsSource.contains("title: \"Connect\""))
#expect(sectionsSource.contains("Task { await self.applySetupCodeAndConnect() }"))
#expect(sectionsSource.contains("Task { await self.connect(gateway) }"))
#expect(sectionsSource.contains("tailnetWarningText"))
// Gateway problems surface once, as the root toast; the settings page must not
// embed a second copy of the banner.
#expect(!sectionsSource.contains("GatewayProblemBanner("))
// Sections compare gateway owners byte-exact, not with raw string equality.
#expect(!sectionsSource.contains("entry.stableID == self.gatewayRegistry.activeStableID"))
#expect(sectionsSource.components(separatedBy: "GatewayStableIdentifier.matches(").count >= 4)
#expect(rootSource.contains("GatewayProblemBanner("))
#expect(rootSource.contains(".gesture(self.gatewayToastSwipeGesture)"))
// Operator auth/pairing problems can coexist with a connected node, so the
// root's only remediation surface must not depend on aggregate status.
#expect(activeProblemToast.contains("appModel.lastGatewayProblem"))
#expect(!activeProblemToast.contains("gatewayStatus"))
// Every problem report re-surfaces a swiped-away toast or shakes the
// visible one; value equality alone must not keep the toast hidden.
#expect(rootSource.contains("self.appModel.gatewayProblemReportCount"))
#expect(rootSource.contains("GatewayToastShakeEffect"))
#expect(actionsSource.contains("await self.gatewayController.connectActiveGateway()"))
#expect(actionsSource.contains("self.gatewayController.refreshActiveGatewayRegistrationFromSettings()"))
#expect(actionsSource.contains("self.gatewayController.restartDiscovery()"))
#expect(actionsSource.contains("await self.appModel.refreshGatewayOverviewIfConnected()"))
#expect(actionsSource
.contains("self.gatewayController.requestLocalNetworkAccess(reason: \"settings_preflight\")"))
#expect(controllerSource.contains("await self.tcpReachabilityProbe("))
#expect(controllerSource.contains("Check Tailscale or LAN."))
#expect(actionsSource.contains("Tailscale is off on this device. Turn it on, then try again."))
#expect(actionsSource.contains("Run /pair approve in your OpenClaw chat"))
#expect(settingsSource.contains("self.resetOnboarding()"))
#expect(settingsSource.contains(".onChange(of: self.onboardingRequestID)"))
#expect(settingsSource.contains("self.syncAfterOnboardingReset()"))
#expect(settingsSource.contains("let acceptsGatewaySetupRequests: Bool"))
#expect(settingsSource.contains("guard self.acceptsGatewaySetupRequests else { return }"))
#expect(settingsSource.contains(".onChange(of: self.acceptsGatewaySetupRequests)"))
#expect(rootSource.matches(of: /acceptsGatewaySetupRequests: !self\.showOnboarding/).count == 2)
#expect(actionsSource.contains("func syncAfterOnboardingReset()"))
#expect(actionsSource.contains("self.pendingManualAuthOverride = nil"))
// The root toast is the only gateway problem surface outside covers, so it
// must keep the reset-onboarding primary action the settings banner had.
#expect(rootSource.contains("resetTitle: \"Reset onboarding\""))
#expect(rootSource.contains("GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: instanceId)"))
#expect(rootSource.contains("self.gatewayController.trustRotatedGatewayCertificate(from: problem)"))
#expect(rootSource.contains("GatewayProblemPrimaryAction.handleProtocolMismatchIfNeeded(problem)"))
#expect(rootSource.contains("await self.gatewayController.connectActiveGateway()"))
#expect(rootSource.contains("GatewayProblemDetailsSheet("))
#expect(onboardingSetupOwnerGuard.lowerBound < consumedGatewaySetup.lowerBound)
#expect(consumedGatewaySetup.lowerBound < deliveredGatewaySetup.lowerBound)
#expect(settingsSource.contains("QRScannerView("))
#expect(settingsOnDismiss.lowerBound < settingsProcessing.lowerBound)
#expect(settingsProcessing.lowerBound < settingsContent.lowerBound)
#expect(settingsPendingSetupHandler.contains("self.showQRScanner = false"))
#expect(settingsScannerCancel.lowerBound < settingsSetupStaging.lowerBound)
#expect(settingsPendingSetupHandler.contains(
"self.gatewayController.cancelPendingConnectionAttempts()"))
#expect(!settingsSource.contains(".onChange(of: self.showQRScanner)"))
#expect(actionsSource.contains("case let .gatewayLink(link):"))
#expect(actionsSource.contains("case let .setupCode(code):"))
#expect(stopScanning.lowerBound < deliverResult.lowerBound)
#expect(trustSource.contains("Trust this gateway?"))
#expect(trustSource.contains("Trust and connect"))
#expect(trustSource.contains("let isEnabled: Bool"))
#expect(rootSource.contains(".gatewayTrustPromptAlert(isEnabled: !self.showOnboarding)"))
}
static func assertGatewayOnboardingFlowGuards() throws {
let onboardingSource = try self.onboardingWizardSource()
let actionsSource = try String(contentsOf: self.settingsProTabActionsSourceURL(), encoding: .utf8)
let controllerSource = try self.gatewayConnectionControllerSource()
let pendingSetupHandler = try self.extract(
onboardingSource,
from: "private func applyPendingGatewaySetupLinkIfNeeded()",
to: "private func connectStagedGatewaySetupLink()")
let stagedSetupConnect = try self.extract(
onboardingSource,
from: "private func connectStagedGatewaySetupLink()",
to: "private func clearStagedGatewaySetupLink()")
let stagedSetupClear = try self.extract(
onboardingSource,
from: "private func clearStagedGatewaySetupLink()",
to: "private func applyGatewayLink(")
let connectionFailure = try self.extract(
onboardingSource,
from: "private func setConnectionFailure(_ message: String)",
to: "var body: some View")
let stagedValidation = try #require(stagedSetupConnect.range(of: "guard link.isValidEndpoint"))
let stagedConsumption = try #require(stagedSetupConnect.range(of: "self.setupLinkStaging.take()"))
let stagedReset = try #require(
stagedSetupConnect.range(of: "await self.appModel.resetGatewaySessionsForTargetSwitch()"))
let onboardingGatewayLink = try self.extract(
onboardingSource,
from: "private func applyGatewayLink(",
to: "private func handleScannedSetupCode(")
let settingsGatewayLink = try self.extract(
actionsSource,
from: "func applyGatewayLink(",
to: "func openGatewayQRScanner()")
let onboardingManualConnect = try self.extract(
onboardingSource,
from: "private func connectCurrentManualGateway(",
to: "private func retryLastAttempt(")
let onboardingRetry = try self.extract(
onboardingSource,
from: "private func retryLastAttempt(",
to: "private func gatewayProblemPrimaryActionTitle(")
let settingsManualConnect = try self.extract(
actionsSource,
from: "func connectManual(setupAttemptID: UUID? = nil) async",
to: "func preflightGateway(host: String)")
#expect(onboardingSource.contains(".gatewayTrustPromptAlert()"))
#expect(onboardingSource.contains("self.applyPendingGatewaySetupLinkIfNeeded()"))
#expect(onboardingSource.contains(".onChange(of: self.appModel.gatewaySetupRequestID)"))
#expect(onboardingSource.contains("self.appModel.consumePendingGatewaySetupLink()"))
#expect(onboardingSource.contains("self.scannerResultHandoff.cancel()"))
#expect(!onboardingSource.contains("pendingScannerResult"))
#expect(onboardingSource.contains("self.setupLinkStaging.stage(link)"))
#expect(pendingSetupHandler.contains("self.gatewayController.cancelPendingConnectionAttempts()"))
#expect(pendingSetupHandler.contains("if self.selectedMode == nil"))
#expect(onboardingSource.contains("Tap Connect to apply."))
#expect(onboardingSource.contains("self.connectStagedGatewaySetupLink()"))
#expect(onboardingSource.contains("Credentials are applied only after you tap Connect."))
#expect(onboardingSource.contains("Plaintext (local network)"))
#expect(onboardingSource.contains("self.statusLine = message"))
#expect(!pendingSetupHandler.contains("self.manualHost ="))
#expect(!pendingSetupHandler.contains("self.manualPort ="))
#expect(!pendingSetupHandler.contains("self.manualTLS ="))
#expect(!pendingSetupHandler.contains("self.applyGatewayLink(link)"))
#expect(!pendingSetupHandler.contains("self.handleScannedLink(link)"))
#expect(!pendingSetupHandler.contains("self.connectManual()"))
#expect(stagedValidation.lowerBound < stagedConsumption.lowerBound)
#expect(stagedReset.lowerBound < stagedConsumption.lowerBound)
#expect(!stagedSetupConnect.contains("self.appModel.disconnectGateway()"))
#expect(stagedSetupConnect.contains(
"self.applyGatewayLink(link, disconnectExistingGatewayForBootstrap: false)"))
#expect(stagedSetupConnect.contains("guard self.connectingGateway == nil else { return }"))
#expect(onboardingSource.contains("case gateway(GatewayStableIdentifier.Key)"))
#expect(onboardingSource.contains("self.connectingGateway = .gateway(gateway.id)"))
#expect(!onboardingSource.contains("connectingGatewayID"))
#expect(stagedSetupConnect.contains("self.setConnectionFailure(message)"))
#expect(connectionFailure.contains("self.localConnectionFailure = message"))
#expect(!connectionFailure.contains("self.connectMessage = message"))
#expect(connectionFailure.contains("self.statusLine = message"))
#expect(onboardingSource.contains(".failedStatus(message: message, allowsRetry: false)"))
#expect(onboardingSource.contains(
"primaryActionTitle: allowsRetry ? OpenClawTextValue.localized(\"Retry\") : nil"))
#expect(onboardingSource.contains("onPrimaryAction: allowsRetry ? self.onRetry : nil"))
#expect(stagedSetupClear.contains("self.localConnectionFailure = nil"))
#expect(onboardingRetry.contains("self.localConnectionFailure = nil"))
#expect(onboardingRetry.contains(
"self.setConnectionFailure(\"No connection to retry. Check the gateway host and port.\")"))
#expect(onboardingSource.contains("self.setupLinkStaging.link == nil else { return }"))
#expect(onboardingGatewayLink.contains("self.gatewayToken = setupAuth.token"))
#expect(onboardingGatewayLink.contains("self.gatewayPassword = setupAuth.password"))
#expect(settingsGatewayLink.contains("self.gatewayToken = setupAuth.token"))
#expect(settingsGatewayLink.contains("self.gatewayPassword = setupAuth.password"))
#expect(onboardingManualConnect.contains("nodeOptions.allowStoredDeviceAuth == true"))
#expect(onboardingManualConnect.contains("self.pendingManualAuthOverride = nil"))
#expect(onboardingManualConnect.contains("targetStableID: stableID"))
#expect(settingsManualConnect.contains("nodeOptions.allowStoredDeviceAuth == true"))
#expect(settingsManualConnect.contains("self.pendingManualAuthOverride = nil"))
#expect(settingsManualConnect.contains("targetStableID: stableID"))
#expect(!controllerSource.contains("shouldApplyTokenField"))
#expect(!controllerSource.contains("shouldApplyPasswordField"))
#expect(controllerSource.contains("allowStoredDeviceAuth: !suppressStoredDeviceAuth"))
#expect(controllerSource.contains(
"deviceAuthGatewayID: GatewaySettingsStore.authenticationOwnerID("))
#expect(controllerSource.contains("DeviceAuthStore.migrateUnscopedToken("))
#expect(controllerSource.contains("DeviceAuthStore.discardUnscopedTokens("))
#expect(onboardingSource.contains(
"self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false)"))
#expect(actionsSource.contains(
"self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false)"))
#expect(onboardingSource.contains(
"self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID"))
#expect(actionsSource.contains(
"self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID"))
// Gateway stable IDs compare byte-exact via GatewayStableIdentifier, never
// via trimmed/string equality; a regressed comparison silently reuses
// credentials across distinct gateway owners.
#expect(onboardingSource.contains(
"if !GatewayStableIdentifier.matches(self.gatewayCredentialFieldStableID, stableID)"))
#expect(actionsSource.contains(
"if !GatewayStableIdentifier.matches(self.gatewayCredentialFieldStableID, stableID)"))
#expect(!onboardingSource.contains("gatewayCredentialFieldStableID == stableID"))
#expect(!actionsSource.contains("gatewayCredentialFieldStableID == stableID"))
#expect(onboardingSource.contains("GatewayStableIdentifier.key(previousStableID) !="))
#expect(actionsSource.contains("GatewayStableIdentifier.key(previousStableID) !="))
}
static func assertGatewayReconnectGuards() throws {
let modelSource = try String(contentsOf: self.nodeAppModelSourceURL(), encoding: .utf8)
let controllerSource = try self.gatewayConnectionControllerSource()
let backgroundReconnect = try self.extract(
modelSource,
from: "private func performBackgroundAliveBeaconIfNeeded(",
to: "private func publishBackgroundAliveBeacon(")
let disconnectGateway = try self.extract(
modelSource,
from: "func disconnectGateway()",
to: "private func disableGatewayAutoReconnect()")
let operatorGatewayLoop = try self.extract(
modelSource,
from: "private func startOperatorGatewayLoop(",
to: "private func startNodeGatewayLoop(")
let nodeGatewayLoop = try self.extract(
modelSource,
from: "private func startNodeGatewayLoop(",
to: "private func makeOperatorConnectOptions(")
let wakeWordRefresh = try self.extract(
modelSource,
from: "private func refreshWakeWordsFromGateway(",
to: "private func isGatewayHealthMonitorDisabled()")
#expect(disconnectGateway.contains("self.beginGatewaySessionReset(chainingAfterExisting: true)"))
#expect(!disconnectGateway.contains("Task {"))
#expect(modelSource.contains(
"private func isCurrentGatewayRoute(generation: UInt64, stableID: String) -> Bool"))
#expect(modelSource.matches(
of: /self\.isCurrentGatewayRoute\(generation: routeGeneration, stableID: stableID\)/).count >= 2)
#expect(operatorGatewayLoop.contains("gatewayReconnectLoopDelay(source: \"operator_loop\")"))
#expect(nodeGatewayLoop.contains("gatewayReconnectLoopDelay(source: \"node_loop\")"))
#expect(modelSource.contains("refreshWakeWordsFromGateway(shouldApply: shouldContinue)"))
#expect(wakeWordRefresh.matches(of: /guard shouldApply\(\) else \{ return \}/).count >= 2)
#expect(modelSource.contains("if !self.gatewayAutoReconnectEnabled || self.gatewayPairingPaused"))
#expect(controllerSource.contains("acceptPendingTrustPrompt()"))
#expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)"))
#expect(controllerSource.contains("allowAutoReconnect: false"))
#expect(controllerSource.contains("guard allowAutoReconnect else { return }"))
#expect(controllerSource.contains("guard self.autoConnectSuppressionGeneration == nil else { return }"))
#expect(backgroundReconnect.contains("let generation = self.gatewayConnectGeneration"))
#expect(backgroundReconnect.contains("await self.resetGatewaySessionsForForcedReconnect()"))
#expect(backgroundReconnect.contains("expectedGeneration: generation"))
#expect(modelSource.contains("expectedGeneration: UInt64)"))
#expect(!modelSource.contains("expectedGeneration: UInt64?"))
#expect(modelSource.contains("GatewayStableIdentifier.exact(self.connectedGatewayID)"))
}
}