549 lines
20 KiB
C#
549 lines
20 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
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 TaskWorkflowTests
|
|
{
|
|
[Fact]
|
|
public async Task CreateAgentTaskAsync_PreservesConfiguredAssigneeAndBacklogState_WhenPlannedChildTask()
|
|
{
|
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
|
|
|
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
|
|
"Parent", "Coordination", "iris", "High", "iris", null, CancellationToken.None);
|
|
|
|
var child = await fixture.TaskService.CreateAgentTaskAsync(
|
|
"PO spec",
|
|
"Prepare specification",
|
|
"iris",
|
|
"Medium",
|
|
"product-owner",
|
|
"programmer-fast",
|
|
parent.Id,
|
|
startsInProgress: false,
|
|
initialState: null,
|
|
ct: CancellationToken.None);
|
|
|
|
Assert.Equal("Backlog", child.State);
|
|
Assert.Equal("product-owner", child.AssignedTo);
|
|
Assert.Equal("programmer-fast", child.ExpectedFrom);
|
|
Assert.True(child.IsAgentTask);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDashboardTaskByIdAsync_MapsChildDelegationAndActivity()
|
|
{
|
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
|
|
|
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
|
|
"Parent", null, "iris", "High", "iris", null, CancellationToken.None);
|
|
|
|
var child = await fixture.TaskService.CreateAgentTaskAsync(
|
|
"Implement",
|
|
"Code changes",
|
|
"iris",
|
|
"High",
|
|
"programmer-fast",
|
|
"programmer-fast",
|
|
parent.Id,
|
|
startsInProgress: false,
|
|
initialState: null,
|
|
ct: CancellationToken.None);
|
|
|
|
var dto = await fixture.TaskService.GetDashboardTaskByIdAsync(child.Id, CancellationToken.None);
|
|
|
|
Assert.NotNull(dto);
|
|
Assert.True(dto!.HasVisibleDelegation);
|
|
Assert.NotNull(dto.LastActivityMessage);
|
|
Assert.Equal("programmer-fast", dto.AssignedTo);
|
|
Assert.Equal("programmer-fast", dto.ExpectedFrom);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BridgeGetChildTasksAsync_ReturnsMappedActivityAndVisibleDelegation()
|
|
{
|
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
|
|
|
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
|
|
"Parent", null, "iris", "High", "iris", null, CancellationToken.None);
|
|
|
|
await fixture.TaskBridgeService.CreateChildTaskAsync(
|
|
parent.Id,
|
|
"Review",
|
|
"Review implementation",
|
|
"iris",
|
|
"Medium",
|
|
"reviewer",
|
|
"reviewer",
|
|
startsInProgress: false,
|
|
ct: CancellationToken.None);
|
|
|
|
var children = await fixture.TaskBridgeService.GetChildTasksAsync(parent.Id, CancellationToken.None);
|
|
var child = Assert.Single(children);
|
|
|
|
Assert.True(child.HasVisibleDelegation);
|
|
Assert.NotNull(child.LastActivityMessage);
|
|
Assert.Equal("reviewer", child.AssignedTo);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GatewayBridgeController_GetBoard_AcceptsProgrammerFastHeader()
|
|
{
|
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
|
|
|
var controller = new GatewayBridgeController(
|
|
fixture.TaskBridgeService,
|
|
fixture.AgentService,
|
|
fixture.Configuration,
|
|
NullLogger<GatewayBridgeController>.Instance)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
|
{
|
|
["X-Agent-Id"] = "programmer-fast"
|
|
})
|
|
}
|
|
};
|
|
|
|
var result = await controller.GetBoard(CancellationToken.None);
|
|
Assert.IsType<OkObjectResult>(result.Result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GatewayBridgeController_GetBoard_AcceptsServiceKeyWithoutConfiguredNexusSystemAgent()
|
|
{
|
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
|
|
|
var controller = new GatewayBridgeController(
|
|
fixture.TaskBridgeService,
|
|
fixture.AgentService,
|
|
fixture.Configuration,
|
|
NullLogger<GatewayBridgeController>.Instance)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
|
{
|
|
["X-Nexus-Api-Key"] = "test-service-key"
|
|
})
|
|
}
|
|
};
|
|
|
|
var result = await controller.GetBoard(CancellationToken.None);
|
|
Assert.IsType<OkObjectResult>(result.Result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DashboardController_GetBoard_AcceptsServiceKeyWithoutJwt()
|
|
{
|
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
|
|
|
var controller = new DashboardController(
|
|
new FakeDashboardService(),
|
|
fixture.TaskService,
|
|
fixture.ActivityRepository,
|
|
new HttpContextAccessor(),
|
|
fixture.AgentService,
|
|
fixture.Configuration,
|
|
fixture.NotificationService,
|
|
fixture.LiveUpdateService)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
|
{
|
|
["X-Nexus-Api-Key"] = "test-service-key"
|
|
})
|
|
}
|
|
};
|
|
|
|
var result = await controller.GetBoard(CancellationToken.None);
|
|
Assert.IsType<OkObjectResult>(result.Result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TasksController_GetBoard_AcceptsProgrammerFastHeader()
|
|
{
|
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
|
|
|
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
|
{
|
|
["X-Agent-Id"] = "programmer-fast"
|
|
})
|
|
}
|
|
};
|
|
|
|
var result = await controller.GetBoard(CancellationToken.None);
|
|
var httpContext = new DefaultHttpContext();
|
|
await result.ExecuteAsync(httpContext);
|
|
|
|
Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TasksController_ResetStale_Anonymous_IsUnauthorized()
|
|
{
|
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
|
|
|
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = TaskWorkflowFixture.CreateHttpContext()
|
|
}
|
|
};
|
|
|
|
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
|
var httpContext = new DefaultHttpContext();
|
|
await result.ExecuteAsync(httpContext);
|
|
|
|
Assert.Equal(StatusCodes.Status401Unauthorized, httpContext.Response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TasksController_ResetStale_UnknownAgentHeader_IsForbidden()
|
|
{
|
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
|
|
|
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
|
{
|
|
["X-Agent-Id"] = "unknown-agent"
|
|
})
|
|
}
|
|
};
|
|
|
|
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
|
var httpContext = new DefaultHttpContext();
|
|
await result.ExecuteAsync(httpContext);
|
|
|
|
Assert.Equal(StatusCodes.Status403Forbidden, httpContext.Response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TasksController_ResetStale_OrdinaryJwtUser_IsForbidden()
|
|
{
|
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
|
|
|
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("user-1", "user"))
|
|
}
|
|
};
|
|
|
|
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
|
var httpContext = new DefaultHttpContext();
|
|
await result.ExecuteAsync(httpContext);
|
|
|
|
Assert.Equal(StatusCodes.Status403Forbidden, httpContext.Response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TasksController_ResetStale_ServiceKey_IsAllowed()
|
|
{
|
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
|
|
|
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
|
{
|
|
["X-Nexus-Api-Key"] = "test-service-key"
|
|
})
|
|
}
|
|
};
|
|
|
|
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
|
var httpContext = new DefaultHttpContext();
|
|
await result.ExecuteAsync(httpContext);
|
|
|
|
Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TasksController_ResetStale_IrisHeader_IsAllowed()
|
|
{
|
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
|
|
|
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = TaskWorkflowFixture.CreateHttpContext(agentId: "iris")
|
|
}
|
|
};
|
|
|
|
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
|
var httpContext = new DefaultHttpContext();
|
|
await result.ExecuteAsync(httpContext);
|
|
|
|
Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GatewayBridgeController_GetBoard_OrdinaryJwtUser_IsUnauthorized()
|
|
{
|
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
|
|
|
var controller = new GatewayBridgeController(
|
|
fixture.TaskBridgeService,
|
|
fixture.AgentService,
|
|
fixture.Configuration,
|
|
NullLogger<GatewayBridgeController>.Instance)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("user-1", "user"))
|
|
}
|
|
};
|
|
|
|
var result = await controller.GetBoard(CancellationToken.None);
|
|
Assert.IsType<UnauthorizedObjectResult>(result.Result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GatewayBridgeController_GetBoard_AdminJwt_IsAllowed()
|
|
{
|
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
|
|
|
var controller = new GatewayBridgeController(
|
|
fixture.TaskBridgeService,
|
|
fixture.AgentService,
|
|
fixture.Configuration,
|
|
NullLogger<GatewayBridgeController>.Instance)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("bao", "admin"))
|
|
}
|
|
};
|
|
|
|
var result = await controller.GetBoard(CancellationToken.None);
|
|
Assert.IsType<OkObjectResult>(result.Result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateChildTaskAsync_TransitionsBacklogParent_WhenCallerIsProgrammerFast()
|
|
{
|
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
|
fixture.SetCallerAgent("programmer-fast");
|
|
|
|
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
|
|
"Parent", "Coordination", "iris", "High", "iris", null, CancellationToken.None);
|
|
|
|
var result = await fixture.TaskBridgeService.CreateChildTaskAsync(
|
|
parent.Id,
|
|
"Implement",
|
|
"Ship the change",
|
|
"programmer-fast",
|
|
"Medium",
|
|
"programmer-fast",
|
|
"programmer-fast",
|
|
startsInProgress: false,
|
|
ct: CancellationToken.None);
|
|
|
|
var updatedParent = await fixture.TaskService.GetByIdAsync(parent.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskBridgeOutcome.Success, result.Outcome);
|
|
Assert.NotNull(updatedParent);
|
|
Assert.Equal("In progress", updatedParent!.State);
|
|
}
|
|
}
|
|
|
|
file sealed class TaskWorkflowFixture : IAsyncDisposable
|
|
{
|
|
private readonly NexusDbContext _db;
|
|
|
|
private TaskWorkflowFixture(
|
|
NexusDbContext db,
|
|
IConfiguration configuration,
|
|
ITaskRepository taskRepository,
|
|
IActivityRepository activityRepository,
|
|
INotificationService notificationService,
|
|
ILiveUpdateService liveUpdateService,
|
|
ITaskService taskService,
|
|
ITaskBridgeService taskBridgeService,
|
|
IAgentService agentService,
|
|
HttpContextAccessor httpContextAccessor)
|
|
{
|
|
_db = db;
|
|
Configuration = configuration;
|
|
TaskRepository = taskRepository;
|
|
ActivityRepository = activityRepository;
|
|
NotificationService = notificationService;
|
|
LiveUpdateService = liveUpdateService;
|
|
TaskService = taskService;
|
|
TaskBridgeService = taskBridgeService;
|
|
AgentService = agentService;
|
|
HttpContextAccessor = httpContextAccessor;
|
|
}
|
|
|
|
public IConfiguration Configuration { get; }
|
|
public ITaskRepository TaskRepository { get; }
|
|
public IActivityRepository ActivityRepository { get; }
|
|
public INotificationService NotificationService { get; }
|
|
public ILiveUpdateService LiveUpdateService { get; }
|
|
public ITaskService TaskService { get; }
|
|
public ITaskBridgeService TaskBridgeService { get; }
|
|
public IAgentService AgentService { get; }
|
|
public HttpContextAccessor HttpContextAccessor { get; }
|
|
|
|
public static async Task<TaskWorkflowFixture> CreateAsync()
|
|
{
|
|
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
|
|
var db = new NexusDbContext(options);
|
|
await db.Database.EnsureCreatedAsync();
|
|
|
|
var configPath = CreateAgentConfigFile();
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["AgentConfigPath"] = configPath,
|
|
["NexusApiKey"] = "test-service-key"
|
|
})
|
|
.Build();
|
|
|
|
var agentService = new AgentService(configuration, new FakeRuntime());
|
|
var liveUpdateService = new LiveUpdateService();
|
|
var activityRepository = new ActivityRepository(db);
|
|
var taskRepository = new TaskRepository(db);
|
|
var notificationService = new NotificationService(db, liveUpdateService);
|
|
var httpContextAccessor = new HttpContextAccessor { HttpContext = CreateHttpContext(agentId: "iris") };
|
|
|
|
var taskService = new TaskService(
|
|
taskRepository,
|
|
activityRepository,
|
|
notificationService,
|
|
agentService,
|
|
httpContextAccessor,
|
|
liveUpdateService);
|
|
|
|
var taskBridgeService = new TaskBridgeService(
|
|
taskService,
|
|
agentService,
|
|
activityRepository,
|
|
notificationService,
|
|
liveUpdateService);
|
|
|
|
return new TaskWorkflowFixture(
|
|
db,
|
|
configuration,
|
|
taskRepository,
|
|
activityRepository,
|
|
notificationService,
|
|
liveUpdateService,
|
|
taskService,
|
|
taskBridgeService,
|
|
agentService,
|
|
httpContextAccessor);
|
|
}
|
|
|
|
public static DefaultHttpContext CreateHttpContext(
|
|
string? agentId = null,
|
|
Dictionary<string, string>? headers = null,
|
|
ClaimsPrincipal? user = null)
|
|
{
|
|
var httpContext = new DefaultHttpContext();
|
|
if (!string.IsNullOrWhiteSpace(agentId))
|
|
httpContext.Request.Headers["X-Agent-Id"] = agentId;
|
|
|
|
if (headers is not null)
|
|
{
|
|
foreach (var (key, value) in headers)
|
|
httpContext.Request.Headers[key] = value;
|
|
}
|
|
|
|
httpContext.User = user ?? new ClaimsPrincipal(new ClaimsIdentity());
|
|
return httpContext;
|
|
}
|
|
|
|
public static ClaimsPrincipal CreateUser(string userId, string role)
|
|
{
|
|
var claims = new[]
|
|
{
|
|
new Claim(ClaimTypes.NameIdentifier, userId),
|
|
new Claim(ClaimTypes.Role, role)
|
|
};
|
|
|
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
|
}
|
|
|
|
public void SetCallerAgent(string agentId)
|
|
{
|
|
HttpContextAccessor.HttpContext = CreateHttpContext(agentId: agentId);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _db.DisposeAsync();
|
|
}
|
|
|
|
private static string CreateAgentConfigFile()
|
|
{
|
|
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
|
|
File.WriteAllText(path,
|
|
"""
|
|
{
|
|
"agents": {
|
|
"defaults": {
|
|
"workspace": "/workspace/default",
|
|
"model": {
|
|
"primary": "deepseek/deepseek-v4-flash"
|
|
}
|
|
},
|
|
"list": [
|
|
{ "id": "iris", "name": "iris", "model": { "primary": "openai/gpt-5.5" } },
|
|
{ "id": "product-owner", "name": "product-owner", "model": { "primary": "openai/gpt-5.5" } },
|
|
{ "id": "programmer", "name": "programmer", "model": { "primary": "openai/gpt-5.4" } },
|
|
{ "id": "programmer-fast", "name": "programmer-fast", "model": { "primary": "openai/gpt-5.3-codex-spark" } },
|
|
{ "id": "reviewer", "name": "reviewer", "model": { "primary": "openai/gpt-5.5" } }
|
|
]
|
|
}
|
|
}
|
|
""");
|
|
|
|
return path;
|
|
}
|
|
}
|
|
|
|
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<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 List<ModelOption> GetAvailableModels() => [];
|
|
}
|