206 lines
7.7 KiB
C#
206 lines
7.7 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using Nexus.Api.Data;
|
|
using Nexus.Api.Models;
|
|
using Nexus.Api.Services;
|
|
using Xunit;
|
|
|
|
namespace Nexus.Api.Tests;
|
|
|
|
public sealed class OpenClawRunGatewayTests
|
|
{
|
|
[Fact]
|
|
public async Task Start_UsesOfficialChatSendShape_AndForwardsInvocationContext()
|
|
{
|
|
var connector = new CapturingConnector(
|
|
["chat.send"],
|
|
new JsonObject
|
|
{
|
|
["runId"] = "oc-run-42",
|
|
["status"] = "started"
|
|
});
|
|
var gateway = new OpenClawRunGateway(
|
|
connector,
|
|
new StubOpenClawWriteGate());
|
|
var invocation = Invocation();
|
|
|
|
var result = await gateway.StartAsync(Run(), invocation);
|
|
|
|
Assert.True(result.Ok);
|
|
Assert.Equal("oc-run-42", result.OpenClawRunId);
|
|
var call = Assert.Single(connector.Calls);
|
|
Assert.Equal("chat.send", call.Method);
|
|
Assert.Equal("agent:iris:main", call.Parameters?["sessionKey"]?.GetValue<string>());
|
|
Assert.Equal("iris", call.Parameters?["agentId"]?.GetValue<string>());
|
|
Assert.Equal("Do the work", call.Parameters?["message"]?.GetValue<string>());
|
|
Assert.False(call.Parameters?["deliver"]?.GetValue<bool>());
|
|
Assert.Equal("run-idempotency", call.Parameters?["idempotencyKey"]?.GetValue<string>());
|
|
Assert.Equal("run-idempotency", call.Invocation?.IdempotencyKey);
|
|
Assert.Equal("run-correlation", call.Invocation?.CorrelationId);
|
|
Assert.Equal("owner-subject", call.Invocation?.Actor);
|
|
Assert.True(call.Invocation?.IncludeIdempotencyParameter);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Start_DoesNotInvokeGateway_WhenWriteBoundaryIsBlocked()
|
|
{
|
|
var connector = new CapturingConnector(
|
|
["chat.send"],
|
|
new JsonObject
|
|
{
|
|
["runId"] = "must-not-be-used",
|
|
["status"] = "started"
|
|
});
|
|
var gate = new StubOpenClawWriteGate(
|
|
OpenClawWriteGateDecision.Block(
|
|
"endpoint_trust_mismatch",
|
|
"The active Gateway endpoint no longer matches the adopted profile.",
|
|
"Verify and adopt the connection again."));
|
|
var gateway = new OpenClawRunGateway(connector, gate);
|
|
|
|
var result = await gateway.StartAsync(Run(), Invocation());
|
|
|
|
Assert.False(result.Ok);
|
|
Assert.Equal(OpenClawRunStates.Blocked, result.State);
|
|
Assert.Empty(connector.Calls);
|
|
Assert.Equal(("chat.send", "operator.write"), Assert.Single(gate.Evaluations));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Stop_RefusesSessionWideAbortWithoutExactOpenClawRunId()
|
|
{
|
|
var connector = new CapturingConnector(["chat.abort"], new JsonObject());
|
|
var gateway = new OpenClawRunGateway(
|
|
connector,
|
|
new StubOpenClawWriteGate());
|
|
var run = Run();
|
|
run.OpenClawRunId = null;
|
|
|
|
var result = await gateway.StopAsync(run, Invocation());
|
|
|
|
Assert.False(result.Ok);
|
|
Assert.Equal(OpenClawRunStates.Blocked, result.State);
|
|
Assert.Empty(connector.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Stop_UsesExactChatAbort_WithoutUnsupportedWireFields()
|
|
{
|
|
var connector = new CapturingConnector(["chat.abort"], new JsonObject { ["ok"] = true });
|
|
var gateway = new OpenClawRunGateway(
|
|
connector,
|
|
new StubOpenClawWriteGate());
|
|
|
|
var result = await gateway.StopAsync(Run(), Invocation());
|
|
|
|
Assert.True(result.Ok);
|
|
var call = Assert.Single(connector.Calls);
|
|
Assert.Equal("chat.abort", call.Method);
|
|
Assert.Equal("oc-run-1", call.Parameters?["runId"]?.GetValue<string>());
|
|
Assert.Null(call.Parameters?["idempotencyKey"]);
|
|
Assert.False(call.Invocation?.IncludeIdempotencyParameter);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task History_IsDisplayNormalizedGatewayData_WithSecretsRedacted()
|
|
{
|
|
var connector = new CapturingConnector(
|
|
["chat.history"],
|
|
new JsonObject
|
|
{
|
|
["messages"] = new JsonArray(new JsonObject { ["text"] = "done" }),
|
|
["accessToken"] = "secret-value",
|
|
["inputTokens"] = 12
|
|
});
|
|
var gateway = new OpenClawRunGateway(
|
|
connector,
|
|
new StubOpenClawWriteGate());
|
|
|
|
var result = await gateway.GetHistoryAsync(Run(), 50);
|
|
|
|
Assert.True(result.Ok);
|
|
Assert.Equal("[redacted]", result.Data?["accessToken"]?.GetValue<string>());
|
|
Assert.Equal(12, result.Data?["inputTokens"]?.GetValue<int>());
|
|
var call = Assert.Single(connector.Calls);
|
|
Assert.Equal("chat.history", call.Method);
|
|
Assert.Equal(50, call.Parameters?["limit"]?.GetValue<int>());
|
|
Assert.Null(call.Invocation);
|
|
}
|
|
|
|
private static OpenClawRun Run()
|
|
=> new()
|
|
{
|
|
Title = "Run",
|
|
Prompt = "Do the work",
|
|
AgentId = "iris",
|
|
SessionKey = "agent:iris:main",
|
|
Status = OpenClawRunStates.Running,
|
|
OpenClawRunId = "oc-run-1",
|
|
StartIdempotencyKey = "run-idempotency",
|
|
CorrelationId = "run-correlation",
|
|
Actor = "owner-subject"
|
|
};
|
|
|
|
private static OpenClawInvocationMetadata Invocation()
|
|
=> new(
|
|
"run-idempotency",
|
|
"run-correlation",
|
|
"owner-subject",
|
|
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
|
|
|
|
private sealed class CapturingConnector : IGatewayConnector
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
|
private readonly JsonNode? _response;
|
|
|
|
public CapturingConnector(IEnumerable<string> methods, JsonNode? response)
|
|
{
|
|
AdvertisedMethods = new HashSet<string>(methods, StringComparer.Ordinal);
|
|
_response = response;
|
|
}
|
|
|
|
public List<InvocationCall> Calls { get; } = [];
|
|
public GatewayConnectionState ConnectionState => GatewayConnectionState.Connected;
|
|
public string? GatewayVersion => "2026.7.0";
|
|
public string? RequiredVersion => "2026.7.0";
|
|
public DateTimeOffset? LastConnectedAt => DateTimeOffset.UtcNow;
|
|
public int ReconnectAttempts => 0;
|
|
public string? StatusMessage => "Connected";
|
|
public string? DeviceId => "nexus-tests";
|
|
public bool DeviceTokenConfigured => true;
|
|
public bool PairingRequired => false;
|
|
public string? PairingRequestId => null;
|
|
public int? ProtocolVersion => 4;
|
|
public IReadOnlySet<string> AdvertisedMethods { get; }
|
|
public IReadOnlySet<string> AdvertisedEvents { get; } = new HashSet<string>();
|
|
public IReadOnlySet<string> GrantedScopes { get; } = new HashSet<string>();
|
|
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)
|
|
{
|
|
var node = parameters switch
|
|
{
|
|
null => null,
|
|
JsonNode jsonNode => jsonNode.DeepClone(),
|
|
_ => JsonSerializer.SerializeToNode(parameters, JsonOptions)
|
|
};
|
|
Calls.Add(new InvocationCall(method, node, invocationContext));
|
|
return Task.FromResult(_response?.DeepClone());
|
|
}
|
|
|
|
public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100) => [];
|
|
}
|
|
|
|
private sealed record InvocationCall(
|
|
string Method,
|
|
JsonNode? Parameters,
|
|
OpenClawInvocationContext? Invocation);
|
|
}
|