Files
nexus/backend-tests/OpenClawSetupServiceTests.cs
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

553 lines
21 KiB
C#

using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class OpenClawSetupServiceTests
{
[Fact]
public void SetupController_IsOwnerOnly()
{
var authorize = Assert.Single(
typeof(OpenClawSetupController)
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.OfType<AuthorizeAttribute>());
Assert.Equal("owner", authorize.Roles);
Assert.Empty(
typeof(OpenClawSetupController)
.GetMethods()
.SelectMany(method =>
method.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true)));
}
[Fact]
public async Task Discover_ReturnsOnlyConfiguredAndWellKnownCandidates_WithoutInvokingGateway()
{
var gateway = ConnectedGateway();
var service = CreateService(gateway: gateway);
var result = await service.DiscoverAsync(new OpenClawDiscoveryRequest(IncludeMdns: true));
Assert.Equal("unsupported", result.MdnsState);
Assert.Contains(result.Candidates, candidate =>
candidate.Endpoint == "ws://openclaw-gateway:18789/"
&& candidate.IsValid);
Assert.Contains(result.Candidates, candidate =>
candidate.Endpoint == "ws://127.0.0.1:18789/"
&& candidate.IsCurrentConnectorEndpoint);
Assert.DoesNotContain(result.Candidates, candidate =>
candidate.Endpoint.Contains("0.0.0.0", StringComparison.Ordinal));
Assert.Empty(gateway.Invocations);
}
[Theory]
[InlineData("ws://example.com:18789/", null)]
[InlineData("wss://user:secret@example.com/", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
[InlineData("wss://example.com/?token=secret", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
[InlineData("wss://example.com/", null)]
public async Task Probe_RejectsUnsafeExternalEndpoints(
string endpoint,
string? fingerprint)
{
var service = CreateService();
var result = await service.ProbeAsync(
new ProbeOpenClawRequest(endpoint, fingerprint));
Assert.False(result.Ok);
Assert.Equal(OpenClawSetupStates.InvalidEndpoint, result.State);
Assert.Null(result.Data);
}
[Fact]
public async Task Probe_ValidExternalEndpointStaysBlockedWithoutOfficialClientIdentity()
{
var gateway = ConnectedGateway();
var service = CreateService(gateway: gateway);
var result = await service.ProbeAsync(new ProbeOpenClawRequest(
"wss://gateway.example.test/",
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
Assert.False(result.Ok);
Assert.Equal(OpenClawSetupStates.ExperimentalBlocked, result.State);
Assert.NotNull(result.Data);
Assert.Empty(gateway.Invocations);
Assert.Empty(gateway.ConfiguredEndpoints);
}
[Fact]
public async Task Probe_BlocksUnofficialClientIdentity_EvenWhenLegacyConnectorIsConnected()
{
var service = CreateService(gateway: ConnectedGateway());
var result = await service.ProbeAsync(
new ProbeOpenClawRequest("ws://127.0.0.1:18789/"));
Assert.False(result.Ok);
Assert.Equal(OpenClawSetupStates.ExperimentalBlocked, result.State);
Assert.False(result.Data?.CanAttach);
}
[Fact]
public async Task Attach_UsesTransientBootstrapSecret_WithoutPersistingIt()
{
var repository = new FakeOpenClawConnectionProfileRepository();
var gateway = ConnectedGateway();
var service = CreateService(
repository,
gateway,
externalIdentitySupported: true);
var request = new AttachOpenClawRequest(
"ws://127.0.0.1:18789/",
"manual",
BootstrapToken: "one-time-secret");
var result = await service.AttachAsync(request);
Assert.True(result.Ok);
Assert.Equal(OpenClawSetupStates.Attached, result.State);
Assert.NotNull(repository.Current);
Assert.True(gateway.BootstrapTokenSupplied);
Assert.DoesNotContain("one-time-secret", JsonSerializer.Serialize(repository.Current), StringComparison.Ordinal);
Assert.DoesNotContain("one-time-secret", JsonSerializer.Serialize(result), StringComparison.Ordinal);
Assert.DoesNotContain("one-time-secret", request.ToString(), StringComparison.Ordinal);
}
[Fact]
public async Task Attach_PersistsOnlySecretFreeProfile_WhenSupportedReadOnlyConnectorIsReady()
{
var repository = new FakeOpenClawConnectionProfileRepository();
var service = CreateService(
repository,
ConnectedGateway(),
externalIdentitySupported: true);
var result = await service.AttachAsync(new AttachOpenClawRequest(
"ws://127.0.0.1:18789/",
"manual"));
Assert.True(result.Ok);
Assert.Equal(OpenClawSetupStates.Attached, result.State);
Assert.NotNull(repository.Current);
Assert.Equal(OpenClawAdoptionStates.Attached, repository.Current!.AdoptionState);
Assert.False(repository.Current.ManagementEnabled);
Assert.Equal(1, repository.Current.Revision);
var serialized = JsonSerializer.Serialize(repository.Current);
Assert.DoesNotContain("BootstrapToken", serialized, StringComparison.Ordinal);
}
[Fact]
public async Task Attach_RejectsLegacyAdminScopeBeforeAdoption()
{
var repository = new FakeOpenClawConnectionProfileRepository();
var gateway = ConnectedGateway();
gateway.GrantedScopes =
new HashSet<string>(["operator.read", "operator.admin"], StringComparer.Ordinal);
var service = CreateService(
repository,
gateway,
externalIdentitySupported: true);
var result = await service.AttachAsync(new AttachOpenClawRequest(
"ws://127.0.0.1:18789/",
"configured"));
Assert.False(result.Ok);
Assert.Equal(OpenClawSetupStates.ExcessiveScope, result.State);
Assert.Null(repository.Current);
}
[Fact]
public async Task Verify_DoesNotAdvanceAttachedProfileWithAdminScope()
{
var repository = new FakeOpenClawConnectionProfileRepository
{
Current = Profile(OpenClawAdoptionStates.Attached, revision: 1)
};
var gateway = ConnectedGateway();
gateway.GrantedScopes =
new HashSet<string>(["operator.read", "operator.admin"], StringComparer.Ordinal);
var service = CreateService(repository, gateway, externalIdentitySupported: true);
var result = await service.VerifyAsync(new VerifyOpenClawRequest(1));
Assert.False(result.Ok);
Assert.Equal(OpenClawSetupStates.ExcessiveScope, result.State);
Assert.Equal(OpenClawAdoptionStates.Attached, repository.Current?.AdoptionState);
Assert.Equal(1, repository.Current?.Revision);
}
[Fact]
public async Task Adopt_CollectsLiveInventory_AndDoesNotCopyResourcePayloads()
{
var repository = new FakeOpenClawConnectionProfileRepository
{
Current = Profile(
adoptionState: OpenClawAdoptionStates.Verified,
revision: 3)
};
var gateway = ConnectedGateway(
"agents.list",
"agents.files.list",
"cron.list",
"models.list",
"channels.status",
"nodes.list");
gateway.Handler = (method, parameters) => method switch
{
"agents.list" => JsonNode.Parse(
"""{"agents":[{"id":"iris"},{"id":"programmer"}]}"""),
"agents.files.list" => JsonNode.Parse(
"""{"files":[{"name":"AGENTS.md"},{"name":"SOUL.md"}]}"""),
"cron.list" => JsonNode.Parse(
"""{"jobs":[{},{},{},{},{},{},{}]}"""),
"models.list" => JsonNode.Parse(
"""{"models":[{},{},{}]}"""),
"channels.status" => JsonNode.Parse(
"""{"channels":{"telegram":{},"discord":{}}}"""),
"nodes.list" => JsonNode.Parse(
"""{"nodes":[{}]}"""),
_ => null
};
var service = CreateService(
repository,
gateway,
externalIdentitySupported: true);
var result = await service.AdoptAsync(new AdoptOpenClawRequest(3));
Assert.True(result.Ok);
Assert.Equal(2, result.Data?.AgentCount);
Assert.Equal(4, result.Data?.AgentFileCount);
Assert.Equal(7, result.Data?.CronJobCount);
Assert.Equal(3, result.Data?.ModelCount);
Assert.Equal(2, result.Data?.ChannelCount);
Assert.Equal(1, result.Data?.NodeCount);
Assert.Equal(OpenClawAdoptionStates.Adopted, repository.Current?.AdoptionState);
Assert.False(repository.Current?.ManagementEnabled);
Assert.Equal(4, repository.Current?.Revision);
}
[Fact]
public async Task Management_RequiresExplicitAdminScopeUpgrade()
{
var repository = new FakeOpenClawConnectionProfileRepository
{
Current = Profile(
adoptionState: OpenClawAdoptionStates.Adopted,
revision: 4)
};
var gateway = ConnectedGateway();
var service = CreateService(
repository,
gateway,
externalIdentitySupported: true);
var blocked = await service.SetManagementAsync(
new SetOpenClawManagementRequest(true, true, 4));
Assert.False(blocked.Ok);
Assert.Equal(OpenClawSetupStates.ScopeUpgradeRequired, blocked.State);
Assert.False(repository.Current?.ManagementEnabled);
Assert.Contains(
gateway.RequestedScopeSets,
scopes => scopes.SetEquals(["operator.read", "operator.admin"]));
gateway.GrantedScopes =
new HashSet<string>(["operator.read", "operator.admin"], StringComparer.Ordinal);
var enabled = await service.SetManagementAsync(
new SetOpenClawManagementRequest(true, true, 4));
Assert.True(enabled.Ok);
Assert.True(repository.Current?.ManagementEnabled);
Assert.Equal(5, repository.Current?.Revision);
}
[Fact]
public async Task ProfileMutation_RejectsStaleRevision()
{
var repository = new FakeOpenClawConnectionProfileRepository
{
Current = Profile(
adoptionState: OpenClawAdoptionStates.Adopted,
revision: 7)
};
var service = CreateService(
repository,
ConnectedGateway(),
externalIdentitySupported: true);
var result = await service.SetManagementAsync(
new SetOpenClawManagementRequest(false, true, 6));
Assert.False(result.Ok);
Assert.Equal(OpenClawSetupStates.ConcurrencyConflict, result.State);
Assert.Equal(7, repository.Current?.Revision);
}
[Fact]
public async Task Delete_RequiresExactEndpointAndDeviceConfirmation()
{
var repository = new FakeOpenClawConnectionProfileRepository
{
Current = Profile(
adoptionState: OpenClawAdoptionStates.Adopted,
revision: 2,
deviceId: "device-bao")
};
var gateway = ConnectedGateway();
var service = CreateService(repository, gateway);
var rejected = await service.DeleteAsync(
new DeleteOpenClawConnectionRequest(
"ws://localhost:18789/",
"device-bao",
2));
Assert.False(rejected.Ok);
Assert.Equal(OpenClawSetupStates.InvalidRequest, rejected.State);
Assert.NotNull(repository.Current);
var removed = await service.DeleteAsync(
new DeleteOpenClawConnectionRequest(
"ws://127.0.0.1:18789/",
"device-bao",
2));
Assert.True(removed.Ok);
Assert.Equal(OpenClawSetupStates.Removed, removed.State);
Assert.Null(repository.Current);
Assert.True(gateway.DisconnectRequested);
}
private static OpenClawSetupService CreateService(
FakeOpenClawConnectionProfileRepository? repository = null,
SetupGatewayConnector? gateway = null,
bool externalIdentitySupported = false)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Integrations:OpenClaw:BaseUrl"] = "http://127.0.0.1:18789",
["Integrations:OpenClaw:RequiredVersion"] = "2026.7.1",
["GatewayConnector:WebSocketPath"] = "/",
["OpenClawSetup:ExternalClientIdentitySupported"] =
externalIdentitySupported.ToString()
})
.Build();
return new OpenClawSetupService(
repository ?? new FakeOpenClawConnectionProfileRepository(),
gateway ?? ConnectedGateway(),
configuration);
}
private static SetupGatewayConnector ConnectedGateway(params string[] additionalMethods)
{
var methods = new HashSet<string>(additionalMethods, StringComparer.Ordinal);
return new SetupGatewayConnector
{
ConnectionState = GatewayConnectionState.Connected,
GatewayVersion = "2026.7.1",
RequiredVersion = "2026.7.1",
ProtocolVersion = 4,
DeviceId = "device-bao",
DeviceTokenConfigured = true,
ActiveEndpoint = "ws://127.0.0.1:18789/",
GrantedScopes = new HashSet<string>(["operator.read"], StringComparer.Ordinal),
AdvertisedMethods = methods
};
}
private static OpenClawConnectionProfile Profile(
string adoptionState,
int revision,
string? deviceId = "device-bao")
=> new()
{
Endpoint = "ws://127.0.0.1:18789/",
DiscoverySource = "configured",
RequiredVersion = "2026.7.1",
AdoptionState = adoptionState,
ManagementEnabled = false,
CapabilityHash = new string('a', 64),
DeviceId = deviceId,
Revision = revision,
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
UpdatedAt = DateTimeOffset.UtcNow
};
}
internal sealed class FakeOpenClawConnectionProfileRepository
: IOpenClawConnectionProfileRepository
{
public OpenClawConnectionProfile? Current { get; set; }
public Task<OpenClawConnectionProfile?> GetPrimaryAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(Clone(Current));
}
public Task<OpenClawConnectionProfile> SavePrimaryAsync(
OpenClawConnectionProfile profile,
int? expectedRevision,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (Current is null)
{
if (expectedRevision is not null and not 0)
throw new OpenClawConnectionProfileConcurrencyException("Profile missing.");
Current = Clone(profile)!;
Current.Revision = 1;
}
else
{
if (expectedRevision != Current.Revision)
{
throw new OpenClawConnectionProfileConcurrencyException(
"Profile changed.",
Current.Revision);
}
Current = Clone(profile)!;
Current.Revision = expectedRevision.Value + 1;
}
Current.UpdatedAt = DateTimeOffset.UtcNow;
return Task.FromResult(Clone(Current)!);
}
public Task<bool> DeletePrimaryAsync(
int expectedRevision,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (Current is null)
return Task.FromResult(false);
if (Current.Revision != expectedRevision)
{
throw new OpenClawConnectionProfileConcurrencyException(
"Profile changed.",
Current.Revision);
}
Current = null;
return Task.FromResult(true);
}
private static OpenClawConnectionProfile? Clone(OpenClawConnectionProfile? profile)
=> profile is null
? null
: new OpenClawConnectionProfile
{
ProfileId = profile.ProfileId,
Endpoint = profile.Endpoint,
DiscoverySource = profile.DiscoverySource,
RequiredVersion = profile.RequiredVersion,
TlsCertificateFingerprint = profile.TlsCertificateFingerprint,
AdoptionState = profile.AdoptionState,
ManagementEnabled = profile.ManagementEnabled,
CapabilityHash = profile.CapabilityHash,
DeviceId = profile.DeviceId,
Revision = profile.Revision,
CreatedAt = profile.CreatedAt,
UpdatedAt = profile.UpdatedAt,
LastProbedAt = profile.LastProbedAt,
LastVerifiedAt = profile.LastVerifiedAt,
AdoptedAt = profile.AdoptedAt
};
}
internal sealed class SetupGatewayConnector : IGatewayConnector
{
public GatewayConnectionState ConnectionState { get; set; }
public string? GatewayVersion { get; set; }
public string? RequiredVersion { get; set; }
public DateTimeOffset? LastConnectedAt { get; set; } = DateTimeOffset.UtcNow;
public int ReconnectAttempts { get; set; }
public string? StatusMessage { get; set; }
public string? DeviceId { get; set; }
public bool DeviceTokenConfigured { get; set; }
public bool PairingRequired { get; set; }
public string? PairingRequestId { get; set; }
public int? ProtocolVersion { get; set; }
public IReadOnlySet<string> AdvertisedMethods { get; set; } =
new HashSet<string>(StringComparer.Ordinal);
public IReadOnlySet<string> AdvertisedEvents { get; set; } =
new HashSet<string>(StringComparer.Ordinal);
public IReadOnlySet<string> GrantedScopes { get; set; } =
new HashSet<string>(StringComparer.Ordinal);
public string? ActiveEndpoint { get; set; } = "ws://127.0.0.1:18789/";
public string? ActiveTlsFingerprint { get; set; }
public DateTimeOffset? LastEventAt { get; set; }
public Func<string, JsonNode?, JsonNode?>? Handler { get; set; }
public List<(string Method, JsonNode? Parameters)> Invocations { get; } = [];
public List<string> ConfiguredEndpoints { get; } = [];
public bool BootstrapTokenSupplied { get; private set; }
public List<HashSet<string>> RequestedScopeSets { get; } = [];
public bool DisconnectRequested { get; private set; }
public Task RequestOperatorScopesAsync(
IReadOnlyCollection<string> scopes,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
RequestedScopeSets.Add(scopes.ToHashSet(StringComparer.Ordinal));
return Task.CompletedTask;
}
public Task ConfigureEndpointAsync(
string endpoint,
string? tlsFingerprint,
string? bootstrapToken,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
ActiveEndpoint = endpoint;
ActiveTlsFingerprint = tlsFingerprint;
ConfiguredEndpoints.Add(endpoint);
BootstrapTokenSupplied |= !string.IsNullOrWhiteSpace(bootstrapToken);
return Task.CompletedTask;
}
public Task DisconnectAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
DisconnectRequested = true;
ConnectionState = GatewayConnectionState.Disconnected;
return Task.CompletedTask;
}
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)
{
cancellationToken.ThrowIfCancellationRequested();
var node = parameters switch
{
null => null,
JsonNode jsonNode => jsonNode.DeepClone(),
_ => JsonSerializer.SerializeToNode(parameters)
};
Invocations.Add((method, node));
return Task.FromResult(Handler?.Invoke(method, node));
}
public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100) => [];
}