Files
nexus/backend-tests/MissionControlPhaseTests.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

324 lines
14 KiB
C#

using System.Reflection;
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.Controllers;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class MissionControlPhaseTests
{
[Fact]
public void AgentConfigSave_IsBaoOwnerOnly()
{
var method = typeof(AgentsController).GetMethod(nameof(AgentsController.SaveConfigFile), BindingFlags.Instance | BindingFlags.Public);
Assert.NotNull(method);
var authorize = method!.GetCustomAttribute<AuthorizeAttribute>();
Assert.NotNull(authorize);
Assert.Equal("owner", authorize!.Roles);
}
[Fact]
public void TaskApprovalEndpoints_AreOwnerOnly()
{
var pending = typeof(TasksController).GetMethod(nameof(TasksController.GetPendingApproval), BindingFlags.Instance | BindingFlags.Public);
var approve = typeof(TasksController).GetMethod(nameof(TasksController.Approve), BindingFlags.Instance | BindingFlags.Public);
var reject = typeof(TasksController).GetMethod(nameof(TasksController.Reject), BindingFlags.Instance | BindingFlags.Public);
Assert.Equal("owner", pending!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
Assert.Equal("owner", approve!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
Assert.Equal("owner", reject!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
}
[Fact]
public void GatewayActivityRedaction_RemovesSensitiveLines()
{
var text = OpenClawGatewayClient.RedactSensitiveText("""
Status: ok
Authorization: Bearer abc.def.ghi
Next step ready
X-Nexus-Api-Key: secret
""");
Assert.Contains("Status: ok", text);
Assert.Contains("Next step ready", text);
Assert.DoesNotContain("Bearer abc", text);
Assert.DoesNotContain("secret", text);
Assert.Equal(2, text.Split("[redacted sensitive line]").Length - 1);
}
[Fact]
public void AgentSummaryBuilder_ProducesStructuredNowAndTodaySummary()
{
var now = DateTimeOffset.UtcNow;
var activity = new[]
{
new ActivityEvent
{
Type = "agent_task",
Message = "programmer completed repo scan",
CreatedAt = now.AddHours(-3)
}
};
var gateway = new[]
{
new AgentActivityEntry("5m ago", "Authorization: Bearer hidden\nWorking on redaction", now.AddMinutes(-5)),
new AgentActivityEntry("20m ago", "Checking task mapping", now.AddMinutes(-20))
};
var summary = AgentSummaryBuilder.Build(activity, gateway, now);
Assert.Equal("gateway-session-history", summary.Now.Source);
Assert.Equal(now.AddMinutes(-5), summary.Now.Timestamp);
Assert.DoesNotContain("Bearer hidden", summary.Now.Text);
Assert.Contains("Working on redaction", summary.Now.Text);
Assert.Equal("derived-mixed", summary.Today.Source);
Assert.Equal(now.AddMinutes(-5), summary.Today.Timestamp);
Assert.Contains("Working on redaction", summary.Today.Text);
Assert.Contains("Checking task mapping", summary.Today.Text);
Assert.Contains("programmer completed repo scan", summary.Today.Text);
}
[Fact]
public async Task ActivityRepository_RedactsBeforePersistenceAndPublishesAgentIds()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var db = new NexusDbContext(options);
await db.Database.EnsureCreatedAsync();
var liveUpdates = new LiveUpdateService();
var subscription = await liveUpdates.SubscribeAsync();
var repository = new ActivityRepository(db, liveUpdates);
await repository.AddAsync(new ActivityEvent
{
Type = "agent",
Message = "Command sent to agent programmer: Authorization: Bearer secret-token"
});
var stored = await repository.GetRecentAsync(1);
Assert.Single(stored);
Assert.DoesNotContain("secret-token", stored[0].Message);
Assert.Contains("programmer", stored[0].Message);
Assert.Contains("Authorization: Bearer [redacted]", stored[0].Message);
var envelope = await subscription.Reader.ReadAsync();
Assert.Equal("activity.created", envelope.Type);
var payloadJson = JsonSerializer.Serialize(envelope.Payload);
using var doc = JsonDocument.Parse(payloadJson);
Assert.Equal("agent", doc.RootElement.GetProperty("Type").GetString());
Assert.DoesNotContain("secret-token", doc.RootElement.GetProperty("Message").GetString());
var agentIds = doc.RootElement.GetProperty("agentIds").EnumerateArray().Select(x => x.GetString()).ToArray();
Assert.Contains("programmer", agentIds);
}
[Fact]
public async Task ActivityRepository_GetByAgentAsync_UsesMappedAgentIds()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var db = new NexusDbContext(options);
await db.Database.EnsureCreatedAsync();
var repository = new ActivityRepository(db, new LiveUpdateService());
await repository.AddAsync(new ActivityEvent
{
Type = "agent",
Message = "Command sent to agent programmer: compile module"
});
await repository.AddAsync(new ActivityEvent
{
Type = "agent",
Message = "Command sent to agent reviewer: inspect module"
});
var programmerEvents = await repository.GetByAgentAsync("programmer", 10);
Assert.Single(programmerEvents);
Assert.True(programmerEvents[0].Message.Contains("programmer", StringComparison.OrdinalIgnoreCase));
Assert.False(programmerEvents[0].Message.Contains("reviewer", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task AgentConfigSave_AuditsFailureWithoutLeakingContent()
{
var configService = new RejectingOpenClawAgentConfigurationService();
var activityRepo = new CapturingActivityRepository();
var controller = new AgentsController(
new FakeAgentService(),
new FakeAgentRuntime(),
activityRepo,
configService,
new FakeDashboardService(),
Microsoft.Extensions.Logging.Abstractions.NullLogger<AgentsController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, "bao"),
new Claim(ClaimTypes.Role, "owner")
], "TestAuth"))
}
}
};
controller.Request.Headers["Idempotency-Key"] = "test-config-save";
var result = await controller.SaveConfigFile(
"programmer",
"TOOLS.md",
new SaveConfigRequest("secret\0payload", "expected-hash"),
CancellationToken.None);
var statusResult = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
Assert.Equal(StatusCodes.Status400BadRequest, statusResult.StatusCode);
var audit = Assert.Single(activityRepo.Added);
Assert.Equal("config_audit", audit.Type);
Assert.Contains("validation=failed", audit.Message);
Assert.DoesNotContain("secret", audit.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("payload", audit.Message, StringComparison.OrdinalIgnoreCase);
}
}
file sealed class RejectingOpenClawAgentConfigurationService
: IOpenClawAgentConfigurationService
{
public Task<OpenClawAgentFileCollectionDto> GetAgentFilesAsync(
string agentId,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawAgentFileDto> GetAgentFileAsync(
string agentId,
string fileName,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawAgentFileWriteDto> SetAgentFileAsync(
string agentId,
string fileName,
UpdateOpenClawAgentFileRequest request,
OpenClawInvocationContext invocationContext,
CancellationToken cancellationToken = default)
=> throw new OpenClawAgentConfigurationValidationException(
"content",
"Content contains null bytes.");
public Task<OpenClawWorkspaceCollectionDto> GetWorkspaceAsync(
string agentId,
string? path,
int offset,
int limit,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawWorkspaceFileDto> GetWorkspaceFileAsync(
string agentId,
string path,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawConfigSchemaLookupDto> GetConfigSchemaAsync(
string path,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawConfigSnapshotDto> GetConfigAsync(
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawConfigPatchDto> PatchConfigAsync(
PatchOpenClawConfigRequest request,
OpenClawInvocationContext invocationContext,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
file sealed class CapturingActivityRepository : IActivityRepository
{
public List<ActivityEvent> Added { get; } = [];
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
=> Task.FromResult((new List<ActivityEvent>(), 0));
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
{
Added.Add(activity);
return Task.FromResult(activity);
}
}
file sealed class FakeAgentService : IAgentService
{
public Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyCollection<AgentInfo>>([]);
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
=> Task.FromResult<AgentDetail?>(null);
public Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "iris", "bao", "programmer" });
}
file sealed class FakeAgentRuntime :
Nexus.Api.Integrations.IAgentRuntime,
IOpenClawChatService
{
public string Name => "fake";
public Task<Nexus.Api.Integrations.AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken)
=> Task.FromResult(new Nexus.Api.Integrations.AgentRuntimeStatus("fake", OperationalStatus.Online, TimeSpan.Zero, null));
public Task<Nexus.Api.Integrations.AgentChatResult> ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken)
=> Task.FromResult(new Nexus.Api.Integrations.AgentChatResult("fake", agentId, conversationId, "ok"));
public Task<Nexus.Api.Integrations.AgentChatResult> SendAsync(
string message,
string conversationId,
string agentId,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
=> ChatAsync(message, conversationId, agentId, cancellationToken);
}
file sealed class FakeDashboardService : IDashboardService
{
public Task<DashboardStatus> GetStatusAsync() => Task.FromResult(new DashboardStatus(true, "online", 1, 0));
public Task<List<DashboardAgentInfo>> GetAgentsAsync() => Task.FromResult(new List<DashboardAgentInfo>());
public Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter) => Task.FromResult(new List<FeedEntry>());
public Task<ChatResponse> SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null));
public Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List<MessageEntry>());
public Task<List<QueueItem>> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List<QueueItem>());
public Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct) => Task.FromResult(new GatewayRuntimeInfo(true, "http://gateway", "test", "test", true, true, "matched", DateTimeOffset.UtcNow, "ok"));
public Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored));
public Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored));
public Task<AgentModelInfo?> GetAgentModelAsync(string agentId) => Task.FromResult<AgentModelInfo?>(null);
public Task<bool> SetAgentModelAsync(string agentId, string model) => Task.FromResult(false);
public Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit) => Task.FromResult(new List<AgentActivityEntry>());
public Task<List<ModelOption>> GetAvailableModelsAsync(CancellationToken ct)
=> Task.FromResult(new List<ModelOption>());
}