macOS/onboarding: prompt for remote gateway auth tokens (#43100)
Merged via squash. Prepared head SHA: 00e2ad847b5a47c34e72e9df1574c0d069b7c671 Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Reviewed-by: @ngutman
This commit is contained in:
@@ -7,6 +7,11 @@ struct GatewayChannelConnectTests {
|
||||
private enum FakeResponse {
|
||||
case helloOk(delayMs: Int)
|
||||
case invalid(delayMs: Int)
|
||||
case authFailed(
|
||||
delayMs: Int,
|
||||
detailCode: String,
|
||||
canRetryWithDeviceToken: Bool,
|
||||
recommendedNextStep: String?)
|
||||
}
|
||||
|
||||
private func makeSession(response: FakeResponse) -> GatewayTestWebSocketSession {
|
||||
@@ -27,6 +32,14 @@ struct GatewayChannelConnectTests {
|
||||
case let .invalid(ms):
|
||||
delayMs = ms
|
||||
message = .string("not json")
|
||||
case let .authFailed(ms, detailCode, canRetryWithDeviceToken, recommendedNextStep):
|
||||
delayMs = ms
|
||||
let id = task.snapshotConnectRequestID() ?? "connect"
|
||||
message = .data(GatewayWebSocketTestSupport.connectAuthFailureData(
|
||||
id: id,
|
||||
detailCode: detailCode,
|
||||
canRetryWithDeviceToken: canRetryWithDeviceToken,
|
||||
recommendedNextStep: recommendedNextStep))
|
||||
}
|
||||
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||
return message
|
||||
@@ -71,4 +84,29 @@ struct GatewayChannelConnectTests {
|
||||
}())
|
||||
#expect(session.snapshotMakeCount() == 1)
|
||||
}
|
||||
|
||||
@Test func `connect surfaces structured auth failure`() async throws {
|
||||
let session = self.makeSession(response: .authFailed(
|
||||
delayMs: 0,
|
||||
detailCode: GatewayConnectAuthDetailCode.authTokenMissing.rawValue,
|
||||
canRetryWithDeviceToken: true,
|
||||
recommendedNextStep: GatewayConnectRecoveryNextStep.updateAuthConfiguration.rawValue))
|
||||
let channel = try GatewayChannelActor(
|
||||
url: #require(URL(string: "ws://example.invalid")),
|
||||
token: nil,
|
||||
session: WebSocketSessionBox(session: session))
|
||||
|
||||
do {
|
||||
try await channel.connect()
|
||||
Issue.record("expected GatewayConnectAuthError")
|
||||
} catch let error as GatewayConnectAuthError {
|
||||
#expect(error.detail == .authTokenMissing)
|
||||
#expect(error.detailCode == GatewayConnectAuthDetailCode.authTokenMissing.rawValue)
|
||||
#expect(error.canRetryWithDeviceToken)
|
||||
#expect(error.recommendedNextStep == .updateAuthConfiguration)
|
||||
#expect(error.recommendedNextStepCode == GatewayConnectRecoveryNextStep.updateAuthConfiguration.rawValue)
|
||||
} catch {
|
||||
Issue.record("unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,40 @@ enum GatewayWebSocketTestSupport {
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
static func connectAuthFailureData(
|
||||
id: String,
|
||||
detailCode: String,
|
||||
message: String = "gateway auth rejected",
|
||||
canRetryWithDeviceToken: Bool = false,
|
||||
recommendedNextStep: String? = nil) -> Data
|
||||
{
|
||||
let recommendedNextStepJson: String
|
||||
if let recommendedNextStep {
|
||||
recommendedNextStepJson = """
|
||||
,
|
||||
"recommendedNextStep": "\(recommendedNextStep)"
|
||||
"""
|
||||
} else {
|
||||
recommendedNextStepJson = ""
|
||||
}
|
||||
let json = """
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": false,
|
||||
"error": {
|
||||
"message": "\(message)",
|
||||
"details": {
|
||||
"code": "\(detailCode)",
|
||||
"canRetryWithDeviceToken": \(canRetryWithDeviceToken ? "true" : "false")
|
||||
\(recommendedNextStepJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
static func requestID(from message: URLSessionWebSocketTask.Message) -> String? {
|
||||
guard let obj = self.requestFrameObject(from: message) else { return nil }
|
||||
guard (obj["type"] as? String) == "req" else {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@MainActor
|
||||
struct OnboardingRemoteAuthPromptTests {
|
||||
@Test func `auth detail codes map to remote auth issues`() {
|
||||
let tokenMissing = GatewayConnectAuthError(
|
||||
message: "token missing",
|
||||
detailCode: GatewayConnectAuthDetailCode.authTokenMissing.rawValue,
|
||||
canRetryWithDeviceToken: false)
|
||||
let tokenMismatch = GatewayConnectAuthError(
|
||||
message: "token mismatch",
|
||||
detailCode: GatewayConnectAuthDetailCode.authTokenMismatch.rawValue,
|
||||
canRetryWithDeviceToken: false)
|
||||
let tokenNotConfigured = GatewayConnectAuthError(
|
||||
message: "token not configured",
|
||||
detailCode: GatewayConnectAuthDetailCode.authTokenNotConfigured.rawValue,
|
||||
canRetryWithDeviceToken: false)
|
||||
let passwordMissing = GatewayConnectAuthError(
|
||||
message: "password missing",
|
||||
detailCode: GatewayConnectAuthDetailCode.authPasswordMissing.rawValue,
|
||||
canRetryWithDeviceToken: false)
|
||||
let pairingRequired = GatewayConnectAuthError(
|
||||
message: "pairing required",
|
||||
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
|
||||
canRetryWithDeviceToken: false)
|
||||
let unknown = GatewayConnectAuthError(
|
||||
message: "other",
|
||||
detailCode: "SOMETHING_ELSE",
|
||||
canRetryWithDeviceToken: false)
|
||||
|
||||
#expect(RemoteGatewayAuthIssue(error: tokenMissing) == .tokenRequired)
|
||||
#expect(RemoteGatewayAuthIssue(error: tokenMismatch) == .tokenMismatch)
|
||||
#expect(RemoteGatewayAuthIssue(error: tokenNotConfigured) == .gatewayTokenNotConfigured)
|
||||
#expect(RemoteGatewayAuthIssue(error: passwordMissing) == .passwordRequired)
|
||||
#expect(RemoteGatewayAuthIssue(error: pairingRequired) == .pairingRequired)
|
||||
#expect(RemoteGatewayAuthIssue(error: unknown) == nil)
|
||||
}
|
||||
|
||||
@Test func `password detail family maps to password required issue`() {
|
||||
let mismatch = GatewayConnectAuthError(
|
||||
message: "password mismatch",
|
||||
detailCode: GatewayConnectAuthDetailCode.authPasswordMismatch.rawValue,
|
||||
canRetryWithDeviceToken: false)
|
||||
let notConfigured = GatewayConnectAuthError(
|
||||
message: "password not configured",
|
||||
detailCode: GatewayConnectAuthDetailCode.authPasswordNotConfigured.rawValue,
|
||||
canRetryWithDeviceToken: false)
|
||||
|
||||
#expect(RemoteGatewayAuthIssue(error: mismatch) == .passwordRequired)
|
||||
#expect(RemoteGatewayAuthIssue(error: notConfigured) == .passwordRequired)
|
||||
}
|
||||
|
||||
@Test func `token field visibility follows onboarding rules`() {
|
||||
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||
showAdvancedConnection: false,
|
||||
remoteToken: "",
|
||||
remoteTokenUnsupported: false,
|
||||
authIssue: nil) == false)
|
||||
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||
showAdvancedConnection: true,
|
||||
remoteToken: "",
|
||||
remoteTokenUnsupported: false,
|
||||
authIssue: nil))
|
||||
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||
showAdvancedConnection: false,
|
||||
remoteToken: "secret",
|
||||
remoteTokenUnsupported: false,
|
||||
authIssue: nil))
|
||||
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||
showAdvancedConnection: false,
|
||||
remoteToken: "",
|
||||
remoteTokenUnsupported: true,
|
||||
authIssue: nil))
|
||||
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||
showAdvancedConnection: false,
|
||||
remoteToken: "",
|
||||
remoteTokenUnsupported: false,
|
||||
authIssue: .tokenRequired))
|
||||
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||
showAdvancedConnection: false,
|
||||
remoteToken: "",
|
||||
remoteTokenUnsupported: false,
|
||||
authIssue: .tokenMismatch))
|
||||
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||
showAdvancedConnection: false,
|
||||
remoteToken: "",
|
||||
remoteTokenUnsupported: false,
|
||||
authIssue: .gatewayTokenNotConfigured) == false)
|
||||
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||
showAdvancedConnection: false,
|
||||
remoteToken: "",
|
||||
remoteTokenUnsupported: false,
|
||||
authIssue: .pairingRequired) == false)
|
||||
}
|
||||
|
||||
@Test func `pairing required copy points users to pair approve`() {
|
||||
let issue = RemoteGatewayAuthIssue.pairingRequired
|
||||
|
||||
#expect(issue.title == "This device needs pairing approval")
|
||||
#expect(issue.body.contains("`/pair approve`"))
|
||||
#expect(issue.statusMessage.contains("/pair approve"))
|
||||
#expect(issue.footnote?.contains("`openclaw devices approve`") == true)
|
||||
}
|
||||
|
||||
@Test func `paired device success copy explains auth source`() {
|
||||
let pairedDevice = RemoteGatewayProbeSuccess(authSource: .deviceToken)
|
||||
let sharedToken = RemoteGatewayProbeSuccess(authSource: .sharedToken)
|
||||
let noAuth = RemoteGatewayProbeSuccess(authSource: GatewayAuthSource.none)
|
||||
|
||||
#expect(pairedDevice.title == "Connected via paired device")
|
||||
#expect(pairedDevice.detail == "This Mac used a stored device token. New or unpaired devices may still need the gateway token.")
|
||||
#expect(sharedToken.title == "Connected with gateway token")
|
||||
#expect(sharedToken.detail == nil)
|
||||
#expect(noAuth.title == "Remote gateway ready")
|
||||
#expect(noAuth.detail == nil)
|
||||
}
|
||||
|
||||
@Test func `transient probe mode restore does not clear probe feedback`() {
|
||||
#expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .local, suppressReset: false))
|
||||
#expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .unconfigured, suppressReset: false))
|
||||
#expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .remote, suppressReset: false) == false)
|
||||
#expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .local, suppressReset: true) == false)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user