P2.1: Add GatewayConnector BackgroundService with WebSocket client

- GatewayConnector: BackgroundService maintaining persistent WebSocket connection
  to OpenClaw Gateway with Bearer token auth from backend env only
- IGatewayConnector: Interface exposing connection state, version info, health
- Exponential backoff reconnect: 1s initial, max 5min, with jitter
- Version pinning: RequiredVersion from config; fail-fast or warn on mismatch
- Pre-flight HTTP version check before WebSocket connect
- GatewayHealthController: GET /api/health/gateway with connection status
- GatewayConnectorOptions: Configurable via GatewayConnector config section
- Token never logged; read from Integrations:OpenClaw:Password/Token only
- 16 tests: options defaults, config binding, health endpoint responses,
  connection states, DI registration, routing attributes
This commit is contained in:
2026-07-13 14:13:39 +02:00
parent a55951f315
commit 6ba788bb6e
6 changed files with 925 additions and 0 deletions
+311
View File
@@ -0,0 +1,311 @@
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Nexus.Api.Controllers;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public class GatewayConnectorTests
{
// ── Options / Configuration tests ──
[Fact]
public void Options_DefaultWebSocketPath_IsWs()
{
var options = new GatewayConnectorOptions();
Assert.Equal("/ws", options.WebSocketPath);
}
[Fact]
public void Options_DefaultReconnectInitialDelay_Is1000ms()
{
var options = new GatewayConnectorOptions();
Assert.Equal(1000, options.ReconnectInitialDelayMs);
}
[Fact]
public void Options_DefaultReconnectMaxDelay_Is5Minutes()
{
var options = new GatewayConnectorOptions();
Assert.Equal(300_000, options.ReconnectMaxDelayMs);
}
[Fact]
public void Options_FailFastOnVersionMismatch_IsTrueByDefault()
{
var options = new GatewayConnectorOptions();
Assert.True(options.FailFastOnVersionMismatch);
}
[Fact]
public void Options_FailFastOnMissingVersion_IsFalseByDefault()
{
var options = new GatewayConnectorOptions();
Assert.False(options.FailFastOnMissingVersion);
}
// ── Configuration binding tests ──
[Fact]
public void Options_BindFromConfiguration()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["GatewayConnector:WebSocketPath"] = "/events",
["GatewayConnector:ReconnectInitialDelayMs"] = "2000",
["GatewayConnector:ReconnectMaxDelayMs"] = "60000",
["GatewayConnector:FailFastOnVersionMismatch"] = "false",
["GatewayConnector:FailFastOnMissingVersion"] = "true"
})
.Build();
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(config);
services.AddOptions<GatewayConnectorOptions>()
.BindConfiguration(GatewayConnectorOptions.SectionName);
var sp = services.BuildServiceProvider();
var options = sp.GetRequiredService<IOptions<GatewayConnectorOptions>>().Value;
Assert.Equal("/events", options.WebSocketPath);
Assert.Equal(2000, options.ReconnectInitialDelayMs);
Assert.Equal(60000, options.ReconnectMaxDelayMs);
Assert.False(options.FailFastOnVersionMismatch);
Assert.True(options.FailFastOnMissingVersion);
}
// ── Health endpoint tests ──
[Fact]
public async Task HealthEndpoint_ReturnsCorrectStructure()
{
var controller = new GatewayHealthController(new FakeGatewayConnector(
GatewayConnectionState.Connected, "2.103.0", "2.103.0",
DateTimeOffset.UtcNow, 0, "Gateway verbunden"));
var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth());
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.Equal("connected", root.GetProperty("status").GetString());
Assert.True(root.GetProperty("connected").GetBoolean());
Assert.Equal("2.103.0", root.GetProperty("gatewayVersion").GetString());
Assert.Equal("2.103.0", root.GetProperty("requiredVersion").GetString());
Assert.True(root.GetProperty("versionPinned").GetBoolean());
Assert.Equal(0, root.GetProperty("reconnectAttempts").GetInt32());
}
[Fact]
public async Task HealthEndpoint_Disconnected_ShowsDisconnected()
{
var controller = new GatewayHealthController(new FakeGatewayConnector(
GatewayConnectionState.Disconnected, null, "2.103.0",
null, 5, "Connection refused"));
var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth());
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.Equal("disconnected", root.GetProperty("status").GetString());
Assert.False(root.GetProperty("connected").GetBoolean());
Assert.Equal("unknown", root.GetProperty("gatewayVersion").GetString());
Assert.Equal(5, root.GetProperty("reconnectAttempts").GetInt32());
Assert.Equal("Connection refused", root.GetProperty("message").GetString());
}
[Fact]
public async Task HealthEndpoint_Reconnecting_ShowsReconnecting()
{
var controller = new GatewayHealthController(new FakeGatewayConnector(
GatewayConnectionState.Reconnecting, null, null,
null, 3, "Reconnecting..."));
var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth());
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.Equal("reconnecting", root.GetProperty("status").GetString());
Assert.False(root.GetProperty("connected").GetBoolean());
Assert.False(root.GetProperty("versionPinned").GetBoolean());
}
[Fact]
public async Task HealthEndpoint_Failed_ShowsFailed()
{
var controller = new GatewayHealthController(new FakeGatewayConnector(
GatewayConnectionState.Failed, "2.100.0", "2.103.0",
null, 10, "Version mismatch — fail-fast"));
var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth());
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.Equal("failed", root.GetProperty("status").GetString());
Assert.False(root.GetProperty("connected").GetBoolean());
Assert.Equal("2.100.0", root.GetProperty("gatewayVersion").GetString());
Assert.Equal("2.103.0", root.GetProperty("requiredVersion").GetString());
Assert.Equal(10, root.GetProperty("reconnectAttempts").GetInt32());
Assert.True(root.TryGetProperty("timestamp", out _));
}
[Fact]
public async Task HealthEndpoint_Unpinned_ShowsVersionPinnedFalse()
{
var controller = new GatewayHealthController(new FakeGatewayConnector(
GatewayConnectionState.Connected, "2.103.0", null,
DateTimeOffset.UtcNow, 0, null));
var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth());
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.False(root.GetProperty("versionPinned").GetBoolean());
Assert.Equal(JsonValueKind.Null, root.GetProperty("requiredVersion").ValueKind);
}
// ── Connection state transition tests ──
[Fact]
public void Connector_InitialState_IsInitializing()
{
var connector = new FakeGatewayConnector(
GatewayConnectionState.Initializing, null, null, null, 0, null);
Assert.Equal(GatewayConnectionState.Initializing, connector.ConnectionState);
Assert.Null(connector.GatewayVersion);
Assert.Null(connector.LastConnectedAt);
Assert.Equal(0, connector.ReconnectAttempts);
}
[Fact]
public void Connector_AfterConnection_ReportsConnected()
{
var now = DateTimeOffset.UtcNow;
var connector = new FakeGatewayConnector(
GatewayConnectionState.Connected, "2.103.0", "2.103.0",
now, 0, null);
Assert.Equal(GatewayConnectionState.Connected, connector.ConnectionState);
Assert.Equal("2.103.0", connector.GatewayVersion);
Assert.Equal("2.103.0", connector.RequiredVersion);
Assert.NotNull(connector.LastConnectedAt);
Assert.Equal(0, connector.ReconnectAttempts);
}
// ── DI registration tests ──
[Fact]
public void Connector_CanBeRegisteredInDi()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Integrations:OpenClaw:BaseUrl"] = "http://localhost:18789",
["Integrations:OpenClaw:RequiredVersion"] = "2.103.0"
})
.Build();
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(config);
services.AddLogging();
services.AddHttpClient("gateway");
services.AddOptions<GatewayConnectorOptions>()
.BindConfiguration(GatewayConnectorOptions.SectionName);
services.AddSingleton<IGatewayConnector, GatewayConnector>();
var sp = services.BuildServiceProvider();
var connector = sp.GetRequiredService<IGatewayConnector>();
Assert.NotNull(connector);
Assert.Equal(GatewayConnectionState.Initializing, connector.ConnectionState);
}
// ── HTTP endpoint routing test ──
[Fact]
public void HealthEndpoint_HasAllowAnonymous()
{
var type = typeof(GatewayHealthController);
var attr = type.GetCustomAttributes(true)
.OfType<Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute>()
.FirstOrDefault();
Assert.NotNull(attr);
}
[Fact]
public void HealthEndpoint_HasCorrectRoute()
{
var method = typeof(GatewayHealthController).GetMethod("GetGatewayHealth");
Assert.NotNull(method);
var routeAttr = method!.GetCustomAttributes(true)
.OfType<Microsoft.AspNetCore.Mvc.HttpGetAttribute>()
.FirstOrDefault();
Assert.NotNull(routeAttr);
Assert.Contains("/api/health/gateway", routeAttr!.Template!);
}
// ── Helpers ──
private static async Task<string> ExecuteAndReadJsonAsync(IResult result)
{
var services = new ServiceCollection();
services.AddLogging();
services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase);
var sp = services.BuildServiceProvider();
var httpContext = new DefaultHttpContext { RequestServices = sp };
// HttpResults.Ok<T> needs a response body to write to
httpContext.Response.Body = new MemoryStream();
await result.ExecuteAsync(httpContext);
httpContext.Response.Body.Position = 0;
using var reader = new StreamReader(httpContext.Response.Body);
return reader.ReadToEnd();
}
}
/// <summary>
/// Test double implementing all members of IGatewayConnector.
/// </summary>
public sealed class FakeGatewayConnector : IGatewayConnector
{
private readonly GatewayConnectionState _state;
private readonly string? _gatewayVersion;
private readonly string? _requiredVersion;
private readonly DateTimeOffset? _lastConnectedAt;
private readonly int _reconnectAttempts;
private readonly string? _statusMessage;
public FakeGatewayConnector(
GatewayConnectionState state,
string? gatewayVersion,
string? requiredVersion,
DateTimeOffset? lastConnectedAt,
int reconnectAttempts,
string? statusMessage)
{
_state = state;
_gatewayVersion = gatewayVersion;
_requiredVersion = requiredVersion;
_lastConnectedAt = lastConnectedAt;
_reconnectAttempts = reconnectAttempts;
_statusMessage = statusMessage;
}
public GatewayConnectionState ConnectionState => _state;
public string? GatewayVersion => _gatewayVersion;
public string? RequiredVersion => _requiredVersion;
public DateTimeOffset? LastConnectedAt => _lastConnectedAt;
public int ReconnectAttempts => _reconnectAttempts;
public string? StatusMessage => _statusMessage;
}