using System.Reflection; using System.Security.Claims; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; 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(); 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()?.Roles); Assert.Equal("owner", approve!.GetCustomAttribute()?.Roles); Assert.Equal("owner", reject!.GetCustomAttribute()?.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() .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() .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 GatewayInfo_ReportsVersionDrift() { var client = CreateClient(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent("""{"version":"2026.07.08"}""", Encoding.UTF8, "application/json") }, requiredVersion: "2026.07.09"); var info = await client.GetGatewayInfoAsync(); Assert.True(info.Reachable); Assert.Equal("2026.07.08", info.Version); Assert.Equal("2026.07.09", info.RequiredVersion); Assert.Equal("drift", info.VersionStatus); Assert.False(info.VersionMatches); Assert.NotNull(info.Warning); Assert.Contains("2026.07.08", info.Warning!); } [Fact] public async Task GatewayInfo_ReportsMissingVersionWhenPinned() { var client = CreateClient(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent("""{"status":"ok"}""", Encoding.UTF8, "application/json") }, requiredVersion: "2026.07.09"); var info = await client.GetGatewayInfoAsync(); Assert.True(info.Reachable); Assert.Null(info.Version); Assert.Equal("missing", info.VersionStatus); Assert.False(info.VersionMatches); Assert.NotNull(info.Warning); Assert.Contains("2026.07.09", info.Warning!); } [Fact] public async Task GatewayInfo_ReportsMatchedPinnedVersion() { var client = CreateClient(request => { var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent("""{"status":"ok"}""", Encoding.UTF8, "application/json") }; response.Headers.Add("X-OpenClaw-Version", "2026.07.09"); return response; }, requiredVersion: "2026.07.09"); var info = await client.GetGatewayInfoAsync(); Assert.True(info.Reachable); Assert.Equal("matched", info.VersionStatus); Assert.True(info.VersionMatches); Assert.Null(info.Warning); } [Fact] public async Task GetAgentsAsync_MapsRuntimeStatesFromGatewayStatus() { var staleTimestamp = DateTimeOffset.UtcNow.AddMinutes(-40).ToString("o"); var client = CreateClient(request => { if (request.RequestUri?.AbsolutePath == "/tools/invoke") { using var doc = JsonDocument.Parse(request.Content!.ReadAsStringAsync().GetAwaiter().GetResult()); var agentId = doc.RootElement.GetProperty("args").GetProperty("sessionKey").GetString()! .Split(':', StringSplitOptions.RemoveEmptyEntries)[1]; object status = agentId switch { "iris" => new { status = "active", isActive = true, currentTask = "Coordinate launch", model = "openai/gpt-5.5" }, "programmer" => new { status = "idle", lastActivity = staleTimestamp, model = "openai/gpt-5.4" }, "reviewer" => new { status = "failed", error = "gateway timeout", model = "openai/gpt-5.5" }, "architekt" => new { status = "unsupported", message = "tool not available", model = "openai/gpt-5.5" }, _ => new { status = "ready", model = "openai/gpt-5.5" } }; return ToolResult(status); } return new HttpResponseMessage(System.Net.HttpStatusCode.NotFound); }, agentIds: ["iris", "programmer", "reviewer", "architekt"]); var agents = await client.GetAgentsAsync(); Assert.Collection(agents.OrderBy(a => a.Id), architekt => { Assert.Equal("architekt", architekt.Id); Assert.Equal("unsupported", architekt.StatusKind); Assert.Equal("Unsupported", architekt.StatusLabel); Assert.Equal("tool not available", architekt.StatusDetail); }, iris => { Assert.Equal("iris", iris.Id); Assert.Equal("connected", iris.StatusKind); Assert.Equal("Arbeitet", iris.StatusLabel); }, programmer => { Assert.Equal("programmer", programmer.Id); Assert.Equal("stale", programmer.StatusKind); Assert.Equal("Stale", programmer.StatusLabel); Assert.NotNull(programmer.StatusDetail); Assert.Contains("40m", programmer.StatusDetail!); }, reviewer => { Assert.Equal("reviewer", reviewer.Id); Assert.Equal("error", reviewer.StatusKind); Assert.Equal("Fehler", reviewer.StatusLabel); Assert.Equal("gateway timeout", reviewer.StatusDetail); }); } [Fact] public async Task AgentConfigService_RejectsNullBytesBeforeReplacingFile() { var agentId = $"phase-p4-{Guid.NewGuid():N}"; var workspacePath = Path.Combine("/mnt", $"workspace-{agentId}"); Directory.CreateDirectory(workspacePath); var configPath = Path.Combine(workspacePath, "TOOLS.md"); await File.WriteAllTextAsync(configPath, "original"); try { var service = new AgentConfigService(); var attempt = await service.SaveConfigFileAsync(agentId, "TOOLS.md", "bad\0content"); Assert.NotNull(attempt.Failure); Assert.Equal("validation_failed", attempt.Failure!.Code); Assert.Equal("failed", attempt.Failure.Validation.Status); Assert.Contains(attempt.Failure.Validation.Errors, error => error.Contains("null bytes", StringComparison.OrdinalIgnoreCase)); Assert.Equal("original", await File.ReadAllTextAsync(configPath)); } finally { Directory.Delete(workspacePath, recursive: true); } } [Fact] public async Task AgentConfigService_ReturnsBackupAndReloadShape_OnSuccessfulSave() { var agentId = $"phase-p4-{Guid.NewGuid():N}"; var workspacePath = Path.Combine("/mnt", $"workspace-{agentId}"); Directory.CreateDirectory(workspacePath); var configPath = Path.Combine(workspacePath, "TOOLS.md"); await File.WriteAllTextAsync(configPath, "before"); try { var service = new AgentConfigService(); var attempt = await service.SaveConfigFileAsync(agentId, "TOOLS.md", "after"); Assert.NotNull(attempt.SaveResult); var result = attempt.SaveResult!; Assert.Equal("passed", result.Validation.Status); Assert.Equal("markdown", result.Validation.FileKind); Assert.Equal("created", result.Backup.Status); Assert.True(result.Backup.BackupCreated); Assert.Equal("not_supported", result.ReloadCheck.Status); Assert.False(string.IsNullOrWhiteSpace(result.ReloadCheck.Message)); Assert.Equal("before", await File.ReadAllTextAsync(configPath + ".bak")); Assert.Equal("after", await File.ReadAllTextAsync(configPath)); } finally { Directory.Delete(workspacePath, recursive: true); } } [Fact] public async Task AgentConfigSave_AuditsFailureWithoutLeakingContent() { var configService = new FakeAgentConfigService(new AgentConfigSaveAttempt( null, new AgentConfigSaveFailure( "validation_failed", new AgentConfigValidationResult("failed", "markdown", ["Content contains null bytes."]), new AgentConfigBackupResult("not_applicable", false), new AgentConfigReloadCheckResult("not_supported", "No hot reload available.")))); var activityRepo = new CapturingActivityRepository(); var controller = new AgentsController( new FakeAgentService(), new FakeAgentRuntime(), activityRepo, configService, new FakeDashboardService(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity( [ new Claim(ClaimTypes.NameIdentifier, "bao"), new Claim(ClaimTypes.Role, "owner") ], "TestAuth")) } } }; var result = await controller.SaveConfigFile("programmer", "TOOLS.md", new SaveConfigRequest("secret\0payload"), CancellationToken.None); var statusResult = Assert.IsAssignableFrom(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); } private static OpenClawGatewayClient CreateClient( Func responder, string? requiredVersion = null, string[]? agentIds = null) { var configValues = new Dictionary { ["Integrations:OpenClaw:RequiredVersion"] = requiredVersion }; if (agentIds is not null) { var configPath = Path.GetTempFileName(); File.WriteAllText(configPath, JsonSerializer.Serialize(new { agents = new { list = agentIds.Select(id => new { id }).ToArray() } })); configValues["AgentConfigPath"] = configPath; } var configuration = new ConfigurationBuilder() .AddInMemoryCollection(configValues) .Build(); var httpClient = new HttpClient(new StubHttpMessageHandler(responder)) { BaseAddress = new Uri("http://gateway.local") }; return new OpenClawGatewayClient(httpClient, configuration); } private static HttpResponseMessage ToolResult(object payload) => new(System.Net.HttpStatusCode.OK) { Content = new StringContent( JsonSerializer.Serialize(new { ok = true, result = payload }), Encoding.UTF8, "application/json") }; } file sealed class StubHttpMessageHandler(Func responder) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(responder(request)); } file sealed class FakeAgentConfigService(AgentConfigSaveAttempt attempt) : IAgentConfigService { public IReadOnlyList GetConfigFiles(string agentId) => []; public Task GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default) => Task.FromResult(null); public Task SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default) => Task.FromResult(attempt); } file sealed class CapturingActivityRepository : IActivityRepository { public List Added { get; } = []; public Task> GetRecentAsync(int take, CancellationToken ct = default) => Task.FromResult(new List()); public Task> GetRecentForTasksAsync(IEnumerable taskIds, CancellationToken ct = default) => Task.FromResult(new List()); public Task<(List Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default) => Task.FromResult((new List(), 0)); public Task> GetByAgentAsync(string agentId, int take, CancellationToken ct = default) => Task.FromResult(new List()); public Task AddAsync(ActivityEvent activity, CancellationToken ct = default) { Added.Add(activity); return Task.FromResult(activity); } } file sealed class FakeAgentService : IAgentService { public Task> GetAgentsAsync(CancellationToken cancellationToken) => Task.FromResult>([]); public Task GetAgentAsync(string id, CancellationToken cancellationToken) => Task.FromResult(null); public Task> GetAllowedAgentIdsAsync(CancellationToken cancellationToken) => Task.FromResult>(new HashSet(StringComparer.OrdinalIgnoreCase) { "iris", "bao", "programmer" }); } file sealed class FakeAgentRuntime : Nexus.Api.Integrations.IAgentRuntime { public string Name => "fake"; public Task GetStatusAsync(CancellationToken cancellationToken) => Task.FromResult(new Nexus.Api.Integrations.AgentRuntimeStatus("fake", OperationalStatus.Online, TimeSpan.Zero, null)); public Task ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken) => Task.FromResult(new Nexus.Api.Integrations.AgentChatResult("fake", agentId, conversationId, "ok")); } file sealed class FakeDashboardService : IDashboardService { public Task GetStatusAsync() => Task.FromResult(new DashboardStatus(true, "online", 1, 0)); public Task> GetAgentsAsync() => Task.FromResult(new List()); public Task> GetOperationsAsync(int limit, string? agentFilter) => Task.FromResult(new List()); public Task SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null)); public Task> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List()); public Task> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List()); public Task GetGatewayInfoAsync(CancellationToken ct) => Task.FromResult(new GatewayRuntimeInfo(true, "http://gateway", "test", "test", true, true, "matched", DateTimeOffset.UtcNow, "ok")); public Task DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored)); public Task CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored)); public Task GetAgentModelAsync(string agentId) => Task.FromResult(null); public Task SetAgentModelAsync(string agentId, string model) => Task.FromResult(false); public Task> GetAgentActivityAsync(string agentId, int limit) => Task.FromResult(new List()); public List GetAvailableModels() => []; }