From 4125811de1f425dee04211827b58d5f76e2e5d2a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 13:01:02 +0000 Subject: [PATCH] test(connection): add coverage for DeviceTokenReceived bootstrap handoff Add four tests to GatewayConnectionManagerTests that cover the HandleDeviceTokenReceived path introduced in the recent pairing fix commits (bootstrap setup codes as node first, reconnect after device approval, complete node bootstrap handoff). New tests: - DeviceTokenReceived_NodeRole_ClearsBootstrapTokenFromRegistry - DeviceTokenReceived_OperatorRole_PreservesBootstrapToken - DeviceTokenReceived_NodeRole_WhenBootstrapAlreadyNull_Succeeds - DeviceTokenReceived_WithIdentityStore_PersistsToken Also adds SimulateDeviceTokenReceived helper to MockLifecycle and MockGatewayClient using the same reflection pattern as the existing SimulateHandshakeSucceeded helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GatewayConnectionManagerTests.cs | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs index 72f7369b1..a2eacec52 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs @@ -657,6 +657,9 @@ public void SimulateHandshake() => public void SimulateV2SignatureFallback() => _client.SimulateV2SignatureFallback(); + public void SimulateDeviceTokenReceived(string token, string role, string[]? scopes = null) => + _client.SimulateDeviceTokenReceived(token, role, scopes); + public void Dispose() { } } @@ -699,6 +702,18 @@ private void OnHandshakeSucceeded() handler?.Invoke(this, EventArgs.Empty); } } + + public void SimulateDeviceTokenReceived(string token, string role, string[]? scopes = null) + { + var field = typeof(OpenClawGatewayClient).GetField( + nameof(DeviceTokenReceived), + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); + if (field != null) + { + var handler = field.GetValue(this) as EventHandler; + handler?.Invoke(this, new DeviceTokenReceivedEventArgs(token, scopes, role)); + } + } } [Fact] @@ -745,6 +760,97 @@ public async Task HandshakeSucceeded_PreservesOtherRecordFields() Assert.Equal("TestGW", record.FriendlyName); } + // ─── DeviceTokenReceived / bootstrap handoff tests ─── + + [Fact] + public async Task DeviceTokenReceived_NodeRole_ClearsBootstrapTokenFromRegistry() + { + _registry.AddOrUpdate(new GatewayRecord + { + Id = "gw-1", + Url = "wss://test", + BootstrapToken = "bs-secret" + }); + _registry.SetActive("gw-1"); + _resolver.OperatorCredential = new GatewayCredential("tok", false, "test"); + + await _manager.ConnectAsync("gw-1"); + var lifecycle = _factory.CreatedClients[0]; + lifecycle.SimulateDeviceTokenReceived("node-device-token", "node"); + await Task.Delay(50); + + var updated = _registry.GetById("gw-1"); + Assert.Null(updated?.BootstrapToken); + } + + [Fact] + public async Task DeviceTokenReceived_OperatorRole_PreservesBootstrapToken() + { + _registry.AddOrUpdate(new GatewayRecord + { + Id = "gw-1", + Url = "wss://test", + BootstrapToken = "bs-secret" + }); + _registry.SetActive("gw-1"); + _resolver.OperatorCredential = new GatewayCredential("tok", false, "test"); + + await _manager.ConnectAsync("gw-1"); + var lifecycle = _factory.CreatedClients[0]; + lifecycle.SimulateDeviceTokenReceived("op-device-token", "operator"); + await Task.Delay(50); + + var record = _registry.GetById("gw-1"); + Assert.Equal("bs-secret", record?.BootstrapToken); + } + + [Fact] + public async Task DeviceTokenReceived_NodeRole_WhenBootstrapAlreadyNull_Succeeds() + { + _registry.AddOrUpdate(new GatewayRecord { Id = "gw-1", Url = "wss://test" }); + _registry.SetActive("gw-1"); + _resolver.OperatorCredential = new GatewayCredential("tok", false, "test"); + + await _manager.ConnectAsync("gw-1"); + var lifecycle = _factory.CreatedClients[0]; + + // Should not throw even when bootstrap is already null + lifecycle.SimulateDeviceTokenReceived("node-device-token", "node"); + await Task.Delay(50); + + var record = _registry.GetById("gw-1"); + Assert.Null(record?.BootstrapToken); + } + + [Fact] + public async Task DeviceTokenReceived_WithIdentityStore_PersistsToken() + { + var capturedTokens = new List<(string path, string token, string role)>(); + var store = new CaptureIdentityStore(capturedTokens); + using var manager = new GatewayConnectionManager( + _resolver, _factory, _registry, NullLogger.Instance, + identityStore: store); + + _registry.AddOrUpdate(new GatewayRecord { Id = "gw-1", Url = "wss://test" }); + _registry.SetActive("gw-1"); + _resolver.OperatorCredential = new GatewayCredential("tok", false, "test"); + + await manager.ConnectAsync("gw-1"); + var lifecycle = _factory.CreatedClients[0]; + lifecycle.SimulateDeviceTokenReceived("op-device-token", "operator"); + await Task.Delay(50); + + Assert.Single(capturedTokens, t => t.token == "op-device-token" && t.role == "operator"); + } + + private sealed class CaptureIdentityStore : IDeviceIdentityStore + { + private readonly List<(string path, string token, string role)> _captured; + public CaptureIdentityStore(List<(string, string, string)> captured) => _captured = captured; + public void StoreToken(string identityPath, string token, string[]? scopes, string role) => + _captured.Add((identityPath, token, role)); + } + private sealed class CountingNodeConnector : INodeConnector { public int ConnectCount { get; private set; }