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()); 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(["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(["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(["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 { ["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(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(["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 GetPrimaryAsync( CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(Clone(Current)); } public Task 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 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 AdvertisedMethods { get; set; } = new HashSet(StringComparer.Ordinal); public IReadOnlySet AdvertisedEvents { get; set; } = new HashSet(StringComparer.Ordinal); public IReadOnlySet GrantedScopes { get; set; } = new HashSet(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? Handler { get; set; } public List<(string Method, JsonNode? Parameters)> Invocations { get; } = []; public List ConfiguredEndpoints { get; } = []; public bool BootstrapTokenSupplied { get; private set; } public List> RequestedScopeSets { get; } = []; public bool DisconnectRequested { get; private set; } public Task RequestOperatorScopesAsync( IReadOnlyCollection 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 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 GetRecentEvents(int limit = 100) => []; }