Files
openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayChannelDeviceTokenRetryTests.swift
2026-07-12 08:42:20 +01:00

223 lines
8.8 KiB
Swift

import CryptoKit
import Foundation
import Testing
@testable import OpenClawKit
extension NSLock {
fileprivate func withDeviceRetryLock<T>(_ body: () -> T) -> T {
self.lock()
defer { self.unlock() }
return body()
}
}
private final class ConnectAuthRecorder: @unchecked Sendable {
private let lock = NSLock()
private var auths: [[String: Any]] = []
func append(from message: URLSessionWebSocketTask.Message) {
guard let auth = Self.connectAuth(from: message) else { return }
self.lock.withDeviceRetryLock {
self.auths.append(auth)
}
}
func auth(at index: Int) -> [String: Any]? {
self.lock.withDeviceRetryLock {
guard self.auths.indices.contains(index) else { return nil }
return self.auths[index]
}
}
private static func connectAuth(from message: URLSessionWebSocketTask.Message) -> [String: Any]? {
let data: Data? = switch message {
case let .data(raw):
raw
case let .string(text):
Data(text.utf8)
@unknown default:
nil
}
guard let data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
json["type"] as? String == "req",
json["method"] as? String == "connect",
let params = json["params"] as? [String: Any],
let auth = params["auth"] as? [String: Any]
else {
return nil
}
return auth
}
}
private final class TrustedDeviceRetryGatewaySession: WebSocketSessioning, GatewayDeviceTokenRetryTrustProviding,
@unchecked Sendable {
let allowsDeviceTokenRetryAuth: Bool
private let lock = NSLock()
private let recorder: ConnectAuthRecorder
private var makeCount = 0
init(recorder: ConnectAuthRecorder, allowsDeviceTokenRetryAuth: Bool) {
self.recorder = recorder
self.allowsDeviceTokenRetryAuth = allowsDeviceTokenRetryAuth
}
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
self.makeWebSocketTask(request: URLRequest(url: url))
}
func makeWebSocketTask(request: URLRequest) -> WebSocketTaskBox {
_ = request
let attemptIndex = self.lock.withDeviceRetryLock { () -> Int in
let current = self.makeCount
self.makeCount += 1
return current
}
let recorder = self.recorder
let task = GatewayTestWebSocketTask(
sendHook: { _, message, sendIndex in
if sendIndex == 0 {
recorder.append(from: message)
}
},
receiveHook: { task, receiveIndex in
if receiveIndex == 0 {
return .data(GatewayWebSocketTestSupport.connectChallengeData())
}
let id = task.snapshotConnectRequestID() ?? "connect"
if attemptIndex == 0 {
return .data(GatewayWebSocketTestSupport.connectAuthFailureData(
id: id,
detailCode: GatewayConnectAuthDetailCode.authTokenMismatch.rawValue,
canRetryWithDeviceToken: true,
recommendedNextStep: GatewayConnectRecoveryNextStep.retryWithDeviceToken.rawValue))
}
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
})
return WebSocketTaskBox(task: task)
}
}
@Suite(.serialized)
struct GatewayChannelDeviceTokenRetryTests {
@Test func `device-token-only auth binding changes after token rotation`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
try await DeviceIdentityStore.withStateDirectory(tempDir) {
let identity = DeviceIdentityStore.loadOrCreate()
let key = SymmetricKey(size: .bits256)
let url = try #require(URL(string: "ws://example.invalid"))
let gatewayID = "device-token-binding-test"
func connectBinding() async throws -> GatewayAuthBinding {
let channel = GatewayChannelActor(
url: url,
token: nil,
authBindingKey: key,
session: WebSocketSessionBox(session: GatewayTestWebSocketSession(taskFactory: {
GatewayTestWebSocketTask()
})),
connectOptions: GatewayConnectOptions(
role: "operator",
scopes: GatewayChannelActor.defaultOperatorConnectScopes,
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-macos-test",
clientMode: "ui",
clientDisplayName: "OpenClaw Test",
deviceAuthGatewayID: gatewayID))
try await channel.connect()
let generation = try #require(await channel.currentConnectionGeneration())
let binding = try #require(await channel.authBinding(
ifCurrentConnectionGeneration: generation))
await channel.shutdown()
return binding
}
let firstToken = "first-device-token"
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: "operator",
token: firstToken,
gatewayID: gatewayID)
let first = try await connectBinding()
let replacementToken = "replacement-device-token"
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: "operator",
token: replacementToken,
gatewayID: gatewayID)
let replacement = try await connectBinding()
let firstFingerprint = try #require(first.credentialFingerprint)
let replacementFingerprint = try #require(replacement.credentialFingerprint)
#expect(first.source == .deviceToken)
#expect(replacement.source == .deviceToken)
#expect(firstFingerprint != replacementFingerprint)
#expect(!firstFingerprint.contains(firstToken))
#expect(!replacementFingerprint.contains(replacementToken))
}
}
@Test func `remote pinned TLS retries stale shared token with stored device token`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
try await DeviceIdentityStore.withStateDirectory(tempDir) {
let identity = DeviceIdentityStore.loadOrCreate()
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: "operator",
token: "stored-device-token")
let recorder = ConnectAuthRecorder()
let session = TrustedDeviceRetryGatewaySession(
recorder: recorder,
allowsDeviceTokenRetryAuth: true)
let options = GatewayConnectOptions(
role: "operator",
scopes: ["operator.read"],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios-test",
clientMode: "ui",
clientDisplayName: "iOS Test",
includeDeviceIdentity: true)
let channel = try GatewayChannelActor(
url: #require(URL(string: "wss://gateway.example.com")),
token: "stale-shared-token",
session: WebSocketSessionBox(session: session),
connectOptions: options)
do {
try await channel.connect()
Issue.record("expected stale shared-token connect to fail before device-token retry")
} catch let error as GatewayConnectAuthError {
#expect(error.detail == .authTokenMismatch)
}
try await channel.connect()
let firstAuth = try #require(recorder.auth(at: 0))
#expect(firstAuth["token"] as? String == "stale-shared-token")
#expect(firstAuth["deviceToken"] == nil)
let retryAuth = try #require(recorder.auth(at: 1))
#expect(retryAuth["token"] as? String == "stale-shared-token")
#expect(retryAuth["deviceToken"] as? String == "stored-device-token")
await channel.shutdown()
}
}
}