mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-08 10:02:56 +08:00
* feat(ios): multi-gateway registry, per-gateway credentials, and switching * fix(ios): harden multi-gateway state isolation
2422 lines
106 KiB
Swift
2422 lines
106 KiB
Swift
import Foundation
|
|
import OpenClawChatUI
|
|
import Testing
|
|
import UIKit
|
|
@testable import OpenClaw
|
|
@testable import OpenClawKit
|
|
|
|
@discardableResult
|
|
private func saveActiveManualGateway(
|
|
host: String,
|
|
port: Int,
|
|
useTLS: Bool,
|
|
stableID: String) -> Bool
|
|
{
|
|
let entry = GatewaySettingsStore.GatewayRegistryEntry(
|
|
stableID: stableID,
|
|
kind: .manual,
|
|
name: "\(host):\(port)",
|
|
host: host,
|
|
port: port,
|
|
useTLS: useTLS,
|
|
lastConnectedAtMs: nil)
|
|
return GatewaySettingsStore.upsertGatewayRegistryEntry(entry, activate: true)
|
|
}
|
|
|
|
private struct GatewayRegistryTestIsolation {
|
|
private static let service = GatewaySettingsStore._testGatewayService
|
|
private static let registryAccount = "gateway-registry"
|
|
private static let legacyAccount = "lastConnection"
|
|
private static let legacyDefaultsKeys = [
|
|
"gateway.last.kind",
|
|
"gateway.last.host",
|
|
"gateway.last.port",
|
|
"gateway.last.tls",
|
|
"gateway.last.stableID",
|
|
]
|
|
|
|
private let previousRegistry: String?
|
|
private let previousLegacyConnection: String?
|
|
private let previousDefaults: [String: Any?]
|
|
|
|
init() {
|
|
gatewayPersistenceTestSemaphore.wait()
|
|
self.previousRegistry = KeychainStore.loadString(
|
|
service: Self.service,
|
|
account: Self.registryAccount)
|
|
self.previousLegacyConnection = KeychainStore.loadString(
|
|
service: Self.service,
|
|
account: Self.legacyAccount)
|
|
self.previousDefaults = Dictionary(uniqueKeysWithValues: Self.legacyDefaultsKeys.map { key in
|
|
(key, UserDefaults.standard.object(forKey: key))
|
|
})
|
|
_ = KeychainStore.delete(service: Self.service, account: Self.registryAccount)
|
|
_ = KeychainStore.delete(service: Self.service, account: Self.legacyAccount)
|
|
for key in Self.legacyDefaultsKeys {
|
|
UserDefaults.standard.removeObject(forKey: key)
|
|
}
|
|
}
|
|
|
|
func restore() {
|
|
if let previousRegistry {
|
|
_ = KeychainStore.saveString(
|
|
previousRegistry,
|
|
service: Self.service,
|
|
account: Self.registryAccount)
|
|
} else {
|
|
_ = KeychainStore.delete(service: Self.service, account: Self.registryAccount)
|
|
}
|
|
if let previousLegacyConnection {
|
|
_ = KeychainStore.saveString(
|
|
previousLegacyConnection,
|
|
service: Self.service,
|
|
account: Self.legacyAccount)
|
|
} else {
|
|
_ = KeychainStore.delete(service: Self.service, account: Self.legacyAccount)
|
|
}
|
|
for (key, value) in self.previousDefaults {
|
|
if let value {
|
|
UserDefaults.standard.set(value, forKey: key)
|
|
} else {
|
|
UserDefaults.standard.removeObject(forKey: key)
|
|
}
|
|
}
|
|
gatewayPersistenceTestSemaphore.signal()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) async {
|
|
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
|
|
while appModel.activeGatewayConnectConfig?.effectiveStableID != stableID,
|
|
ContinuousClock().now < deadline
|
|
{
|
|
await Task.yield()
|
|
}
|
|
}
|
|
|
|
@Suite(.serialized) struct GatewayConnectionControllerTests {
|
|
@Test @MainActor func `chat owner survives reconnect while session refresh identity changes`() {
|
|
let appModel = NodeAppModel()
|
|
let disconnectedOwner = appModel.chatViewModelOwnerID
|
|
let disconnectedRefreshIdentity = appModel.chatViewModelIdentityID
|
|
|
|
appModel._test_setOperatorConnected(true)
|
|
#expect(appModel.chatViewModelOwnerID == disconnectedOwner)
|
|
#expect(appModel.chatViewModelIdentityID != disconnectedRefreshIdentity)
|
|
|
|
appModel._test_setOperatorConnected(false)
|
|
#expect(appModel.chatViewModelOwnerID == disconnectedOwner)
|
|
#expect(appModel.chatViewModelIdentityID == disconnectedRefreshIdentity)
|
|
}
|
|
|
|
@Test func `direct push registration dedupe includes gateway identity`() {
|
|
#expect(!NodeAppModel.shouldPublishDirectAPNsRegistration(
|
|
token: "device-token",
|
|
gatewayStableID: "gateway-a",
|
|
lastToken: "device-token",
|
|
lastGatewayStableID: "gateway-a"))
|
|
#expect(NodeAppModel.shouldPublishDirectAPNsRegistration(
|
|
token: "device-token",
|
|
gatewayStableID: "gateway-b",
|
|
lastToken: "device-token",
|
|
lastGatewayStableID: "gateway-a"))
|
|
}
|
|
|
|
@Test @MainActor func `resolved display name sets default when missing`() {
|
|
let defaults = UserDefaults.standard
|
|
let displayKey = "node.displayName"
|
|
|
|
withUserDefaults([displayKey: nil, "node.instanceId": "ios-test"]) {
|
|
let appModel = NodeAppModel()
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
|
|
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
|
|
#expect(!resolved.isEmpty)
|
|
#expect(defaults.string(forKey: displayKey) == resolved)
|
|
}
|
|
}
|
|
|
|
@Test @MainActor func `current caps reflect toggles`() {
|
|
withUserDefaults([
|
|
"node.instanceId": "ios-test",
|
|
"node.displayName": "Test Node",
|
|
"camera.enabled": true,
|
|
"location.enabledMode": OpenClawLocationMode.always.rawValue,
|
|
VoiceWakePreferences.enabledKey: true,
|
|
]) {
|
|
let appModel = NodeAppModel()
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
let caps = Set(controller._test_currentCaps())
|
|
|
|
#expect(caps.contains(OpenClawCapability.canvas.rawValue))
|
|
#expect(caps.contains(OpenClawCapability.screen.rawValue))
|
|
#expect(caps.contains(OpenClawCapability.camera.rawValue))
|
|
#expect(caps.contains(OpenClawCapability.location.rawValue))
|
|
#expect(caps.contains(OpenClawCapability.voiceWake.rawValue))
|
|
#expect(caps.contains(OpenClawCapability.talk.rawValue))
|
|
}
|
|
}
|
|
|
|
@Test @MainActor func `current commands include location when enabled`() {
|
|
withUserDefaults([
|
|
"node.instanceId": "ios-test",
|
|
"location.enabledMode": OpenClawLocationMode.whileUsing.rawValue,
|
|
]) {
|
|
let appModel = NodeAppModel()
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
let commands = Set(controller._test_currentCommands())
|
|
|
|
#expect(commands.contains(OpenClawLocationCommand.get.rawValue))
|
|
}
|
|
}
|
|
|
|
@Test @MainActor func `location permission requires global services and app authorization`() {
|
|
#expect(GatewayConnectionController._test_isLocationAvailable(
|
|
servicesEnabled: true,
|
|
status: .authorizedWhenInUse))
|
|
#expect(GatewayConnectionController._test_isLocationAvailable(
|
|
servicesEnabled: true,
|
|
status: .authorizedAlways))
|
|
#expect(!GatewayConnectionController._test_isLocationAvailable(
|
|
servicesEnabled: false,
|
|
status: .authorizedAlways))
|
|
#expect(!GatewayConnectionController._test_isLocationAvailable(
|
|
servicesEnabled: true,
|
|
status: .denied))
|
|
}
|
|
|
|
@Test @MainActor func `current commands exclude dangerous system exec commands`() {
|
|
withUserDefaults([
|
|
"node.instanceId": "ios-test",
|
|
"camera.enabled": true,
|
|
"location.enabledMode": OpenClawLocationMode.whileUsing.rawValue,
|
|
]) {
|
|
let appModel = NodeAppModel()
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
let commands = Set(controller._test_currentCommands())
|
|
|
|
// iOS should expose notify, but not host shell/exec-approval commands.
|
|
#expect(commands.contains(OpenClawSystemCommand.notify.rawValue))
|
|
#expect(!commands.contains(OpenClawSystemCommand.run.rawValue))
|
|
#expect(!commands.contains(OpenClawSystemCommand.which.rawValue))
|
|
#expect(!commands.contains(OpenClawSystemCommand.execApprovalsGet.rawValue))
|
|
#expect(!commands.contains(OpenClawSystemCommand.execApprovalsSet.rawValue))
|
|
}
|
|
}
|
|
|
|
@Test @MainActor func `operator connect options only request approval scope when enabled`() {
|
|
let appModel = NodeAppModel()
|
|
let withoutApprovalScope = appModel._test_makeOperatorConnectOptions(
|
|
clientId: "openclaw-ios",
|
|
displayName: "OpenClaw iOS",
|
|
includeApprovalScope: false)
|
|
let withApprovalScope = appModel._test_makeOperatorConnectOptions(
|
|
clientId: "openclaw-ios",
|
|
displayName: "OpenClaw iOS",
|
|
includeApprovalScope: true)
|
|
let withAdminScope = appModel._test_makeOperatorConnectOptions(
|
|
clientId: "openclaw-ios",
|
|
displayName: "OpenClaw iOS",
|
|
includeAdminScope: true,
|
|
includeApprovalScope: false)
|
|
|
|
#expect(withoutApprovalScope.role == "operator")
|
|
#expect(!withoutApprovalScope.scopes.contains("operator.admin"))
|
|
#expect(withoutApprovalScope.scopes.contains("operator.read"))
|
|
#expect(withoutApprovalScope.scopes.contains("operator.write"))
|
|
#expect(!withoutApprovalScope.scopes.contains("operator.approvals"))
|
|
#expect(withoutApprovalScope.scopes.contains("operator.talk.secrets"))
|
|
#expect(!withoutApprovalScope.scopesAreExplicit)
|
|
|
|
#expect(withApprovalScope.scopes.contains("operator.approvals"))
|
|
#expect(withAdminScope.scopes.contains("operator.admin"))
|
|
}
|
|
|
|
@Test @MainActor func `operator talk permission upgrade uses explicit least privilege scopes`() {
|
|
let appModel = NodeAppModel()
|
|
let options = appModel._test_makeOperatorConnectOptions(
|
|
clientId: "openclaw-ios",
|
|
displayName: "OpenClaw iOS",
|
|
includeApprovalScope: false,
|
|
forceExplicitScopes: true)
|
|
|
|
#expect(options.scopesAreExplicit)
|
|
#expect(!options.scopes.contains("operator.admin"))
|
|
#expect(!options.scopes.contains("operator.approvals"))
|
|
#expect(options.scopes.contains("operator.read"))
|
|
#expect(options.scopes.contains("operator.write"))
|
|
#expect(options.scopes.contains("operator.talk.secrets"))
|
|
}
|
|
|
|
@Test func `operator admin scope requests only when shared auth or already granted`() {
|
|
#expect(
|
|
!NodeAppModel._test_shouldRequestOperatorAdminScope(
|
|
token: nil,
|
|
password: nil,
|
|
storedOperatorScopes: ["operator.read", "operator.write", "operator.talk.secrets"]))
|
|
#expect(
|
|
NodeAppModel._test_shouldRequestOperatorAdminScope(
|
|
token: nil,
|
|
password: nil,
|
|
storedOperatorScopes: ["operator.admin"]))
|
|
#expect(
|
|
NodeAppModel._test_shouldRequestOperatorAdminScope(
|
|
token: "shared-token",
|
|
password: nil,
|
|
storedOperatorScopes: []))
|
|
#expect(
|
|
NodeAppModel._test_shouldRequestOperatorAdminScope(
|
|
token: nil,
|
|
password: "shared-password",
|
|
storedOperatorScopes: []))
|
|
#expect(
|
|
!NodeAppModel._test_shouldRequestOperatorAdminScope(
|
|
token: "shared-token",
|
|
password: nil,
|
|
storedOperatorScopes: [],
|
|
forceTalkPermissionUpgradeRequest: true))
|
|
}
|
|
|
|
@Test func `stored device token scope gap uses gateway scope compatibility`() {
|
|
#expect(!GatewayChannelActor._test_requestedScopesExceedStoredToken(
|
|
role: "operator",
|
|
requestedScopes: ["operator.read", "operator.write", "operator.talk.secrets"],
|
|
storedToken: "stored-device-token",
|
|
storedScopes: ["operator.admin"]))
|
|
#expect(!GatewayChannelActor._test_requestedScopesExceedStoredToken(
|
|
role: "operator",
|
|
requestedScopes: ["operator.read"],
|
|
storedToken: "stored-device-token",
|
|
storedScopes: []))
|
|
#expect(GatewayChannelActor._test_requestedScopesExceedStoredToken(
|
|
role: "operator",
|
|
requestedScopes: ["operator.admin"],
|
|
storedToken: "stored-device-token",
|
|
storedScopes: ["operator.read"]))
|
|
}
|
|
|
|
@Test func `operator approval scope requests stay backward compatible`() {
|
|
#expect(
|
|
!NodeAppModel._test_shouldRequestOperatorApprovalScope(
|
|
token: nil,
|
|
password: nil,
|
|
storedOperatorScopes: ["operator.read", "operator.write", "operator.talk.secrets"]))
|
|
#expect(
|
|
NodeAppModel._test_shouldRequestOperatorApprovalScope(
|
|
token: nil,
|
|
password: nil,
|
|
storedOperatorScopes: [
|
|
"operator.approvals",
|
|
"operator.read",
|
|
"operator.write",
|
|
"operator.talk.secrets",
|
|
]))
|
|
#expect(
|
|
NodeAppModel._test_shouldRequestOperatorApprovalScope(
|
|
token: "shared-token",
|
|
password: nil,
|
|
storedOperatorScopes: []))
|
|
#expect(
|
|
!NodeAppModel._test_shouldRequestOperatorApprovalScope(
|
|
token: "shared-token",
|
|
password: nil,
|
|
storedOperatorScopes: [],
|
|
forceTalkPermissionUpgradeRequest: true))
|
|
#expect(
|
|
NodeAppModel._test_shouldRequestOperatorApprovalScope(
|
|
token: nil,
|
|
password: nil,
|
|
storedOperatorScopes: ["operator.approvals"],
|
|
forceTalkPermissionUpgradeRequest: true))
|
|
}
|
|
|
|
@Test @MainActor func `operator pairing problem preserves primary gateway connection state`() {
|
|
let appModel = NodeAppModel()
|
|
appModel._test_setGatewayConnected(true)
|
|
appModel.gatewayServerName = "gateway.example.com"
|
|
appModel.gatewayRemoteAddress = "127.0.0.1:53380"
|
|
let problem = GatewayConnectionProblem(
|
|
kind: .pairingScopeUpgradeRequired,
|
|
owner: .gateway,
|
|
title: "Additional permissions required",
|
|
message: "Approve the requested permissions on the gateway.",
|
|
requestId: "req-admin",
|
|
retryable: false,
|
|
pauseReconnect: true)
|
|
|
|
appModel._test_applyOperatorGatewayConnectionProblem(problem)
|
|
|
|
#expect(appModel._test_isGatewayConnected())
|
|
#expect(appModel.gatewayServerName == "gateway.example.com")
|
|
#expect(appModel.gatewayRemoteAddress == "127.0.0.1:53380")
|
|
#expect(appModel.lastGatewayProblem == problem)
|
|
#expect(appModel.gatewayPairingPaused)
|
|
#expect(appModel.gatewayPairingRequestId == "req-admin")
|
|
|
|
appModel._test_clearGatewayConnectionProblem()
|
|
|
|
#expect(appModel.lastGatewayProblem == problem)
|
|
#expect(appModel.gatewayPairingPaused)
|
|
#expect(appModel.gatewayPairingRequestId == "req-admin")
|
|
|
|
appModel._test_clearOperatorGatewayConnectionProblemIfCurrent()
|
|
|
|
#expect(appModel._test_isGatewayConnected())
|
|
#expect(appModel.gatewayServerName == "gateway.example.com")
|
|
#expect(appModel.lastGatewayProblem == nil)
|
|
#expect(!appModel.gatewayPairingPaused)
|
|
#expect(appModel.gatewayPairingRequestId == nil)
|
|
#expect(appModel.gatewayStatusText == "Connected")
|
|
}
|
|
|
|
@Test func `gateway connect config matches equivalent inputs`() {
|
|
let lhs = Self.makeGatewayConnectConfig()
|
|
let rhs = GatewayConnectConfig(
|
|
url: lhs.url,
|
|
stableID: lhs.stableID,
|
|
tls: lhs.tls,
|
|
token: lhs.token,
|
|
bootstrapToken: lhs.bootstrapToken,
|
|
password: lhs.password,
|
|
nodeOptions: GatewayConnectOptions(
|
|
role: "node",
|
|
scopes: [],
|
|
caps: ["canvas", "screen"],
|
|
commands: ["location.get", "notify"],
|
|
permissions: ["screen": true],
|
|
clientId: "ios",
|
|
clientMode: "node",
|
|
clientDisplayName: "Phone"))
|
|
|
|
#expect(lhs.hasSameConnectionInputs(as: rhs))
|
|
}
|
|
|
|
@Test func `setup auth override is scoped to scanned endpoint`() {
|
|
let link = GatewayConnectDeepLink(
|
|
host: "first.gateway.example.com",
|
|
port: 443,
|
|
tls: true,
|
|
bootstrapToken: "bootstrap-token",
|
|
token: "source-token",
|
|
password: "source-password")
|
|
let pending = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link).manualAuthOverride
|
|
let firstStableID = GatewayConnectionController.ManualAuthOverride.manualStableID(
|
|
host: link.host,
|
|
port: link.port)
|
|
let secondStableID = GatewayConnectionController.ManualAuthOverride.manualStableID(
|
|
host: "second.gateway.example.com",
|
|
port: 443)
|
|
|
|
let first = GatewayConnectionController.ManualAuthOverride.currentManualInput(
|
|
token: "source-token",
|
|
pendingOverride: pending,
|
|
password: "source-password",
|
|
targetStableID: firstStableID)
|
|
let second = GatewayConnectionController.ManualAuthOverride.currentManualInput(
|
|
token: "source-token",
|
|
pendingOverride: pending,
|
|
password: "source-password",
|
|
targetStableID: secondStableID)
|
|
let edited = GatewayConnectionController.ManualAuthOverride.currentManualInput(
|
|
token: "replacement-token",
|
|
pendingOverride: pending,
|
|
password: "source-password",
|
|
targetStableID: secondStableID)
|
|
let ordinary = GatewayConnectionController.ManualAuthOverride.currentManualInput(
|
|
token: "manual-token",
|
|
pendingOverride: nil,
|
|
password: nil,
|
|
targetStableID: secondStableID)
|
|
|
|
#expect(first?.token == "source-token")
|
|
#expect(first?.bootstrapToken == "bootstrap-token")
|
|
#expect(first?.password == "source-password")
|
|
#expect(first?.targetStableID == firstStableID)
|
|
#expect(first?.suppressStoredDeviceAuth == true)
|
|
#expect(second?.token == nil)
|
|
#expect(second?.bootstrapToken == nil)
|
|
#expect(second?.password == nil)
|
|
#expect(second?.targetStableID == secondStableID)
|
|
#expect(second?.suppressStoredDeviceAuth == true)
|
|
#expect(edited?.token == "replacement-token")
|
|
#expect(edited?.password == nil)
|
|
#expect(ordinary?.suppressStoredDeviceAuth == false)
|
|
}
|
|
|
|
@Test func `persisted setup auth stays scoped after view recreation`() throws {
|
|
let instanceID = "setup-auth-owner-\(UUID().uuidString)"
|
|
defer { GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID) }
|
|
let firstStableID = "manual|first.gateway.example.com|443"
|
|
let secondStableID = "manual|second.gateway.example.com|443"
|
|
GatewaySettingsStore.saveGatewayCredentials(
|
|
token: "source-token",
|
|
bootstrapToken: "source-bootstrap-token",
|
|
password: "source-password",
|
|
gatewayStableID: firstStableID,
|
|
suppressStoredDeviceAuth: true,
|
|
instanceId: instanceID)
|
|
|
|
let relaunchedOverride = try #require(
|
|
GatewayConnectionController.ManualAuthOverride.persisted(
|
|
instanceId: instanceID,
|
|
targetStableID: firstStableID))
|
|
let sameTargetRetryOverride = try #require(
|
|
GatewayConnectionController.ManualAuthOverride.persisted(
|
|
instanceId: instanceID,
|
|
targetStableID: firstStableID))
|
|
#expect(sameTargetRetryOverride.bootstrapToken == "source-bootstrap-token")
|
|
#expect(GatewayConnectionController.ManualAuthOverride.persisted(
|
|
instanceId: instanceID,
|
|
targetStableID: secondStableID) == nil)
|
|
let secondGatewayAuth = try #require(
|
|
GatewayConnectionController.ManualAuthOverride.currentManualInput(
|
|
token: "source-token",
|
|
pendingOverride: relaunchedOverride,
|
|
password: "source-password",
|
|
targetStableID: secondStableID))
|
|
GatewaySettingsStore.saveGatewayCredentials(
|
|
token: secondGatewayAuth.token,
|
|
bootstrapToken: secondGatewayAuth.bootstrapToken,
|
|
password: secondGatewayAuth.password,
|
|
gatewayStableID: secondStableID,
|
|
suppressStoredDeviceAuth: secondGatewayAuth.suppressStoredDeviceAuth,
|
|
instanceId: instanceID)
|
|
|
|
#expect(secondGatewayAuth.token == nil)
|
|
#expect(secondGatewayAuth.bootstrapToken == nil)
|
|
#expect(secondGatewayAuth.password == nil)
|
|
#expect(GatewaySettingsStore.loadGatewayCredentials(
|
|
instanceId: instanceID,
|
|
gatewayStableID: firstStableID).bootstrapToken == "source-bootstrap-token")
|
|
let persistedCredentiallessHandoff = GatewaySettingsStore.loadGatewayCredentials(
|
|
instanceId: instanceID,
|
|
gatewayStableID: secondStableID)
|
|
#expect(!persistedCredentiallessHandoff.hasCredentials)
|
|
#expect(persistedCredentiallessHandoff.suppressStoredDeviceAuth)
|
|
let nextRelaunchOverride = try #require(
|
|
GatewayConnectionController.ManualAuthOverride.persisted(
|
|
instanceId: instanceID,
|
|
targetStableID: secondStableID))
|
|
#expect(nextRelaunchOverride.targetStableID == secondStableID)
|
|
#expect(nextRelaunchOverride.suppressStoredDeviceAuth)
|
|
}
|
|
|
|
@Test @MainActor func `empty setup auth does not reuse stored gateway credentials`() async throws {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
|
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
|
let defaults = UserDefaults.standard
|
|
let previousInstanceID = defaults.object(forKey: "node.instanceId")
|
|
let instanceID = "ios-test-\(UUID().uuidString)"
|
|
defaults.set(instanceID, forKey: "node.instanceId")
|
|
GatewaySettingsStore.saveGatewayCredentials(
|
|
token: "stored-token",
|
|
bootstrapToken: nil,
|
|
password: "stored-password",
|
|
gatewayStableID: "manual|stored.example.com|443",
|
|
suppressStoredDeviceAuth: false,
|
|
instanceId: instanceID)
|
|
defer {
|
|
GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID)
|
|
if let previousInstanceID {
|
|
defaults.set(previousInstanceID, forKey: "node.instanceId")
|
|
} else {
|
|
defaults.removeObject(forKey: "node.instanceId")
|
|
}
|
|
if let previousStateDir {
|
|
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
|
} else {
|
|
unsetenv("OPENCLAW_STATE_DIR")
|
|
}
|
|
try? FileManager.default.removeItem(at: tempDir)
|
|
}
|
|
|
|
let link = GatewayConnectDeepLink(
|
|
host: "192.168.1.41",
|
|
port: 18789,
|
|
tls: false,
|
|
bootstrapToken: nil,
|
|
token: nil,
|
|
password: nil)
|
|
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
|
|
await controller.connectManual(
|
|
host: link.host,
|
|
port: link.port,
|
|
useTLS: link.tls,
|
|
authOverride: setupAuth.manualAuthOverride)
|
|
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
|
|
while appModel.activeGatewayConnectConfig == nil, ContinuousClock().now < deadline {
|
|
await Task.yield()
|
|
}
|
|
|
|
#expect(appModel.activeGatewayConnectConfig != nil)
|
|
#expect(appModel.activeGatewayConnectConfig?.token == nil)
|
|
#expect(appModel.activeGatewayConnectConfig?.bootstrapToken == nil)
|
|
#expect(appModel.activeGatewayConnectConfig?.password == nil)
|
|
#expect(appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == false)
|
|
#expect(appModel.activeGatewayConnectConfig?.nodeOptions.deviceAuthGatewayID == setupAuth.targetStableID)
|
|
}
|
|
|
|
@Test @MainActor func `legacy auth preserves proven relay credentials and otherwise requires full re-pair`() throws {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
|
let defaults = UserDefaults.standard
|
|
let previousInstanceID = defaults.string(forKey: "node.instanceId")
|
|
let instanceID = "legacy-relay-\(UUID().uuidString)"
|
|
let gatewayService = GatewaySettingsStore._testGatewayService
|
|
let lastConnectionAccount = "gateway-registry"
|
|
let previousLastConnection = KeychainStore.loadString(
|
|
service: gatewayService,
|
|
account: lastConnectionAccount)
|
|
let previousRelay = ShareGatewayRelaySettings.loadConfig()
|
|
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
|
defaults.set(instanceID, forKey: "node.instanceId")
|
|
defer {
|
|
GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID)
|
|
if let previousInstanceID {
|
|
defaults.set(previousInstanceID, forKey: "node.instanceId")
|
|
} else {
|
|
defaults.removeObject(forKey: "node.instanceId")
|
|
}
|
|
if let previousStateDir {
|
|
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
|
} else {
|
|
unsetenv("OPENCLAW_STATE_DIR")
|
|
}
|
|
if let previousLastConnection {
|
|
_ = KeychainStore.saveString(
|
|
previousLastConnection,
|
|
service: gatewayService,
|
|
account: lastConnectionAccount)
|
|
} else {
|
|
_ = KeychainStore.delete(service: gatewayService, account: lastConnectionAccount)
|
|
}
|
|
if let previousRelay {
|
|
ShareGatewayRelaySettings.saveConfig(previousRelay)
|
|
} else {
|
|
ShareGatewayRelaySettings.clearConfig()
|
|
}
|
|
try? FileManager.default.removeItem(at: tempDir)
|
|
}
|
|
|
|
let stableID = "manual|gateway.example.com|443"
|
|
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
|
|
let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: primaryIdentity.deviceId,
|
|
role: "node",
|
|
token: "legacy-primary-token")
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: primaryIdentity.deviceId,
|
|
role: "operator",
|
|
token: "legacy-operator-token")
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: shareIdentity.deviceId,
|
|
role: "node",
|
|
token: "legacy-share-token",
|
|
profile: .shareExtension)
|
|
GatewaySettingsStore.saveLegacyGatewayTokenForMigrationTest(
|
|
"unproven-field-token",
|
|
instanceId: instanceID)
|
|
saveActiveManualGateway(
|
|
host: "gateway.example.com",
|
|
port: 443,
|
|
useTLS: true,
|
|
stableID: stableID)
|
|
ShareGatewayRelaySettings.saveConfig(ShareGatewayRelayConfig(
|
|
gatewayURLString: "wss://gateway.example.com",
|
|
token: "proven-relay-token",
|
|
password: "proven-relay-password",
|
|
sessionKey: "main"))
|
|
|
|
let extensionConfig = try #require(ShareGatewayRelaySettings.loadConfigDiscardingUnscopedDeviceAuth())
|
|
#expect(extensionConfig.gatewayStableID == nil)
|
|
#expect(DeviceAuthStore.loadToken(
|
|
deviceId: shareIdentity.deviceId,
|
|
role: "node",
|
|
profile: .shareExtension) == nil)
|
|
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: shareIdentity.deviceId,
|
|
role: "node",
|
|
token: "ambiguous-share-token",
|
|
profile: .shareExtension)
|
|
_ = GatewayConnectionController(appModel: NodeAppModel(), startDiscovery: false)
|
|
|
|
#expect(DeviceAuthStore.loadToken(
|
|
deviceId: primaryIdentity.deviceId,
|
|
role: "node",
|
|
gatewayID: stableID)?.token == "legacy-primary-token")
|
|
#expect(DeviceAuthStore.loadToken(
|
|
deviceId: primaryIdentity.deviceId,
|
|
role: "operator",
|
|
gatewayID: stableID) == nil)
|
|
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "operator") == nil)
|
|
#expect(DeviceAuthStore.loadToken(
|
|
deviceId: shareIdentity.deviceId,
|
|
role: "node",
|
|
gatewayID: stableID,
|
|
profile: .shareExtension) == nil)
|
|
#expect(DeviceAuthStore.loadToken(
|
|
deviceId: shareIdentity.deviceId,
|
|
role: "node",
|
|
profile: .shareExtension) == nil)
|
|
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node") == nil)
|
|
#expect(ShareGatewayRelaySettings.loadConfig()?.gatewayStableID == stableID)
|
|
let credentials = GatewaySettingsStore.loadGatewayCredentials(
|
|
instanceId: instanceID,
|
|
gatewayStableID: stableID)
|
|
#expect(credentials.token == "proven-relay-token")
|
|
#expect(credentials.password == "proven-relay-password")
|
|
#expect(!credentials.suppressStoredDeviceAuth)
|
|
#expect(KeychainStore.loadString(
|
|
service: gatewayService,
|
|
account: "gateway-token.\(instanceID)") == nil)
|
|
|
|
let rePairStableID = "manual|pair-again.example.com|443"
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: primaryIdentity.deviceId,
|
|
role: "node",
|
|
token: "legacy-node-without-shared-auth")
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: primaryIdentity.deviceId,
|
|
role: "operator",
|
|
token: "legacy-operator-without-shared-auth")
|
|
GatewaySettingsStore.saveLegacyGatewayTokenForMigrationTest(
|
|
"unproven-field-token",
|
|
instanceId: instanceID)
|
|
saveActiveManualGateway(
|
|
host: "pair-again.example.com",
|
|
port: 443,
|
|
useTLS: true,
|
|
stableID: rePairStableID)
|
|
ShareGatewayRelaySettings.saveConfig(ShareGatewayRelayConfig(
|
|
gatewayURLString: "wss://pair-again.example.com",
|
|
token: nil,
|
|
password: nil,
|
|
sessionKey: "main"))
|
|
|
|
_ = GatewayConnectionController(appModel: NodeAppModel(), startDiscovery: false)
|
|
|
|
#expect(DeviceAuthStore.loadToken(
|
|
deviceId: primaryIdentity.deviceId,
|
|
role: "node",
|
|
gatewayID: rePairStableID) == nil)
|
|
#expect(DeviceAuthStore.loadToken(
|
|
deviceId: primaryIdentity.deviceId,
|
|
role: "operator",
|
|
gatewayID: rePairStableID) == nil)
|
|
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node") == nil)
|
|
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "operator") == nil)
|
|
}
|
|
|
|
@Test @MainActor func `successful setup handoff enables target scoped auth`() throws {
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
|
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
|
defer {
|
|
if let previousStateDir {
|
|
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
|
} else {
|
|
unsetenv("OPENCLAW_STATE_DIR")
|
|
}
|
|
try? FileManager.default.removeItem(at: tempDir)
|
|
}
|
|
|
|
let identity = DeviceIdentityStore.loadOrCreate()
|
|
let previousStableID = "manual|previous.gateway.example.com|443"
|
|
let stableID = "manual|new.gateway.example.com|443"
|
|
let instanceID = "bootstrap-handoff-\(UUID().uuidString)"
|
|
let previousInstanceID = UserDefaults.standard.string(forKey: "node.instanceId")
|
|
UserDefaults.standard.set(instanceID, forKey: "node.instanceId")
|
|
defer {
|
|
GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID)
|
|
if let previousInstanceID {
|
|
UserDefaults.standard.set(previousInstanceID, forKey: "node.instanceId")
|
|
} else {
|
|
UserDefaults.standard.removeObject(forKey: "node.instanceId")
|
|
}
|
|
}
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: identity.deviceId,
|
|
role: "node",
|
|
token: "previous-node-token",
|
|
gatewayID: previousStableID)
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: identity.deviceId,
|
|
role: "operator",
|
|
token: "previous-operator-token",
|
|
gatewayID: previousStableID)
|
|
var nodeOptions = GatewayConnectOptions(
|
|
role: "node",
|
|
scopes: [],
|
|
caps: [],
|
|
commands: [],
|
|
permissions: [:],
|
|
clientId: "ios",
|
|
clientMode: "node",
|
|
clientDisplayName: "Phone",
|
|
allowStoredDeviceAuth: false,
|
|
deviceAuthGatewayID: stableID)
|
|
let config = try GatewayConnectConfig(
|
|
url: #require(URL(string: "wss://127.0.0.1:1")),
|
|
stableID: stableID,
|
|
tls: nil,
|
|
token: nil,
|
|
bootstrapToken: "one-time-bootstrap",
|
|
password: nil,
|
|
nodeOptions: nodeOptions)
|
|
GatewaySettingsStore.saveGatewayCredentials(
|
|
token: nil,
|
|
bootstrapToken: "one-time-bootstrap",
|
|
password: nil,
|
|
gatewayStableID: stableID,
|
|
suppressStoredDeviceAuth: true,
|
|
instanceId: instanceID)
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
appModel.applyGatewayConnectConfig(config)
|
|
|
|
let emptyIssuanceOptions = appModel._test_completeSuccessfulGatewayAuthHandoff(
|
|
issuedRoles: [],
|
|
nodeOptions: nodeOptions)
|
|
let operatorOnlyOptions = appModel._test_completeSuccessfulGatewayAuthHandoff(
|
|
issuedRoles: ["operator"],
|
|
nodeOptions: nodeOptions)
|
|
let nodeOnlyOptions = appModel._test_completeSuccessfulGatewayAuthHandoff(
|
|
issuedRoles: ["node"],
|
|
nodeOptions: nodeOptions)
|
|
#expect(emptyIssuanceOptions == nil)
|
|
#expect(operatorOnlyOptions == nil)
|
|
#expect(nodeOnlyOptions == nil)
|
|
#expect(appModel.activeGatewayConnectConfig?.bootstrapToken == "one-time-bootstrap")
|
|
#expect(GatewaySettingsStore.loadGatewayCredentialMetadata(
|
|
instanceId: instanceID,
|
|
gatewayStableID: stableID) != nil)
|
|
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: identity.deviceId,
|
|
role: "node",
|
|
token: "new-node-token",
|
|
gatewayID: stableID)
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: identity.deviceId,
|
|
role: "operator",
|
|
token: "new-operator-token",
|
|
gatewayID: stableID)
|
|
let bootstrapOptions = nodeOptions
|
|
appModel._test_setGatewayLoopTasks(node: nil, operator: Task {})
|
|
nodeOptions = try #require(appModel._test_completeSuccessfulGatewayAuthHandoff(
|
|
issuedRoles: ["node", "operator"],
|
|
nodeOptions: nodeOptions))
|
|
|
|
#expect(nodeOptions.allowStoredDeviceAuth)
|
|
#expect(appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == true)
|
|
#expect(appModel.activeGatewayConnectConfig?.bootstrapToken == nil)
|
|
#expect(GatewaySettingsStore.loadGatewayCredentialMetadata(
|
|
instanceId: instanceID,
|
|
gatewayStableID: stableID) == nil)
|
|
#expect(DeviceAuthStore.loadToken(
|
|
deviceId: identity.deviceId,
|
|
role: "node",
|
|
gatewayID: stableID)?.token == "new-node-token")
|
|
#expect(DeviceAuthStore.loadToken(
|
|
deviceId: identity.deviceId,
|
|
role: "operator",
|
|
gatewayID: stableID)?.token == "new-operator-token")
|
|
#expect(DeviceAuthStore.loadToken(
|
|
deviceId: identity.deviceId,
|
|
role: "node",
|
|
gatewayID: previousStableID)?.token == "previous-node-token")
|
|
#expect(appModel._test_hasGatewayLoopTasks().operator)
|
|
#expect(appModel._test_currentGatewayReconnectOptions(
|
|
stableID: stableID,
|
|
fallback: bootstrapOptions).allowStoredDeviceAuth)
|
|
}
|
|
|
|
@Test @MainActor func `bootstrap pairing clears only the target gateway`() async throws {
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
|
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
|
let gatewayA = "manual|gateway-a-\(UUID().uuidString)|443"
|
|
let gatewayB = "manual|gateway-b-\(UUID().uuidString)|443"
|
|
defer {
|
|
GatewayTLSStore.clearFingerprint(stableID: gatewayA)
|
|
GatewayTLSStore.clearFingerprint(stableID: gatewayB)
|
|
if let previousStateDir {
|
|
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
|
} else {
|
|
unsetenv("OPENCLAW_STATE_DIR")
|
|
}
|
|
try? FileManager.default.removeItem(at: tempDir)
|
|
}
|
|
|
|
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
|
|
let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
|
|
GatewayTLSStore.saveFingerprint("fingerprint-a", stableID: gatewayA)
|
|
GatewayTLSStore.saveFingerprint("fingerprint-b", stableID: gatewayB)
|
|
let gatewayAOwner = GatewaySettingsStore.authenticationOwnerID(routeStableID: gatewayA)
|
|
let gatewayBOwner = GatewaySettingsStore.authenticationOwnerID(routeStableID: gatewayB)
|
|
for (routeID, ownerID) in [(gatewayA, gatewayAOwner), (gatewayB, gatewayBOwner)] {
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: primaryIdentity.deviceId,
|
|
role: "node",
|
|
token: "primary-\(routeID)",
|
|
gatewayID: ownerID)
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: shareIdentity.deviceId,
|
|
role: "node",
|
|
token: "share-\(routeID)",
|
|
gatewayID: ownerID,
|
|
profile: .shareExtension)
|
|
}
|
|
|
|
let appModel = NodeAppModel()
|
|
await GatewayOnboardingReset.prepareForBootstrapPairing(
|
|
appModel: appModel,
|
|
instanceId: "",
|
|
gatewayStableID: gatewayB,
|
|
disconnectGateway: false)
|
|
|
|
#expect(DeviceAuthStore.loadToken(
|
|
deviceId: primaryIdentity.deviceId,
|
|
role: "node",
|
|
gatewayID: gatewayAOwner) != nil)
|
|
#expect(DeviceAuthStore.loadToken(
|
|
deviceId: primaryIdentity.deviceId,
|
|
role: "node",
|
|
gatewayID: gatewayBOwner) == nil)
|
|
#expect(DeviceAuthStore.loadToken(
|
|
deviceId: shareIdentity.deviceId,
|
|
role: "node",
|
|
gatewayID: gatewayAOwner,
|
|
profile: .shareExtension) != nil)
|
|
#expect(DeviceAuthStore.loadToken(
|
|
deviceId: shareIdentity.deviceId,
|
|
role: "node",
|
|
gatewayID: gatewayBOwner,
|
|
profile: .shareExtension) == nil)
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: gatewayA) == "fingerprint-a")
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: gatewayB) == nil)
|
|
}
|
|
|
|
@Test @MainActor func `explicit auth starts operator loop while stored auth is disabled`() throws {
|
|
let stableID = "manual|gateway.example.com|443"
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let config = try GatewayConnectConfig(
|
|
url: #require(URL(string: "wss://127.0.0.1:1")),
|
|
stableID: stableID,
|
|
tls: nil,
|
|
token: "shared-token",
|
|
bootstrapToken: nil,
|
|
password: nil,
|
|
nodeOptions: GatewayConnectOptions(
|
|
role: "node",
|
|
scopes: [],
|
|
caps: [],
|
|
commands: [],
|
|
permissions: [:],
|
|
clientId: "ios",
|
|
clientMode: "node",
|
|
clientDisplayName: "Phone",
|
|
allowStoredDeviceAuth: false,
|
|
deviceAuthGatewayID: stableID))
|
|
|
|
appModel.applyGatewayConnectConfig(config)
|
|
|
|
#expect(appModel._test_hasGatewayLoopTasks().node)
|
|
#expect(appModel._test_hasGatewayLoopTasks().operator)
|
|
}
|
|
|
|
@Test @MainActor func `applying different gateway config reconnects active tasks`() throws {
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let first = try Self.makeGatewayConnectConfig(
|
|
url: #require(URL(string: "wss://127.0.0.1:1")),
|
|
stableID: "manual|first.gateway.example.com|443")
|
|
let second = try Self.makeGatewayConnectConfig(
|
|
url: #require(URL(string: "wss://127.0.0.1:2")),
|
|
stableID: "manual|second.gateway.example.com|443")
|
|
|
|
appModel.applyGatewayConnectConfig(first)
|
|
appModel.talkMode.updateGatewayConnected(true)
|
|
appModel.applyGatewayConnectConfig(second)
|
|
|
|
#expect(appModel.connectedGatewayID == second.stableID)
|
|
#expect(!appModel.talkMode.isGatewayConnected)
|
|
}
|
|
|
|
@Test @MainActor func `forced reconnect reset clears active gateway loop tasks`() async {
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
|
|
appModel.applyGatewayConnectConfig(Self.makeGatewayConnectConfig())
|
|
#expect(appModel._test_hasGatewayLoopTasks().node)
|
|
#expect(appModel._test_hasGatewayLoopTasks().operator)
|
|
|
|
await appModel.resetGatewaySessionsForForcedReconnect()
|
|
|
|
#expect(!appModel._test_hasGatewayLoopTasks().node)
|
|
#expect(!appModel._test_hasGatewayLoopTasks().operator)
|
|
}
|
|
|
|
@Test @MainActor func `forced reconnect reset waits for canceled loop cleanup`() async {
|
|
var cleanupStarted = false
|
|
var releaseCleanup: CheckedContinuation<Void, Never>?
|
|
var resetFinished = false
|
|
let appModel = NodeAppModel()
|
|
let loopTask = Task { @MainActor in
|
|
while !Task.isCancelled {
|
|
await Task.yield()
|
|
}
|
|
cleanupStarted = true
|
|
await withCheckedContinuation { continuation in
|
|
releaseCleanup = continuation
|
|
}
|
|
}
|
|
appModel._test_setGatewayLoopTasks(node: loopTask)
|
|
defer {
|
|
releaseCleanup?.resume()
|
|
appModel.disconnectGateway()
|
|
}
|
|
|
|
let resetTask = Task { @MainActor in
|
|
await appModel.resetGatewaySessionsForForcedReconnect()
|
|
resetFinished = true
|
|
}
|
|
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
|
|
while !cleanupStarted, ContinuousClock().now < deadline {
|
|
await Task.yield()
|
|
}
|
|
|
|
#expect(cleanupStarted)
|
|
#expect(appModel.hasGatewaySessionResetInFlight)
|
|
#expect(!resetFinished)
|
|
|
|
releaseCleanup?.resume()
|
|
releaseCleanup = nil
|
|
await resetTask.value
|
|
|
|
#expect(resetFinished)
|
|
#expect(!appModel.hasGatewaySessionResetInFlight)
|
|
}
|
|
|
|
@Test @MainActor func `manual disconnect chains after existing reset to own new loops`() async {
|
|
let resetRelease = AsyncStream<Void>.makeStream()
|
|
let appModel = NodeAppModel()
|
|
defer {
|
|
resetRelease.continuation.finish()
|
|
appModel._test_setGatewaySessionResetTask(nil)
|
|
appModel.disconnectGateway()
|
|
}
|
|
let existingReset = Task {
|
|
for await _ in resetRelease.stream {
|
|
return
|
|
}
|
|
}
|
|
appModel._test_setGatewaySessionResetTask(existingReset)
|
|
appModel.applyGatewayConnectConfig(Self.makeGatewayConnectConfig())
|
|
#expect(appModel._test_hasGatewayLoopTasks().node)
|
|
#expect(appModel._test_hasGatewayLoopTasks().operator)
|
|
|
|
appModel.disconnectGateway()
|
|
resetRelease.continuation.yield()
|
|
resetRelease.continuation.finish()
|
|
await appModel.waitForGatewaySessionResetIfNeeded()
|
|
|
|
#expect(appModel.activeGatewayConnectConfig == nil)
|
|
#expect(!appModel._test_hasGatewayLoopTasks().node)
|
|
#expect(!appModel._test_hasGatewayLoopTasks().operator)
|
|
}
|
|
|
|
@Test @MainActor func `target switch reset clears previous reconnect route`() async {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let defaults = UserDefaults.standard
|
|
let reconnectDefaults: [String: Any?] = [
|
|
"gateway.autoconnect": true,
|
|
"gateway.manual.enabled": true,
|
|
"gateway.manual.host": "previous.gateway.invalid",
|
|
"gateway.manual.port": 443,
|
|
"gateway.manual.tls": true,
|
|
]
|
|
var reconnectDefaultsSnapshot: [String: Any?] = [:]
|
|
for key in reconnectDefaults.keys {
|
|
reconnectDefaultsSnapshot[key] = defaults.object(forKey: key)
|
|
}
|
|
let gatewayService = GatewaySettingsStore._testGatewayService
|
|
let lastConnectionAccount = "gateway-registry"
|
|
let priorLastConnection = KeychainStore.loadString(
|
|
service: gatewayService,
|
|
account: lastConnectionAccount)
|
|
let priorRelayConfig = ShareGatewayRelaySettings.loadConfig()
|
|
defer {
|
|
if let priorRelayConfig {
|
|
ShareGatewayRelaySettings.saveConfig(priorRelayConfig)
|
|
} else {
|
|
ShareGatewayRelaySettings.clearConfig()
|
|
}
|
|
}
|
|
defer {
|
|
for (key, value) in reconnectDefaultsSnapshot {
|
|
if let value {
|
|
defaults.set(value, forKey: key)
|
|
} else {
|
|
defaults.removeObject(forKey: key)
|
|
}
|
|
}
|
|
if let priorLastConnection {
|
|
_ = KeychainStore.saveString(
|
|
priorLastConnection,
|
|
service: gatewayService,
|
|
account: lastConnectionAccount)
|
|
} else {
|
|
_ = KeychainStore.delete(service: gatewayService, account: lastConnectionAccount)
|
|
}
|
|
}
|
|
for (key, value) in reconnectDefaults {
|
|
defaults.set(value, forKey: key)
|
|
}
|
|
saveActiveManualGateway(
|
|
host: "previous.gateway.invalid",
|
|
port: 443,
|
|
useTLS: true,
|
|
stableID: "manual|previous.gateway.invalid|443")
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
|
|
ShareGatewayRelaySettings.saveConfig(ShareGatewayRelayConfig(
|
|
gatewayURLString: "wss://previous.gateway.invalid",
|
|
token: "previous-token",
|
|
password: nil,
|
|
sessionKey: "main"))
|
|
appModel.applyGatewayConnectConfig(Self.makeGatewayConnectConfig())
|
|
await appModel.resetGatewaySessionsForTargetSwitch()
|
|
|
|
#expect(!appModel.gatewayAutoReconnectEnabled)
|
|
#expect(!defaults.bool(forKey: "gateway.autoconnect"))
|
|
#expect(appModel.activeGatewayConnectConfig == nil)
|
|
#expect(appModel.gatewayServerName == nil)
|
|
#expect(!appModel._test_hasGatewayLoopTasks().node)
|
|
#expect(!appModel._test_hasGatewayLoopTasks().operator)
|
|
#expect(ShareGatewayRelaySettings.loadConfig() == nil)
|
|
|
|
let relaunchedModel = NodeAppModel()
|
|
defer { relaunchedModel.disconnectGateway() }
|
|
let relaunchedController = GatewayConnectionController(
|
|
appModel: relaunchedModel,
|
|
startDiscovery: false)
|
|
relaunchedController._test_triggerAutoConnect()
|
|
|
|
#expect(!relaunchedController._test_didAutoConnect())
|
|
#expect(relaunchedModel.activeGatewayConnectConfig == nil)
|
|
}
|
|
|
|
@Test @MainActor func `target switch reset reasserts persisted reconnect pause after teardown`() async {
|
|
let defaults = UserDefaults.standard
|
|
let priorAutoConnect = defaults.object(forKey: "gateway.autoconnect")
|
|
defer {
|
|
if let priorAutoConnect {
|
|
defaults.set(priorAutoConnect, forKey: "gateway.autoconnect")
|
|
} else {
|
|
defaults.removeObject(forKey: "gateway.autoconnect")
|
|
}
|
|
}
|
|
defaults.set(true, forKey: "gateway.autoconnect")
|
|
|
|
let teardownRelease = AsyncStream<Void>.makeStream()
|
|
let appModel = NodeAppModel()
|
|
defer {
|
|
appModel._test_setGatewaySessionResetTask(nil)
|
|
appModel.disconnectGateway()
|
|
}
|
|
let staleTeardownTask = Task {
|
|
for await _ in teardownRelease.stream {
|
|
defaults.set(true, forKey: "gateway.autoconnect")
|
|
return
|
|
}
|
|
}
|
|
appModel._test_setGatewaySessionResetTask(staleTeardownTask)
|
|
|
|
let targetResetTask = Task {
|
|
await appModel.resetGatewaySessionsForTargetSwitch()
|
|
}
|
|
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
|
|
while defaults.bool(forKey: "gateway.autoconnect"), ContinuousClock().now < deadline {
|
|
await Task.yield()
|
|
}
|
|
#expect(!defaults.bool(forKey: "gateway.autoconnect"))
|
|
|
|
teardownRelease.continuation.yield()
|
|
teardownRelease.continuation.finish()
|
|
await targetResetTask.value
|
|
|
|
#expect(!defaults.bool(forKey: "gateway.autoconnect"))
|
|
}
|
|
|
|
@Test @MainActor func `newer gateway connect generation rejects queued config`() throws {
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let staleGeneration = appModel.beginGatewayConnectAttempt()
|
|
let currentGeneration = appModel.beginGatewayConnectAttempt()
|
|
let staleConfig = try Self.makeGatewayConnectConfig(
|
|
url: #require(URL(string: "wss://stale.gateway.invalid")),
|
|
stableID: "manual|stale.gateway.invalid|443")
|
|
let currentConfig = try Self.makeGatewayConnectConfig(
|
|
url: #require(URL(string: "wss://127.0.0.1:1")),
|
|
stableID: "manual|current.gateway.invalid|443")
|
|
|
|
appModel.applyGatewayConnectConfig(staleConfig, expectedGeneration: staleGeneration)
|
|
#expect(appModel.activeGatewayConnectConfig == nil)
|
|
|
|
appModel.applyGatewayConnectConfig(currentConfig, expectedGeneration: currentGeneration)
|
|
#expect(appModel.activeGatewayConnectConfig?.stableID == currentConfig.stableID)
|
|
}
|
|
|
|
@Test @MainActor func `direct gateway apply invalidates older queued config`() throws {
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let staleGeneration = appModel.beginGatewayConnectAttempt()
|
|
let staleConfig = try Self.makeGatewayConnectConfig(
|
|
url: #require(URL(string: "wss://stale.gateway.invalid")),
|
|
stableID: "manual|stale.gateway.invalid|443")
|
|
let currentConfig = try Self.makeGatewayConnectConfig(
|
|
url: #require(URL(string: "wss://127.0.0.1:1")),
|
|
stableID: "manual|current.gateway.invalid|443")
|
|
|
|
appModel.applyGatewayConnectConfig(currentConfig)
|
|
appModel.applyGatewayConnectConfig(staleConfig, expectedGeneration: staleGeneration)
|
|
|
|
#expect(appModel.activeGatewayConnectConfig?.stableID == currentConfig.stableID)
|
|
}
|
|
|
|
@Test @MainActor func `newer explicit connect immediately invalidates queued config`() async throws {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let host = "new-target.gateway.invalid"
|
|
let stableID = "manual|\(host.lowercased())|443"
|
|
defer { GatewayTLSStore.clearFingerprint(stableID: stableID) }
|
|
GatewayTLSStore.clearFingerprint(stableID: stableID)
|
|
|
|
let probeStarted = AsyncStream<Void>.makeStream()
|
|
let probeResults = AsyncStream<GatewayTLSFingerprintProbeResult>.makeStream()
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let controller = GatewayConnectionController(
|
|
appModel: appModel,
|
|
startDiscovery: false,
|
|
tcpReachabilityProbe: { _, _, _, _ in true },
|
|
tlsFingerprintProbe: { _ in
|
|
probeStarted.continuation.yield()
|
|
for await result in probeResults.stream {
|
|
return result
|
|
}
|
|
return .failure(.certificateUnavailable)
|
|
})
|
|
let staleGeneration = appModel.beginGatewayConnectAttempt()
|
|
let staleConfig = try Self.makeGatewayConnectConfig(
|
|
url: #require(URL(string: "wss://old-target.gateway.invalid")),
|
|
stableID: "manual|old-target.gateway.invalid|443")
|
|
let duringResolutionConfig = try Self.makeGatewayConnectConfig(
|
|
url: #require(URL(string: "wss://resolution-window.gateway.invalid")),
|
|
stableID: "manual|resolution-window.gateway.invalid|443")
|
|
var startedIterator = probeStarted.stream.makeAsyncIterator()
|
|
|
|
let connectTask = Task {
|
|
await controller.connectManual(host: host, port: 443, useTLS: true)
|
|
}
|
|
_ = await startedIterator.next()
|
|
appModel.applyGatewayConnectConfig(staleConfig, expectedGeneration: staleGeneration)
|
|
|
|
#expect(appModel.activeGatewayConnectConfig == nil)
|
|
|
|
let duringResolutionGeneration = appModel.gatewayConnectGeneration
|
|
probeResults.continuation.yield(.fingerprint("new-target-fingerprint"))
|
|
probeResults.continuation.finish()
|
|
await connectTask.value
|
|
await controller.acceptPendingTrustPrompt()
|
|
appModel.applyGatewayConnectConfig(
|
|
duringResolutionConfig,
|
|
expectedGeneration: duringResolutionGeneration)
|
|
|
|
#expect(appModel.activeGatewayConnectConfig?.stableID != duringResolutionConfig.stableID)
|
|
}
|
|
|
|
@Test @MainActor func `trusted certificate keeps device auth route scoped`() async throws {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let host = "127.0.0.1"
|
|
let stableID = "manual|\(host)|1"
|
|
defer { GatewayTLSStore.clearFingerprint(stableID: stableID) }
|
|
GatewayTLSStore.clearFingerprint(stableID: stableID)
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let controller = GatewayConnectionController(
|
|
appModel: appModel,
|
|
startDiscovery: false,
|
|
tcpReachabilityProbe: { _, _, _, _ in true },
|
|
tlsFingerprintProbe: { _ in .fingerprint("route-independent-fingerprint") })
|
|
|
|
await controller.connectManual(host: host, port: 1, useTLS: true)
|
|
await controller.acceptPendingTrustPrompt()
|
|
for _ in 0..<100 where appModel.activeGatewayConnectConfig == nil {
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
|
|
#expect(appModel.activeGatewayConnectConfig?.stableID == stableID)
|
|
#expect(appModel.activeGatewayConnectConfig?.nodeOptions.deviceAuthGatewayID == stableID)
|
|
}
|
|
|
|
@Test @MainActor func `first trust aborts when certificate pin is not durable`() async {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let host = "127.0.0.1"
|
|
let stableID = "manual|\(host)|2"
|
|
defer { GatewayTLSStore.clearFingerprint(stableID: stableID) }
|
|
GatewayTLSStore.clearFingerprint(stableID: stableID)
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let controller = GatewayConnectionController(
|
|
appModel: appModel,
|
|
startDiscovery: false,
|
|
tcpReachabilityProbe: { _, _, _, _ in true },
|
|
tlsFingerprintProbe: { _ in .fingerprint("unpersisted-fingerprint") },
|
|
persistTLSFingerprint: { _, _ in false })
|
|
|
|
await controller.connectManual(host: host, port: 2, useTLS: true)
|
|
await controller.acceptPendingTrustPrompt()
|
|
|
|
#expect(controller.pendingTrustPrompt != nil)
|
|
#expect(appModel.activeGatewayConnectConfig == nil)
|
|
#expect(appModel.gatewayStatusText == "Could not save gateway certificate")
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == nil)
|
|
}
|
|
|
|
@Test @MainActor func `certificate rotation preserves route scoped device auth`() async throws {
|
|
let stableID = "manual|rotation-\(UUID().uuidString)|443"
|
|
defer { GatewayTLSStore.clearFingerprint(stableID: stableID) }
|
|
#expect(GatewayTLSStore.replaceFingerprint("old-certificate", stableID: stableID))
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
var options = GatewayConnectOptions(
|
|
role: "node",
|
|
scopes: [],
|
|
caps: [],
|
|
commands: [],
|
|
permissions: [:],
|
|
clientId: "openclaw-ios",
|
|
clientMode: "node",
|
|
clientDisplayName: nil,
|
|
deviceAuthGatewayID: stableID)
|
|
options.allowStoredDeviceAuth = true
|
|
let config = try GatewayConnectConfig(
|
|
url: #require(URL(string: "wss://127.0.0.1:1")),
|
|
stableID: stableID,
|
|
tls: GatewayTLSParams(
|
|
required: true,
|
|
expectedFingerprint: "old-certificate",
|
|
allowTOFU: false,
|
|
storeKey: stableID),
|
|
token: nil,
|
|
bootstrapToken: nil,
|
|
password: nil,
|
|
nodeOptions: options)
|
|
appModel.applyGatewayConnectConfig(config)
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
let error = GatewayTLSValidationError(
|
|
failure: GatewayTLSValidationFailure(
|
|
kind: .pinMismatch,
|
|
host: "127.0.0.1",
|
|
storeKey: stableID,
|
|
expectedFingerprint: "old-certificate",
|
|
observedFingerprint: "new-certificate",
|
|
systemTrustOk: true),
|
|
context: "connect to gateway")
|
|
let problem = try #require(GatewayConnectionProblemMapper.map(error: error))
|
|
|
|
let didTrust = await controller.trustRotatedGatewayCertificate(from: problem)
|
|
#expect(didTrust)
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "new-certificate")
|
|
#expect(appModel.activeGatewayConnectConfig?.nodeOptions.deviceAuthGatewayID == stableID)
|
|
#expect(appModel.activeGatewayConnectConfig?.tls?.expectedFingerprint == "new-certificate")
|
|
}
|
|
|
|
@Test @MainActor func `cancel during forced reset restores current gateway`() async throws {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let host = "replacement.gateway.invalid"
|
|
let stableID = "manual|\(host)|443"
|
|
defer { GatewayTLSStore.clearFingerprint(stableID: stableID) }
|
|
GatewayTLSStore.saveFingerprint("replacement-fingerprint", stableID: stableID)
|
|
|
|
let resetFinished = AsyncStream<Void>.makeStream()
|
|
let resetRelease = AsyncStream<Void>.makeStream()
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let currentConfig = try Self.makeGatewayConnectConfig(
|
|
url: #require(URL(string: "wss://127.0.0.1:1")),
|
|
stableID: "manual|current.gateway.invalid|443")
|
|
appModel.applyGatewayConnectConfig(currentConfig)
|
|
let controller = GatewayConnectionController(
|
|
appModel: appModel,
|
|
startDiscovery: false,
|
|
forceReconnectReset: { appModel in
|
|
await appModel.resetGatewaySessionsForForcedReconnect()
|
|
resetFinished.continuation.yield()
|
|
for await _ in resetRelease.stream {
|
|
return
|
|
}
|
|
})
|
|
var finishedIterator = resetFinished.stream.makeAsyncIterator()
|
|
|
|
await controller.connectManual(host: host, port: 443, useTLS: true, forceReconnect: true)
|
|
_ = await finishedIterator.next()
|
|
#expect(!appModel._test_hasGatewayLoopTasks().node)
|
|
|
|
controller.cancelPendingConnectionAttempts()
|
|
resetRelease.continuation.yield()
|
|
resetRelease.continuation.finish()
|
|
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
|
|
while !appModel._test_hasGatewayLoopTasks().node, ContinuousClock().now < deadline {
|
|
await Task.yield()
|
|
}
|
|
|
|
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: currentConfig) == true)
|
|
#expect(appModel._test_hasGatewayLoopTasks().node)
|
|
}
|
|
|
|
@Test @MainActor func `cancel without pending task preserves reconnect pause`() async {
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let currentConfig = Self.makeGatewayConnectConfig()
|
|
appModel.applyGatewayConnectConfig(currentConfig)
|
|
await appModel.resetGatewaySessionsForForcedReconnect()
|
|
let problem = GatewayConnectionProblem(
|
|
kind: .protocolMismatch,
|
|
owner: .gateway,
|
|
title: "Protocol mismatch",
|
|
message: "Upgrade the gateway before reconnecting.",
|
|
retryable: false,
|
|
pauseReconnect: true)
|
|
appModel._test_applyOperatorGatewayConnectionProblem(problem)
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
|
|
controller.cancelPendingConnectionAttempts()
|
|
for _ in 0..<10 {
|
|
await Task.yield()
|
|
}
|
|
|
|
#expect(!appModel.gatewayPairingPaused)
|
|
#expect(appModel.lastGatewayProblem == problem)
|
|
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: currentConfig) == true)
|
|
#expect(!appModel._test_hasGatewayLoopTasks().node)
|
|
}
|
|
|
|
@Test @MainActor func `new connect waits for superseded forced reset`() async throws {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let forceHost = "192.168.1.39"
|
|
|
|
let resetRelease = AsyncStream<Void>.makeStream()
|
|
var resetStarted = false
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let currentConfig = try Self.makeGatewayConnectConfig(
|
|
url: #require(URL(string: "wss://127.0.0.1:1")),
|
|
stableID: "manual|current.gateway.invalid|443")
|
|
appModel.applyGatewayConnectConfig(currentConfig)
|
|
let controller = GatewayConnectionController(
|
|
appModel: appModel,
|
|
startDiscovery: false,
|
|
forceReconnectReset: { appModel in
|
|
await appModel.resetGatewaySessionsForForcedReconnect()
|
|
resetStarted = true
|
|
for await _ in resetRelease.stream {
|
|
return
|
|
}
|
|
})
|
|
|
|
await controller.connectManual(host: forceHost, port: 18789, useTLS: false, forceReconnect: true)
|
|
// Simulator WebSocket teardown can take several seconds under the aggregate iOS suite.
|
|
// Keep this bounded while allowing the real session barrier to finish before superseding it.
|
|
let resetStartDeadline = ContinuousClock().now.advanced(by: .seconds(10))
|
|
while !resetStarted, ContinuousClock().now < resetStartDeadline {
|
|
await Task.yield()
|
|
}
|
|
#expect(resetStarted)
|
|
await controller.connectManual(host: "192.168.1.40", port: 18789, useTLS: false)
|
|
|
|
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: currentConfig) == true)
|
|
#expect(!appModel._test_hasGatewayLoopTasks().node)
|
|
|
|
resetRelease.continuation.yield()
|
|
resetRelease.continuation.finish()
|
|
let replacementStableID = "manual|192.168.1.40|18789"
|
|
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
|
|
while appModel.activeGatewayConnectConfig?.stableID != replacementStableID,
|
|
ContinuousClock().now < deadline
|
|
{
|
|
await Task.yield()
|
|
}
|
|
|
|
#expect(appModel.activeGatewayConnectConfig?.stableID == replacementStableID)
|
|
#expect(appModel._test_hasGatewayLoopTasks().node)
|
|
}
|
|
|
|
@Test @MainActor func `new connect waits for model owned reset barrier`() async throws {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let resetRelease = AsyncStream<Void>.makeStream()
|
|
let appModel = NodeAppModel()
|
|
defer {
|
|
appModel._test_setGatewaySessionResetTask(nil)
|
|
appModel.disconnectGateway()
|
|
}
|
|
let currentConfig = try Self.makeGatewayConnectConfig(
|
|
url: #require(URL(string: "wss://127.0.0.1:1")),
|
|
stableID: "manual|current.gateway.invalid|443")
|
|
appModel.applyGatewayConnectConfig(currentConfig)
|
|
let modelResetTask = Task {
|
|
for await _ in resetRelease.stream {
|
|
return
|
|
}
|
|
}
|
|
appModel._test_setGatewaySessionResetTask(modelResetTask)
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
|
|
await controller.connectManual(host: "192.168.1.41", port: 18789, useTLS: false)
|
|
await Task.yield()
|
|
|
|
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: currentConfig) == true)
|
|
|
|
resetRelease.continuation.yield()
|
|
resetRelease.continuation.finish()
|
|
let replacementStableID = "manual|192.168.1.41|18789"
|
|
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
|
|
while appModel.activeGatewayConnectConfig?.stableID != replacementStableID,
|
|
ContinuousClock().now < deadline
|
|
{
|
|
await Task.yield()
|
|
}
|
|
|
|
#expect(appModel.activeGatewayConnectConfig?.stableID == replacementStableID)
|
|
}
|
|
|
|
@Test @MainActor func `trust decline releases suppression without reconnecting unpinned target`() async {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let defaults = UserDefaults.standard
|
|
let updates: [String: Any?] = [
|
|
"gateway.autoconnect": false,
|
|
"gateway.manual.enabled": true,
|
|
"gateway.manual.host": "persisted.gateway.invalid",
|
|
"gateway.manual.port": 443,
|
|
"gateway.manual.tls": true,
|
|
"node.instanceId": "ios-test",
|
|
]
|
|
var snapshot: [String: Any?] = [:]
|
|
for key in updates.keys {
|
|
snapshot[key] = defaults.object(forKey: key)
|
|
}
|
|
for (key, value) in updates {
|
|
defaults.set(value, forKey: key)
|
|
}
|
|
defer {
|
|
for (key, value) in snapshot {
|
|
if let value {
|
|
defaults.set(value, forKey: key)
|
|
} else {
|
|
defaults.removeObject(forKey: key)
|
|
}
|
|
}
|
|
}
|
|
|
|
let explicitHost = "explicit.gateway.invalid"
|
|
let explicitStableID = "manual|\(explicitHost)|443"
|
|
defer { GatewayTLSStore.clearFingerprint(stableID: explicitStableID) }
|
|
GatewayTLSStore.clearFingerprint(stableID: explicitStableID)
|
|
let probeStarted = AsyncStream<Void>.makeStream()
|
|
let probeResults = AsyncStream<GatewayTLSFingerprintProbeResult>.makeStream()
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let controller = GatewayConnectionController(
|
|
appModel: appModel,
|
|
startDiscovery: false,
|
|
tcpReachabilityProbe: { _, _, _, _ in true },
|
|
tlsFingerprintProbe: { _ in
|
|
probeStarted.continuation.yield()
|
|
for await result in probeResults.stream {
|
|
return result
|
|
}
|
|
return .failure(.certificateUnavailable)
|
|
})
|
|
defaults.set(true, forKey: "gateway.autoconnect")
|
|
var startedIterator = probeStarted.stream.makeAsyncIterator()
|
|
|
|
let connectTask = Task {
|
|
await controller.connectManual(host: explicitHost, port: 443, useTLS: true)
|
|
}
|
|
_ = await startedIterator.next()
|
|
controller._test_triggerAutoConnect()
|
|
|
|
#expect(!controller._test_didAutoConnect())
|
|
#expect(appModel.activeGatewayConnectConfig == nil)
|
|
|
|
probeResults.continuation.yield(.fingerprint("explicit-fingerprint"))
|
|
probeResults.continuation.finish()
|
|
await connectTask.value
|
|
#expect(controller.pendingTrustPrompt?.fingerprintSha256 == "explicit-fingerprint")
|
|
|
|
controller.declinePendingTrustPrompt()
|
|
|
|
#expect(!controller._test_didAutoConnect())
|
|
#expect(!controller._test_isAutoConnectSuppressed())
|
|
#expect(appModel.activeGatewayConnectConfig == nil)
|
|
}
|
|
|
|
@Test @MainActor func `active manual TLS auto connect wins over legacy manual defaults`() async {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let host = "manual-autoconnect-\(UUID().uuidString).example.com"
|
|
let stableID = "manual|\(host.lowercased())|443"
|
|
let previousStableID = "manual|previous-gateway.example.com|443"
|
|
let priorPreviousFingerprint = GatewayTLSStore.loadFingerprint(stableID: previousStableID)
|
|
defer {
|
|
GatewayTLSStore.clearFingerprint(stableID: stableID)
|
|
if let priorPreviousFingerprint {
|
|
GatewayTLSStore.saveFingerprint(priorPreviousFingerprint, stableID: previousStableID)
|
|
} else {
|
|
GatewayTLSStore.clearFingerprint(stableID: previousStableID)
|
|
}
|
|
}
|
|
GatewayTLSStore.saveFingerprint("previous-certificate", stableID: previousStableID)
|
|
saveActiveManualGateway(
|
|
host: host,
|
|
port: 443,
|
|
useTLS: true,
|
|
stableID: stableID)
|
|
|
|
await withUserDefaults([
|
|
"gateway.autoconnect": true,
|
|
"gateway.manual.enabled": true,
|
|
"gateway.manual.host": "previous-gateway.example.com",
|
|
"gateway.manual.port": 443,
|
|
"gateway.manual.tls": true,
|
|
"node.instanceId": "ios-test",
|
|
"gateway.last.host": nil,
|
|
"gateway.last.port": nil,
|
|
"gateway.last.tls": nil,
|
|
"gateway.last.stableID": nil,
|
|
"gateway.last.kind": nil,
|
|
"gateway.preferredStableID": nil,
|
|
"gateway.lastDiscoveredStableID": nil,
|
|
]) {
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
|
|
controller._test_triggerAutoConnect()
|
|
#expect(!controller._test_didAutoConnect())
|
|
|
|
GatewayTLSStore.saveFingerprint("trusted-certificate", stableID: stableID)
|
|
controller._test_triggerAutoConnect()
|
|
#expect(controller._test_didAutoConnect())
|
|
await waitForActiveGateway(stableID: stableID, appModel: appModel)
|
|
#expect(appModel.activeGatewayConnectConfig?.effectiveStableID == stableID)
|
|
}
|
|
}
|
|
|
|
@Test @MainActor func `active local manual gateway auto connects without TLS pin`() async {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let stableID = "manual|127.0.0.1|1"
|
|
defer {
|
|
GatewayTLSStore.clearFingerprint(stableID: stableID)
|
|
}
|
|
GatewayTLSStore.clearFingerprint(stableID: stableID)
|
|
saveActiveManualGateway(host: "127.0.0.1", port: 1, useTLS: false, stableID: stableID)
|
|
|
|
await withUserDefaults([
|
|
"gateway.autoconnect": true,
|
|
"gateway.manual.enabled": false,
|
|
"node.instanceId": "ios-test",
|
|
]) {
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
|
|
controller._test_triggerAutoConnect()
|
|
|
|
#expect(controller._test_didAutoConnect())
|
|
await waitForActiveGateway(stableID: stableID, appModel: appModel)
|
|
#expect(appModel.activeGatewayConnectConfig?.effectiveStableID == stableID)
|
|
#expect(appModel.activeGatewayConnectConfig?.url == URL(string: "ws://127.0.0.1:1"))
|
|
}
|
|
}
|
|
|
|
@Test @MainActor func `missing active discovered gateway auto connects to latest manual gateway`() async {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let manualID = "manual|127.0.0.1|1"
|
|
saveActiveManualGateway(host: "127.0.0.1", port: 1, useTLS: false, stableID: manualID)
|
|
_ = GatewaySettingsStore.markGatewayConnected(stableID: manualID, atMs: 42)
|
|
let discoveredID = "bonjour|missing"
|
|
_ = GatewaySettingsStore.upsertGatewayRegistryEntry(.init(
|
|
stableID: discoveredID,
|
|
kind: .discovered,
|
|
name: "Missing Gateway",
|
|
host: nil,
|
|
port: nil,
|
|
useTLS: true,
|
|
lastConnectedAtMs: 84))
|
|
_ = GatewaySettingsStore.setActiveGateway(stableID: discoveredID)
|
|
|
|
await withUserDefaults([
|
|
"gateway.autoconnect": true,
|
|
"gateway.manual.enabled": false,
|
|
"node.instanceId": "ios-test",
|
|
]) {
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
|
|
controller._test_triggerAutoConnect()
|
|
|
|
#expect(controller._test_didAutoConnect())
|
|
await waitForActiveGateway(stableID: manualID, appModel: appModel)
|
|
#expect(appModel.activeGatewayConnectConfig?.effectiveStableID == manualID)
|
|
#expect(GatewaySettingsStore.activeGatewayEntry()?.stableID == manualID)
|
|
}
|
|
}
|
|
|
|
@Test @MainActor func `forget gateway clears matching share relay only`() {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let priorRelay = ShareGatewayRelaySettings.loadConfig()
|
|
defer {
|
|
if let priorRelay {
|
|
ShareGatewayRelaySettings.saveConfig(priorRelay)
|
|
} else {
|
|
ShareGatewayRelaySettings.clearConfig()
|
|
}
|
|
}
|
|
let stableID = "manual|forgotten.example.com|443"
|
|
ShareGatewayRelaySettings.saveConfig(.init(
|
|
gatewayURLString: "wss://forgotten.example.com",
|
|
gatewayStableID: stableID,
|
|
token: "relay-token",
|
|
password: nil,
|
|
sessionKey: "main"))
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
|
|
controller.forgetGateway(stableID: stableID)
|
|
|
|
#expect(ShareGatewayRelaySettings.loadConfig() == nil)
|
|
}
|
|
|
|
@Test @MainActor func `forget manual gateway clears matching legacy auto connect defaults`() {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let host = "forgotten.example.com"
|
|
let port = 443
|
|
let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID(host: host, port: port)
|
|
withUserDefaults([
|
|
"gateway.manual.enabled": true,
|
|
"gateway.manual.host": host,
|
|
"gateway.manual.port": port,
|
|
"gateway.manual.tls": true,
|
|
]) {
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
|
|
controller.forgetGateway(stableID: stableID)
|
|
|
|
let defaults = UserDefaults.standard
|
|
#expect(!defaults.bool(forKey: "gateway.manual.enabled"))
|
|
#expect(defaults.object(forKey: "gateway.manual.host") == nil)
|
|
#expect(defaults.object(forKey: "gateway.manual.port") == nil)
|
|
#expect(defaults.object(forKey: "gateway.manual.tls") == nil)
|
|
}
|
|
}
|
|
|
|
@Test @MainActor func `forget gateway preserves legacy defaults for another manual gateway`() {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
withUserDefaults([
|
|
"gateway.manual.enabled": true,
|
|
"gateway.manual.host": "kept.example.com",
|
|
"gateway.manual.port": 443,
|
|
"gateway.manual.tls": true,
|
|
]) {
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
|
|
controller.forgetGateway(stableID: "manual|forgotten.example.com|443")
|
|
|
|
let defaults = UserDefaults.standard
|
|
#expect(defaults.bool(forKey: "gateway.manual.enabled"))
|
|
#expect(defaults.string(forKey: "gateway.manual.host") == "kept.example.com")
|
|
#expect(defaults.integer(forKey: "gateway.manual.port") == 443)
|
|
#expect(defaults.bool(forKey: "gateway.manual.tls"))
|
|
}
|
|
}
|
|
|
|
@Test @MainActor func `forget connected gateway disconnects and repeats device auth cleanup`() async throws {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let service = GatewaySettingsStore._testGatewayService
|
|
let account = "gateway-registry"
|
|
let prior = KeychainStore.loadString(service: service, account: account)
|
|
defer {
|
|
if let prior {
|
|
_ = KeychainStore.saveString(prior, service: service, account: account)
|
|
} else {
|
|
_ = KeychainStore.delete(service: service, account: account)
|
|
}
|
|
}
|
|
_ = KeychainStore.delete(service: service, account: account)
|
|
let connectedID = "manual|connected.example.com|443"
|
|
let selectedID = "manual|selected.example.com|443"
|
|
saveActiveManualGateway(host: "selected.example.com", port: 443, useTLS: true, stableID: selectedID)
|
|
let appModel = NodeAppModel()
|
|
try appModel.applyGatewayConnectConfig(Self.makeGatewayConnectConfig(
|
|
url: #require(URL(string: "wss://connected.example.com")),
|
|
stableID: connectedID))
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
let identity = DeviceIdentityStore.loadOrCreate()
|
|
defer { DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: "node", gatewayID: connectedID) }
|
|
|
|
controller.forgetGateway(stableID: connectedID)
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: identity.deviceId,
|
|
role: "node",
|
|
token: "late-handshake-token",
|
|
gatewayID: connectedID)
|
|
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
|
|
while DeviceAuthStore.loadToken(
|
|
deviceId: identity.deviceId,
|
|
role: "node",
|
|
gatewayID: connectedID) != nil,
|
|
ContinuousClock().now < deadline
|
|
{
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
|
|
#expect(appModel.activeGatewayConnectConfig == nil)
|
|
#expect(GatewaySettingsStore.activeGatewayEntry()?.stableID == selectedID)
|
|
#expect(DeviceAuthStore.loadToken(
|
|
deviceId: identity.deviceId,
|
|
role: "node",
|
|
gatewayID: connectedID) == nil)
|
|
}
|
|
|
|
@Test @MainActor func `forget selected gateway preserves a different live route`() throws {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let service = GatewaySettingsStore._testGatewayService
|
|
let account = "gateway-registry"
|
|
let prior = KeychainStore.loadString(service: service, account: account)
|
|
defer {
|
|
if let prior {
|
|
_ = KeychainStore.saveString(prior, service: service, account: account)
|
|
} else {
|
|
_ = KeychainStore.delete(service: service, account: account)
|
|
}
|
|
}
|
|
_ = KeychainStore.delete(service: service, account: account)
|
|
let selectedID = "manual|selected.example.com|443"
|
|
let connectedID = "manual|connected.example.com|443"
|
|
saveActiveManualGateway(host: "selected.example.com", port: 443, useTLS: true, stableID: selectedID)
|
|
_ = GatewaySettingsStore.upsertGatewayRegistryEntry(.init(
|
|
stableID: connectedID,
|
|
kind: .manual,
|
|
name: "connected.example.com:443",
|
|
host: "connected.example.com",
|
|
port: 443,
|
|
useTLS: true,
|
|
lastConnectedAtMs: nil))
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let connectedConfig = try Self.makeGatewayConnectConfig(
|
|
url: #require(URL(string: "wss://connected.example.com")),
|
|
stableID: connectedID)
|
|
appModel.applyGatewayConnectConfig(connectedConfig)
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
|
|
controller.forgetGateway(stableID: selectedID)
|
|
|
|
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: connectedConfig) == true)
|
|
#expect(GatewaySettingsStore.activeGatewayEntry() == nil)
|
|
#expect(GatewaySettingsStore.loadGatewayRegistry().entries.map(\.stableID) == [connectedID])
|
|
}
|
|
|
|
@Test @MainActor func `forget gateway clears only matching legacy discovery selectors`() {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let service = GatewaySettingsStore._testGatewayService
|
|
let preferredAccount = "preferredStableID"
|
|
let lastAccount = "lastDiscoveredStableID"
|
|
let priorPreferred = KeychainStore.loadString(service: service, account: preferredAccount)
|
|
let priorLast = KeychainStore.loadString(service: service, account: lastAccount)
|
|
defer {
|
|
if let priorPreferred {
|
|
_ = KeychainStore.saveString(priorPreferred, service: service, account: preferredAccount)
|
|
} else {
|
|
_ = KeychainStore.delete(service: service, account: preferredAccount)
|
|
}
|
|
if let priorLast {
|
|
_ = KeychainStore.saveString(priorLast, service: service, account: lastAccount)
|
|
} else {
|
|
_ = KeychainStore.delete(service: service, account: lastAccount)
|
|
}
|
|
}
|
|
let forgottenID = "bonjour|forgotten"
|
|
let keptID = "bonjour|kept"
|
|
_ = KeychainStore.saveString(forgottenID, service: service, account: preferredAccount)
|
|
_ = KeychainStore.saveString(keptID, service: service, account: lastAccount)
|
|
|
|
withUserDefaults([
|
|
"gateway.preferredStableID": forgottenID,
|
|
"gateway.lastDiscoveredStableID": keptID,
|
|
]) {
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
|
|
controller.forgetGateway(stableID: forgottenID)
|
|
|
|
let defaults = UserDefaults.standard
|
|
#expect(defaults.object(forKey: "gateway.preferredStableID") == nil)
|
|
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == keptID)
|
|
#expect(KeychainStore.loadString(service: service, account: preferredAccount) == nil)
|
|
#expect(KeychainStore.loadString(service: service, account: lastAccount) == keptID)
|
|
}
|
|
}
|
|
|
|
@Test @MainActor func `forget gateway cancels its pending trust handoff`() async {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let service = GatewaySettingsStore._testGatewayService
|
|
let account = "gateway-registry"
|
|
let prior = KeychainStore.loadString(service: service, account: account)
|
|
defer {
|
|
if let prior {
|
|
_ = KeychainStore.saveString(prior, service: service, account: account)
|
|
} else {
|
|
_ = KeychainStore.delete(service: service, account: account)
|
|
}
|
|
}
|
|
_ = KeychainStore.delete(service: service, account: account)
|
|
let host = "pending-trust.example.com"
|
|
let port = 443
|
|
let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID(host: host, port: port)
|
|
defer { GatewayTLSStore.clearFingerprint(stableID: stableID) }
|
|
GatewayTLSStore.clearFingerprint(stableID: stableID)
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let controller = GatewayConnectionController(
|
|
appModel: appModel,
|
|
startDiscovery: false,
|
|
tcpReachabilityProbe: { _, _, _, _ in true },
|
|
tlsFingerprintProbe: { _ in .fingerprint("forgotten-fingerprint") })
|
|
|
|
await controller.connectManual(host: host, port: port, useTLS: true)
|
|
#expect(controller.pendingTrustPrompt?.stableID == stableID)
|
|
controller.forgetGateway(stableID: stableID)
|
|
await controller.acceptPendingTrustPrompt()
|
|
|
|
#expect(controller.pendingTrustPrompt == nil)
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == nil)
|
|
#expect(!GatewaySettingsStore.loadGatewayRegistry().entries.contains { $0.stableID == stableID })
|
|
}
|
|
|
|
@Test @MainActor func `forget gateway preserves another gateway pending trust handoff`() async {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let pendingHost = "pending-kept.example.com"
|
|
let pendingID = GatewayConnectionController.ManualAuthOverride.manualStableID(
|
|
host: pendingHost,
|
|
port: 443)
|
|
defer { GatewayTLSStore.clearFingerprint(stableID: pendingID) }
|
|
GatewayTLSStore.clearFingerprint(stableID: pendingID)
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let controller = GatewayConnectionController(
|
|
appModel: appModel,
|
|
startDiscovery: false,
|
|
tcpReachabilityProbe: { _, _, _, _ in true },
|
|
tlsFingerprintProbe: { _ in .fingerprint("kept-fingerprint") })
|
|
|
|
await controller.connectManual(host: pendingHost, port: 443, useTLS: true)
|
|
#expect(controller.pendingTrustPrompt?.stableID == pendingID)
|
|
controller.forgetGateway(stableID: "bonjour|unrelated-forgotten")
|
|
|
|
#expect(controller.pendingTrustPrompt?.stableID == pendingID)
|
|
}
|
|
|
|
@Test @MainActor func `forget live gateway preserves replacement pending trust generation`() async throws {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let connectedID = "manual|connected-before-forget.example.com|443"
|
|
let replacementHost = "replacement-after-forget.example.com"
|
|
let replacementID = GatewayConnectionController.ManualAuthOverride.manualStableID(
|
|
host: replacementHost,
|
|
port: 443)
|
|
defer { GatewayTLSStore.clearFingerprint(stableID: replacementID) }
|
|
GatewayTLSStore.clearFingerprint(stableID: replacementID)
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
try appModel.applyGatewayConnectConfig(Self.makeGatewayConnectConfig(
|
|
url: #require(URL(string: "wss://connected-before-forget.example.com")),
|
|
stableID: connectedID))
|
|
let controller = GatewayConnectionController(
|
|
appModel: appModel,
|
|
startDiscovery: false,
|
|
tcpReachabilityProbe: { _, _, _, _ in true },
|
|
tlsFingerprintProbe: { _ in .fingerprint("replacement-fingerprint") })
|
|
|
|
await controller.connectManual(host: replacementHost, port: 443, useTLS: true)
|
|
#expect(controller.pendingTrustPrompt?.stableID == replacementID)
|
|
controller.forgetGateway(stableID: connectedID)
|
|
await controller.acceptPendingTrustPrompt()
|
|
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
|
|
while appModel.activeGatewayConnectConfig?.effectiveStableID != replacementID,
|
|
ContinuousClock().now < deadline
|
|
{
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
|
|
#expect(appModel.activeGatewayConnectConfig?.effectiveStableID == replacementID)
|
|
}
|
|
|
|
@Test @MainActor func `stale cancellation lease cannot release newer suppression`() {
|
|
let appModel = NodeAppModel()
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
|
|
let staleLease = controller.cancelPendingConnectionAttempts()
|
|
let currentLease = controller.cancelPendingConnectionAttempts()
|
|
|
|
controller.resumeAutoConnect(after: staleLease)
|
|
#expect(controller._test_isAutoConnectSuppressed())
|
|
|
|
controller.resumeAutoConnect(after: currentLease)
|
|
#expect(!controller._test_isAutoConnectSuppressed())
|
|
}
|
|
|
|
@Test @MainActor func `cancellation lease restores previous auto connect state`() {
|
|
withUserDefaults([
|
|
"gateway.autoconnect": true,
|
|
"gateway.manual.enabled": false,
|
|
"gateway.preferredStableID": nil,
|
|
"gateway.lastDiscoveredStableID": nil,
|
|
]) {
|
|
let appModel = NodeAppModel()
|
|
appModel.gatewayAutoReconnectEnabled = true
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
let scannerLease = controller.cancelPendingConnectionAttempts(suspendCurrentGateway: true)
|
|
|
|
#expect(!appModel.gatewayAutoReconnectEnabled)
|
|
#expect(UserDefaults.standard.bool(forKey: "gateway.autoconnect"))
|
|
|
|
let replacementLease = controller.cancelPendingConnectionAttempts()
|
|
controller.resumeAutoConnect(after: scannerLease)
|
|
#expect(!appModel.gatewayAutoReconnectEnabled)
|
|
|
|
controller.resumeAutoConnect(after: replacementLease)
|
|
#expect(appModel.gatewayAutoReconnectEnabled)
|
|
#expect(UserDefaults.standard.bool(forKey: "gateway.autoconnect"))
|
|
}
|
|
}
|
|
|
|
@Test @MainActor func `completed target switch does not restore auto connect state`() {
|
|
withUserDefaults(["gateway.autoconnect": true]) {
|
|
let appModel = NodeAppModel()
|
|
appModel.gatewayAutoReconnectEnabled = true
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
let lease = controller.cancelPendingConnectionAttempts(suspendCurrentGateway: true)
|
|
|
|
controller.releaseAutoConnectSuppression(after: lease)
|
|
|
|
#expect(!controller._test_isAutoConnectSuppressed())
|
|
#expect(!appModel.gatewayAutoReconnectEnabled)
|
|
#expect(UserDefaults.standard.bool(forKey: "gateway.autoconnect"))
|
|
}
|
|
}
|
|
|
|
@Test @MainActor func `auto connect choice made during target review wins`() throws {
|
|
try withUserDefaults(["gateway.autoconnect": true]) {
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let suspendedConfig = try Self.makeGatewayConnectConfig(
|
|
url: #require(URL(string: "wss://127.0.0.1:1")),
|
|
stableID: "manual|127.0.0.1|1")
|
|
appModel.applyGatewayConnectConfig(suspendedConfig)
|
|
appModel.gatewayAutoReconnectEnabled = true
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
let lease = controller.cancelPendingConnectionAttempts(suspendCurrentGateway: true)
|
|
|
|
UserDefaults.standard.set(false, forKey: "gateway.autoconnect")
|
|
controller.resumeAutoConnect(after: lease)
|
|
|
|
#expect(!appModel.gatewayAutoReconnectEnabled)
|
|
#expect(appModel.activeGatewayConnectConfig == nil)
|
|
#expect(!UserDefaults.standard.bool(forKey: "gateway.autoconnect"))
|
|
}
|
|
}
|
|
|
|
@Test @MainActor func `failed replacement restores inherited scanner reconnect state`() async throws {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let defaults = UserDefaults.standard
|
|
let updates: [String: Any?] = [
|
|
"gateway.autoconnect": true,
|
|
"gateway.manual.enabled": false,
|
|
"node.instanceId": "ios-test",
|
|
"gateway.preferredStableID": nil,
|
|
"gateway.lastDiscoveredStableID": nil,
|
|
]
|
|
var snapshot: [String: Any?] = [:]
|
|
for key in updates.keys {
|
|
snapshot[key] = defaults.object(forKey: key)
|
|
}
|
|
for (key, value) in updates {
|
|
if let value {
|
|
defaults.set(value, forKey: key)
|
|
} else {
|
|
defaults.removeObject(forKey: key)
|
|
}
|
|
}
|
|
defer {
|
|
for (key, value) in snapshot {
|
|
if let value {
|
|
defaults.set(value, forKey: key)
|
|
} else {
|
|
defaults.removeObject(forKey: key)
|
|
}
|
|
}
|
|
}
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let suspendedConfig = try Self.makeGatewayConnectConfig(
|
|
url: #require(URL(string: "ws://127.0.0.1:1")),
|
|
stableID: "manual|127.0.0.1|1")
|
|
appModel.applyGatewayConnectConfig(suspendedConfig)
|
|
appModel.gatewayAutoReconnectEnabled = true
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
_ = controller.cancelPendingConnectionAttempts(suspendCurrentGateway: true)
|
|
|
|
await controller.connectManual(host: "invalid.example.com", port: 70000, useTLS: true)
|
|
|
|
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
|
|
while appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: suspendedConfig) != true,
|
|
ContinuousClock().now < deadline
|
|
{
|
|
await Task.yield()
|
|
}
|
|
#expect(!controller._test_isAutoConnectSuppressed())
|
|
#expect(appModel.gatewayAutoReconnectEnabled)
|
|
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: suspendedConfig) == true)
|
|
#expect(defaults.bool(forKey: "gateway.autoconnect"))
|
|
}
|
|
|
|
@Test @MainActor func `foreground reconnect cannot replace queued explicit handoff`() async {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let defaults = UserDefaults.standard
|
|
let updates: [String: Any?] = [
|
|
"gateway.autoconnect": false,
|
|
"gateway.manual.enabled": true,
|
|
"gateway.manual.host": "192.168.1.20",
|
|
"gateway.manual.port": 18789,
|
|
"gateway.manual.tls": false,
|
|
"node.instanceId": "ios-test",
|
|
]
|
|
var snapshot: [String: Any?] = [:]
|
|
for key in updates.keys {
|
|
snapshot[key] = defaults.object(forKey: key)
|
|
}
|
|
for (key, value) in updates {
|
|
defaults.set(value, forKey: key)
|
|
}
|
|
defer {
|
|
for (key, value) in snapshot {
|
|
if let value {
|
|
defaults.set(value, forKey: key)
|
|
} else {
|
|
defaults.removeObject(forKey: key)
|
|
}
|
|
}
|
|
}
|
|
|
|
let resetRelease = AsyncStream<Void>.makeStream()
|
|
let appModel = NodeAppModel()
|
|
defer {
|
|
resetRelease.continuation.finish()
|
|
appModel._test_setGatewaySessionResetTask(nil)
|
|
appModel.disconnectGateway()
|
|
}
|
|
let modelResetTask = Task {
|
|
for await _ in resetRelease.stream {
|
|
return
|
|
}
|
|
}
|
|
appModel._test_setGatewaySessionResetTask(modelResetTask)
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
defaults.set(true, forKey: "gateway.autoconnect")
|
|
|
|
let explicitStableID = "manual|192.168.1.41|18789"
|
|
await controller.connectManual(host: "192.168.1.41", port: 18789, useTLS: false)
|
|
#expect(appModel.activeGatewayConnectConfig == nil)
|
|
|
|
controller._test_triggerAutoReconnect()
|
|
for _ in 0..<10 {
|
|
await Task.yield()
|
|
}
|
|
#expect(controller._test_didAutoConnect())
|
|
#expect(appModel.activeGatewayConnectConfig == nil)
|
|
|
|
resetRelease.continuation.yield()
|
|
resetRelease.continuation.finish()
|
|
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
|
|
while appModel.activeGatewayConnectConfig?.stableID != explicitStableID,
|
|
ContinuousClock().now < deadline
|
|
{
|
|
await Task.yield()
|
|
}
|
|
|
|
#expect(appModel.activeGatewayConnectConfig?.stableID == explicitStableID)
|
|
controller._test_triggerAutoConnect()
|
|
#expect(controller._test_didAutoConnect())
|
|
#expect(appModel.activeGatewayConnectConfig?.stableID == explicitStableID)
|
|
}
|
|
|
|
@Test @MainActor func `clearing trust prompt invalidates in flight probe`() async {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let probeStarted = AsyncStream<Void>.makeStream()
|
|
let probeResults = AsyncStream<GatewayTLSFingerprintProbeResult>.makeStream()
|
|
let appModel = NodeAppModel()
|
|
let controller = GatewayConnectionController(
|
|
appModel: appModel,
|
|
startDiscovery: false,
|
|
tcpReachabilityProbe: { _, _, _, _ in true },
|
|
tlsFingerprintProbe: { _ in
|
|
probeStarted.continuation.yield()
|
|
for await result in probeResults.stream {
|
|
return result
|
|
}
|
|
return .failure(.certificateUnavailable)
|
|
})
|
|
var startedIterator = probeStarted.stream.makeAsyncIterator()
|
|
|
|
let connectTask = Task {
|
|
await controller.connectManual(host: "trust-probe-cancel.invalid", port: 443, useTLS: true)
|
|
}
|
|
_ = await startedIterator.next()
|
|
controller.clearPendingTrustPrompt()
|
|
probeResults.continuation.yield(.fingerprint("stale-fingerprint"))
|
|
probeResults.continuation.finish()
|
|
await connectTask.value
|
|
|
|
#expect(controller.pendingTrustPrompt == nil)
|
|
}
|
|
|
|
@Test @MainActor func `foreground stale connection restart reapplies active gateway config`() async {
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
|
|
let config = Self.makeGatewayConnectConfig()
|
|
appModel.applyGatewayConnectConfig(config)
|
|
await appModel._test_restartGatewaySessionsAfterForegroundStaleConnection()
|
|
|
|
#expect(appModel.gatewayStatusText == "Reconnecting…")
|
|
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: config) == true)
|
|
#expect(appModel._test_hasGatewayLoopTasks().node)
|
|
#expect(appModel._test_hasGatewayLoopTasks().operator)
|
|
}
|
|
|
|
@Test @MainActor func `switch to manual gateway applies its stable I D and URL`() async {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let service = GatewaySettingsStore._testGatewayService
|
|
let account = "gateway-registry"
|
|
let prior = KeychainStore.loadString(service: service, account: account)
|
|
defer {
|
|
if let prior {
|
|
_ = KeychainStore.saveString(prior, service: service, account: account)
|
|
} else {
|
|
_ = KeychainStore.delete(service: service, account: account)
|
|
}
|
|
}
|
|
_ = KeychainStore.delete(service: service, account: account)
|
|
let stableID = "manual|127.0.0.1|1"
|
|
saveActiveManualGateway(host: "127.0.0.1", port: 1, useTLS: false, stableID: stableID)
|
|
let appModel = NodeAppModel()
|
|
defer { appModel.disconnectGateway() }
|
|
let controller = GatewayConnectionController(
|
|
appModel: appModel,
|
|
startDiscovery: false,
|
|
forceReconnectReset: { _ in })
|
|
|
|
let failure = await controller.switchToGateway(stableID: stableID)
|
|
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
|
|
while appModel.activeGatewayConnectConfig == nil, ContinuousClock().now < deadline {
|
|
await Task.yield()
|
|
}
|
|
|
|
#expect(failure == nil)
|
|
#expect(appModel.activeGatewayConnectConfig?.effectiveStableID == stableID)
|
|
#expect(appModel.activeGatewayConnectConfig?.url == URL(string: "ws://127.0.0.1:1"))
|
|
#expect(GatewaySettingsStore.activeGatewayEntry()?.stableID == stableID)
|
|
}
|
|
|
|
@Test @MainActor func `switch to undiscoverable gateway returns failure without changing active gateway`() async {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let service = GatewaySettingsStore._testGatewayService
|
|
let account = "gateway-registry"
|
|
let prior = KeychainStore.loadString(service: service, account: account)
|
|
defer {
|
|
if let prior {
|
|
_ = KeychainStore.saveString(prior, service: service, account: account)
|
|
} else {
|
|
_ = KeychainStore.delete(service: service, account: account)
|
|
}
|
|
}
|
|
_ = KeychainStore.delete(service: service, account: account)
|
|
let activeID = "manual|127.0.0.1|1"
|
|
let discoveredID = "bonjour|missing"
|
|
saveActiveManualGateway(host: "127.0.0.1", port: 1, useTLS: false, stableID: activeID)
|
|
_ = GatewaySettingsStore.upsertGatewayRegistryEntry(.init(
|
|
stableID: discoveredID,
|
|
kind: .discovered,
|
|
name: "Kitchen Gateway",
|
|
host: nil,
|
|
port: nil,
|
|
useTLS: true,
|
|
lastConnectedAtMs: nil))
|
|
let appModel = NodeAppModel()
|
|
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
|
|
|
let failure = await controller.switchToGateway(stableID: discoveredID)
|
|
|
|
#expect(failure == "Kitchen Gateway is not currently discoverable on this network.")
|
|
#expect(GatewaySettingsStore.activeGatewayEntry()?.stableID == activeID)
|
|
#expect(appModel.activeGatewayConnectConfig == nil)
|
|
}
|
|
|
|
@Test @MainActor func `chat cache remains isolated when active gateway switches`() async throws {
|
|
let registryIsolation = GatewayRegistryTestIsolation()
|
|
defer { registryIsolation.restore() }
|
|
let service = GatewaySettingsStore._testGatewayService
|
|
let account = "gateway-registry"
|
|
let prior = KeychainStore.loadString(service: service, account: account)
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
|
defer {
|
|
if let prior {
|
|
_ = KeychainStore.saveString(prior, service: service, account: account)
|
|
} else {
|
|
_ = KeychainStore.delete(service: service, account: account)
|
|
}
|
|
if let previousStateDir {
|
|
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
|
} else {
|
|
unsetenv("OPENCLAW_STATE_DIR")
|
|
}
|
|
try? FileManager.default.removeItem(at: tempDir)
|
|
}
|
|
_ = KeychainStore.delete(service: service, account: account)
|
|
let gatewayA = "manual|gateway-a|18789"
|
|
let gatewayB = "manual|gateway-b|18789"
|
|
saveActiveManualGateway(host: "gateway-a", port: 18789, useTLS: false, stableID: gatewayA)
|
|
_ = GatewaySettingsStore.upsertGatewayRegistryEntry(.init(
|
|
stableID: gatewayB,
|
|
kind: .manual,
|
|
name: "Gateway B",
|
|
host: "gateway-b",
|
|
port: 18789,
|
|
useTLS: false,
|
|
lastConnectedAtMs: nil))
|
|
let appModel = NodeAppModel()
|
|
let session = OpenClawChatSessionEntry(
|
|
key: "agent:main:a",
|
|
kind: nil,
|
|
displayName: "Gateway A session",
|
|
surface: nil,
|
|
subject: nil,
|
|
room: nil,
|
|
space: nil,
|
|
updatedAt: 1,
|
|
sessionId: nil,
|
|
systemSent: nil,
|
|
abortedLastRun: nil,
|
|
thinkingLevel: nil,
|
|
verboseLevel: nil,
|
|
inputTokens: nil,
|
|
outputTokens: nil,
|
|
totalTokens: nil,
|
|
modelProvider: nil,
|
|
model: nil,
|
|
contextTokens: nil)
|
|
|
|
await appModel.storeCachedChatSessions([session])
|
|
_ = GatewaySettingsStore.setActiveGateway(stableID: gatewayB)
|
|
#expect(await appModel.loadCachedChatSessions().isEmpty)
|
|
_ = GatewaySettingsStore.setActiveGateway(stableID: gatewayA)
|
|
#expect(await appModel.loadCachedChatSessions() == [session])
|
|
}
|
|
|
|
private static func makeGatewayConnectConfig(
|
|
// Fail locally instead of making lifecycle tests depend on DNS or external network timing.
|
|
url: URL = URL(string: "wss://127.0.0.1:1")!,
|
|
stableID: String = "manual|gateway.example.com|443") -> GatewayConnectConfig
|
|
{
|
|
GatewayConnectConfig(
|
|
url: url,
|
|
stableID: stableID,
|
|
tls: GatewayTLSParams(
|
|
required: true,
|
|
expectedFingerprint: "abc",
|
|
allowTOFU: false,
|
|
storeKey: stableID),
|
|
token: "token",
|
|
bootstrapToken: nil,
|
|
password: nil,
|
|
nodeOptions: GatewayConnectOptions(
|
|
role: "node",
|
|
scopes: [],
|
|
caps: ["screen", "canvas"],
|
|
commands: ["notify", "location.get"],
|
|
permissions: ["screen": true],
|
|
clientId: "ios",
|
|
clientMode: "node",
|
|
clientDisplayName: "Phone"))
|
|
}
|
|
}
|