374 lines
14 KiB
C#
374 lines
14 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Http.Json;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
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_IsGatewayRoot()
|
|
{
|
|
var options = new GatewayConnectorOptions();
|
|
Assert.Equal("/", options.WebSocketPath);
|
|
Assert.Equal(4, options.ProtocolVersion);
|
|
Assert.Equal(["operator.read"], options.Scopes);
|
|
Assert.Equal("nexus", options.ClientId);
|
|
Assert.Equal("backend", options.ClientMode);
|
|
Assert.False(options.ExternalClientIdentitySupported);
|
|
Assert.False(options.AllowReservedInternalClientIdentity);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[Fact]
|
|
public void Connector_DefaultsToVerifiedGatewayVersionPin()
|
|
{
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["Integrations:OpenClaw:BaseUrl"] = "http://127.0.0.1:18789"
|
|
})
|
|
.Build();
|
|
var connector = new GatewayConnector(
|
|
configuration,
|
|
Options.Create(new GatewayConnectorOptions()),
|
|
NullLogger<GatewayConnector>.Instance);
|
|
|
|
Assert.Equal(
|
|
OpenClawGatewayProtocol.DefaultRequiredGatewayVersion,
|
|
connector.RequiredVersion);
|
|
Assert.Equal("2026.7.1", connector.RequiredVersion);
|
|
}
|
|
|
|
// ── Configuration binding tests ──
|
|
|
|
[Fact]
|
|
public void Options_BindFromConfiguration()
|
|
{
|
|
var config = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["GatewayConnector:WebSocketPath"] = "/events",
|
|
["GatewayConnector:ProtocolVersion"] = "4",
|
|
["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(4, options.ProtocolVersion);
|
|
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;
|
|
public string? DeviceId => null;
|
|
public bool DeviceTokenConfigured => false;
|
|
public bool PairingRequired => false;
|
|
public string? PairingRequestId => null;
|
|
public int? ProtocolVersion => _state == GatewayConnectionState.Connected ? 4 : null;
|
|
public IReadOnlySet<string> AdvertisedMethods { get; } = new HashSet<string>(StringComparer.Ordinal)
|
|
{
|
|
"health",
|
|
"tasks.list"
|
|
};
|
|
public IReadOnlySet<string> AdvertisedEvents { get; } = new HashSet<string>(StringComparer.Ordinal)
|
|
{
|
|
"health",
|
|
"tick"
|
|
};
|
|
public IReadOnlySet<string> GrantedScopes { get; } = new HashSet<string>(StringComparer.Ordinal)
|
|
{
|
|
"operator.read"
|
|
};
|
|
public DateTimeOffset? LastEventAt => null;
|
|
|
|
public bool Supports(string method) => AdvertisedMethods.Contains(method);
|
|
|
|
public Task<JsonNode?> InvokeAsync(
|
|
string method,
|
|
object? parameters = null,
|
|
TimeSpan? timeout = null,
|
|
CancellationToken cancellationToken = default,
|
|
OpenClawInvocationContext? invocationContext = null)
|
|
=> Task.FromResult<JsonNode?>(null);
|
|
|
|
public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100) => [];
|
|
}
|