225 lines
8.1 KiB
C#
225 lines
8.1 KiB
C#
using System.Reflection;
|
|
using System.Text;
|
|
using System.Text.Json.Nodes;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Nexus.Api.Controllers;
|
|
using Nexus.Api.Services;
|
|
using Xunit;
|
|
|
|
namespace Nexus.Api.Tests;
|
|
|
|
public sealed class OpenClawEventProjectionTests
|
|
{
|
|
[Fact]
|
|
public void Project_OrdersEvents_RedactsSecrets_AndReportsSequenceGap()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var connector = new EventConnector
|
|
{
|
|
Events =
|
|
[
|
|
new GatewayEventEnvelope(
|
|
"chat",
|
|
new JsonObject
|
|
{
|
|
["runId"] = "run-1",
|
|
["state"] = "delta",
|
|
["seq"] = 2,
|
|
["apiToken"] = "do-not-leak",
|
|
["inputTokens"] = 42
|
|
},
|
|
12,
|
|
4,
|
|
now.AddMilliseconds(20)),
|
|
new GatewayEventEnvelope(
|
|
"sessions.changed",
|
|
new JsonObject { ["sessionKey"] = "agent:iris:main" },
|
|
10,
|
|
3,
|
|
now)
|
|
]
|
|
};
|
|
var service = new OpenClawEventProjectionService(connector);
|
|
|
|
var batch = service.Project(null);
|
|
|
|
Assert.Equal(2, batch.Events.Count);
|
|
Assert.Equal("sessions.changed", batch.Events[0].EventName);
|
|
Assert.Equal("chat", batch.Events[1].EventName);
|
|
Assert.True(batch.Events[1].SequenceGapDetected);
|
|
Assert.Equal(11, batch.Events[1].MissingSequenceFrom);
|
|
Assert.Equal(11, batch.Events[1].MissingSequenceTo);
|
|
Assert.Equal("[redacted]", batch.Events[1].Payload?["apiToken"]?.GetValue<string>());
|
|
Assert.Equal(42, batch.Events[1].Payload?["inputTokens"]?.GetValue<int>());
|
|
Assert.StartsWith("gw-", batch.Events[0].Id);
|
|
Assert.Equal(batch.Events[^1].Id, batch.Cursor);
|
|
}
|
|
|
|
[Fact]
|
|
public void Project_ReplaysAfterKnownCursor_AndSignalsExpiredCursor()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var connector = new EventConnector
|
|
{
|
|
Events =
|
|
[
|
|
Event("chat", 3, now.AddSeconds(2)),
|
|
Event("session.tool", 2, now.AddSeconds(1)),
|
|
Event("sessions.changed", 1, now)
|
|
]
|
|
};
|
|
var service = new OpenClawEventProjectionService(connector);
|
|
var initial = service.Project(null);
|
|
|
|
var replay = service.Project(initial.Events[0].Id);
|
|
var expired = service.Project("gw-expired");
|
|
|
|
Assert.Equal(2, replay.Events.Count);
|
|
Assert.False(replay.ReplayBoundaryMissed);
|
|
Assert.True(expired.ReplayBoundaryMissed);
|
|
Assert.Equal(3, expired.Events.Count);
|
|
Assert.Equal(initial.Events[^1].Id, expired.Cursor);
|
|
}
|
|
|
|
[Fact]
|
|
public void Project_SignalsExpiredCursorAfterBackendRestartWithEmptyBuffer()
|
|
{
|
|
var service = new OpenClawEventProjectionService(new EventConnector());
|
|
|
|
var batch = service.Project("gw-from-previous-process");
|
|
|
|
Assert.True(batch.ReplayBoundaryMissed);
|
|
Assert.Empty(batch.Events);
|
|
Assert.Equal("origin", batch.Cursor);
|
|
}
|
|
|
|
[Fact]
|
|
public void Project_ReportsOuterSequenceResetWithoutCallingItAMissingRange()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var connector = new EventConnector
|
|
{
|
|
Events =
|
|
[
|
|
Event("chat", 1, now.AddSeconds(1)),
|
|
Event("chat", 80, now)
|
|
]
|
|
};
|
|
var service = new OpenClawEventProjectionService(connector);
|
|
|
|
var batch = service.Project(null);
|
|
|
|
Assert.True(batch.Events[1].SequenceResetDetected);
|
|
Assert.False(batch.Events[1].SequenceGapDetected);
|
|
Assert.Equal(80, batch.Events[1].PreviousSequence);
|
|
Assert.Equal(1, batch.Events[1].Sequence);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("sessions.changed", "session")]
|
|
[InlineData("session.tool", "tool")]
|
|
[InlineData("exec.approval.requested", "approval")]
|
|
[InlineData("artifact.created", "artifact")]
|
|
[InlineData("chat", "run")]
|
|
public void Project_ClassifiesOperationalEventFamilies(string eventName, string category)
|
|
{
|
|
var connector = new EventConnector
|
|
{
|
|
Events = [Event(eventName, 1, DateTimeOffset.UtcNow)]
|
|
};
|
|
|
|
var item = Assert.Single(new OpenClawEventProjectionService(connector).Project(null).Events);
|
|
|
|
Assert.Equal(category, item.Category);
|
|
Assert.Equal($"openclaw.{category}", item.Type);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Controller_UsesLastEventId_AndCanReturnFiniteReplay()
|
|
{
|
|
var connector = new EventConnector
|
|
{
|
|
Events = [Event("chat", 7, DateTimeOffset.UtcNow)]
|
|
};
|
|
var projector = new OpenClawEventProjectionService(connector);
|
|
var controller = new OpenClawEventsController(projector);
|
|
var context = new DefaultHttpContext();
|
|
context.Request.Headers["Last-Event-ID"] = "gw-expired";
|
|
context.Response.Body = new MemoryStream();
|
|
controller.ControllerContext = new ControllerContext { HttpContext = context };
|
|
|
|
await controller.Stream(follow: false);
|
|
|
|
context.Response.Body.Position = 0;
|
|
using var reader = new StreamReader(context.Response.Body, Encoding.UTF8);
|
|
var body = await reader.ReadToEndAsync();
|
|
Assert.Equal("text/event-stream", context.Response.ContentType);
|
|
Assert.Contains("event: openclaw.connection", body, StringComparison.Ordinal);
|
|
Assert.Contains("event: openclaw.gap", body, StringComparison.Ordinal);
|
|
Assert.Contains("event: openclaw.run", body, StringComparison.Ordinal);
|
|
Assert.Contains("event: openclaw.heartbeat", body, StringComparison.Ordinal);
|
|
Assert.Contains("last-event-id-outside-buffer", body, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Controller_IsAuthenticated()
|
|
{
|
|
var authorize = typeof(OpenClawEventsController)
|
|
.GetCustomAttributes<AuthorizeAttribute>()
|
|
.SingleOrDefault();
|
|
|
|
Assert.NotNull(authorize);
|
|
}
|
|
|
|
private static GatewayEventEnvelope Event(
|
|
string name,
|
|
long sequence,
|
|
DateTimeOffset receivedAt)
|
|
=> new(
|
|
name,
|
|
new JsonObject
|
|
{
|
|
["runId"] = "run-1",
|
|
["state"] = "delta",
|
|
["seq"] = sequence
|
|
},
|
|
sequence,
|
|
sequence,
|
|
receivedAt);
|
|
|
|
private sealed class EventConnector : IGatewayConnector
|
|
{
|
|
public IReadOnlyList<GatewayEventEnvelope> Events { get; init; } = [];
|
|
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-test";
|
|
public bool DeviceTokenConfigured => true;
|
|
public bool PairingRequired => false;
|
|
public string? PairingRequestId => null;
|
|
public int? ProtocolVersion => 4;
|
|
public IReadOnlySet<string> AdvertisedMethods { get; } = new HashSet<string>();
|
|
public IReadOnlySet<string> AdvertisedEvents { get; } = new HashSet<string>();
|
|
public IReadOnlySet<string> GrantedScopes { get; } = new HashSet<string>();
|
|
public DateTimeOffset? LastEventAt => Events.FirstOrDefault()?.ReceivedAt;
|
|
|
|
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)
|
|
=> Events.Take(limit).ToList();
|
|
}
|
|
}
|