feat(advisoryai): register runs service and expose canonical /v1/advisory-ai/runs endpoint

- Register RunService and IRunStore (InMemoryRunStore) in DI
- Disambiguate IGuidGenerator namespaces (Chat vs Runs)
- Mount RunEndpoints at canonical /v1/advisory-ai/runs path
- Make RunService public for WebService composition
- Add integration tests for runs authorization and CRUD

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
master
2026-03-09 07:53:17 +02:00
parent e0c79e0dc0
commit 354654ea84
5 changed files with 118 additions and 3 deletions

View File

@@ -17,6 +17,7 @@ using StellaOps.AdvisoryAI.PolicyStudio;
using StellaOps.AdvisoryAI.Providers;
using StellaOps.AdvisoryAI.Queue;
using StellaOps.AdvisoryAI.Remediation;
using StellaOps.AdvisoryAI.Runs;
using StellaOps.OpsMemory.Storage;
using System;
using System.Collections.Generic;
@@ -137,7 +138,10 @@ public static class ServiceCollectionExtensions
services.AddOptions<ConversationOptions>()
.Bind(configuration.GetSection("AdvisoryAI:Chat"))
.ValidateOnStart();
services.TryAddSingleton<IGuidGenerator, DefaultGuidGenerator>();
services.TryAddSingleton<StellaOps.AdvisoryAI.Chat.IGuidGenerator, StellaOps.AdvisoryAI.Chat.DefaultGuidGenerator>();
services.TryAddSingleton<StellaOps.AdvisoryAI.Runs.IGuidGenerator, StellaOps.AdvisoryAI.Runs.DefaultGuidGenerator>();
services.TryAddSingleton<IRunStore, InMemoryRunStore>();
services.TryAddSingleton<IRunService, RunService>();
services.TryAddSingleton<IConversationService, ConversationService>();
services.TryAddSingleton<ConversationContextBuilder>();
services.TryAddSingleton<ChatPromptAssembler>();

View File

@@ -29,7 +29,7 @@ public static class RunEndpoints
/// <returns>The route group builder.</returns>
public static RouteGroupBuilder MapRunEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/api/v1/runs")
var group = builder.MapGroup("/v1/advisory-ai/runs")
.WithTags("Runs")
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
.RequireTenant();

View File

@@ -300,6 +300,9 @@ app.MapChatEndpoints();
// AI Attestations endpoints (Sprint: SPRINT_20260109_011_001 Task: AIAT-009)
app.MapAttestationEndpoints();
// AI Runs endpoints (Sprint: SPRINT_20260109_011_003 Task: RUN-006)
app.MapRunEndpoints();
// Evidence Pack endpoints (Sprint: SPRINT_20260109_011_005 Task: EVPK-010)
app.MapEvidencePackEndpoints();

View File

@@ -15,7 +15,7 @@ namespace StellaOps.AdvisoryAI.Runs;
/// Implementation of the run service.
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-003
/// </summary>
internal sealed class RunService : IRunService
public sealed class RunService : IRunService
{
private readonly IRunStore _store;
private readonly TimeProvider _timeProvider;

View File

@@ -0,0 +1,108 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Integration;
[Trait("Category", TestCategories.Integration)]
public sealed class RunEndpointsIntegrationTests : IDisposable
{
private readonly WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program> _factory = new();
[Fact]
public async Task QueryRuns_WithoutScope_ReturnsForbidden()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
client.DefaultRequestHeaders.Add("X-Tenant-Id", "test-tenant");
var response = await client.GetAsync("/v1/advisory-ai/runs");
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}
[Fact]
public async Task CreateAndQueryRuns_WithOperateAndViewScopes_ReturnExpectedPayloads()
{
using var operateClient = CreateClientWithScopes("advisory-ai:operate advisory-ai:view");
var createResponse = await operateClient.PostAsJsonAsync(
"/v1/advisory-ai/runs",
new
{
title = "Investigate CVE-2026-0001",
objective = "Confirm exploitability",
context = new
{
focusedCveId = "CVE-2026-0001",
focusedComponent = "pkg:npm/example@1.0.0"
}
});
createResponse.StatusCode.Should().Be(HttpStatusCode.Created);
var created = await createResponse.Content.ReadFromJsonAsync<RunResponse>();
created.Should().NotBeNull();
created!.RunId.Should().NotBeNullOrWhiteSpace();
created.Title.Should().Be("Investigate CVE-2026-0001");
using var viewClient = CreateClientWithScopes("advisory-ai:view");
var queryResponse = await viewClient.GetAsync("/v1/advisory-ai/runs?take=10");
queryResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var query = await queryResponse.Content.ReadFromJsonAsync<RunQueryResponse>();
query.Should().NotBeNull();
query!.TotalCount.Should().BeGreaterThanOrEqualTo(1);
query.Runs.Should().Contain(run => run.RunId == created.RunId);
var getResponse = await viewClient.GetAsync($"/v1/advisory-ai/runs/{created.RunId}");
getResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var loaded = await getResponse.Content.ReadFromJsonAsync<RunResponse>();
loaded.Should().NotBeNull();
loaded!.RunId.Should().Be(created.RunId);
loaded.Context.Should().NotBeNull();
loaded.Context!.FocusedCveId.Should().Be("CVE-2026-0001");
}
public void Dispose()
{
_factory.Dispose();
}
private HttpClient CreateClientWithScopes(string scopes)
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "test-user");
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", scopes);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
client.DefaultRequestHeaders.Add("X-Tenant-Id", "test-tenant");
client.DefaultRequestHeaders.Add("X-User-Id", "test-user");
client.DefaultRequestHeaders.Add("X-StellaOps-Client", "test-client");
return client;
}
private sealed record RunQueryResponse
{
public required RunResponse[] Runs { get; init; }
public required int TotalCount { get; init; }
}
private sealed record RunResponse
{
public required string RunId { get; init; }
public required string Title { get; init; }
public RunContextResponse? Context { get; init; }
}
private sealed record RunContextResponse
{
public string? FocusedCveId { get; init; }
}
}