Implement MongoDB-based storage for Pack Run approval, artifact, log, and state management
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Added MongoPackRunApprovalStore for managing approval states with MongoDB.
- Introduced MongoPackRunArtifactUploader for uploading and storing artifacts.
- Created MongoPackRunLogStore to handle logging of pack run events.
- Developed MongoPackRunStateStore for persisting and retrieving pack run states.
- Implemented unit tests for MongoDB stores to ensure correct functionality.
- Added MongoTaskRunnerTestContext for setting up MongoDB test environment.
- Enhanced PackRunStateFactory to correctly initialize state with gate reasons.
This commit is contained in:
master
2025-11-07 10:01:35 +02:00
parent e5ffcd6535
commit a1ce3f74fa
122 changed files with 8730 additions and 914 deletions

View File

@@ -9,6 +9,7 @@ using System.Net;
using System.Net.Http.Json;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
@@ -22,6 +23,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Bson.IO;
using MongoDB.Driver;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Core.Jobs;
@@ -29,6 +31,7 @@ using StellaOps.Concelier.Models;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Observations;
using StellaOps.Concelier.Core.Raw;
using StellaOps.Concelier.WebService.Jobs;
using StellaOps.Concelier.WebService.Options;
using StellaOps.Concelier.WebService.Contracts;
@@ -36,6 +39,7 @@ using Xunit.Sdk;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using Xunit;
using Xunit.Abstractions;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using StellaOps.Concelier.WebService.Diagnostics;
@@ -50,9 +54,15 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
private const string TestSigningSecret = "0123456789ABCDEF0123456789ABCDEF";
private static readonly SymmetricSecurityKey TestSigningKey = new(Encoding.UTF8.GetBytes(TestSigningSecret));
private readonly ITestOutputHelper _output;
private MongoDbRunner _runner = null!;
private ConcelierApplicationFactory _factory = null!;
public WebServiceEndpointsTests(ITestOutputHelper output)
{
_output = output;
}
public Task InitializeAsync()
{
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
@@ -200,17 +210,123 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
Assert.True(response.StatusCode == HttpStatusCode.BadRequest, $"Expected 400 but got {(int)response.StatusCode}: {body}");
}
[Fact]
public async Task AdvisoryChunksEndpoint_ReturnsParagraphAnchors()
{
var newestRaw = BsonDocument.Parse(
"""
{
"summary": {
"intro": "This is a deterministic summary paragraph describing CVE-2025-0001 with remediation context for Advisory AI consumers."
},
"details": [
"Long-form remediation guidance that exceeds the minimum length threshold and mentions affected packages.",
{
"body": "Nested context that Advisory AI can cite when rendering downstream explanations."
}
]
}
""");
var olderRaw = BsonDocument.Parse(
"""
{
"summary": {
"intro": "Older paragraph that should be visible when no section filter applies."
}
}
""");
var newerCreatedAt = new DateTime(2025, 1, 7, 0, 0, 0, DateTimeKind.Utc);
var olderCreatedAt = new DateTime(2025, 1, 5, 0, 0, 0, DateTimeKind.Utc);
var newerHash = ComputeContentHash(newestRaw);
var olderHash = ComputeContentHash(olderRaw);
var documents = new[]
{
CreateChunkObservationDocument(
id: "tenant-a:chunk:newest",
tenant: "tenant-a",
createdAt: newerCreatedAt,
alias: "cve-2025-0001",
rawDocument: newestRaw),
CreateChunkObservationDocument(
id: "tenant-a:chunk:older",
tenant: "tenant-a",
createdAt: olderCreatedAt,
alias: "cve-2025-0001",
rawDocument: olderRaw)
};
await SeedObservationDocumentsAsync(documents);
await SeedAdvisoryRawDocumentsAsync(
CreateAdvisoryRawDocument("tenant-a", "nvd", "tenant-a:chunk:newest", newerHash, newestRaw.DeepClone().AsBsonDocument),
CreateAdvisoryRawDocument("tenant-a", "nvd", "tenant-a:chunk:older", olderHash, olderRaw.DeepClone().AsBsonDocument));
using var client = _factory.CreateClient();
var response = await client.GetAsync("/advisories/cve-2025-0001/chunks?tenant=tenant-a&section=summary&format=csaf");
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadAsStringAsync();
using var document = JsonDocument.Parse(payload);
var root = document.RootElement;
Assert.Equal("cve-2025-0001", root.GetProperty("advisoryKey").GetString());
Assert.Equal(1, root.GetProperty("total").GetInt32());
Assert.False(root.GetProperty("truncated").GetBoolean());
var chunk = Assert.Single(root.GetProperty("chunks").EnumerateArray());
Assert.Equal("summary", chunk.GetProperty("section").GetString());
Assert.Equal("summary.intro", chunk.GetProperty("paragraphId").GetString());
var text = chunk.GetProperty("text").GetString();
Assert.False(string.IsNullOrWhiteSpace(text));
Assert.Contains("deterministic summary paragraph", text, StringComparison.OrdinalIgnoreCase);
var metadata = chunk.GetProperty("metadata");
Assert.Equal("summary.intro", metadata.GetProperty("path").GetString());
Assert.Equal("csaf", metadata.GetProperty("format").GetString());
var sources = root.GetProperty("sources").EnumerateArray().ToArray();
Assert.Equal(2, sources.Length);
Assert.Equal("tenant-a:chunk:newest", sources[0].GetProperty("observationId").GetString());
Assert.Equal("tenant-a:chunk:older", sources[1].GetProperty("observationId").GetString());
Assert.All(
sources,
source => Assert.True(string.Equals("csaf", source.GetProperty("format").GetString(), StringComparison.OrdinalIgnoreCase)));
}
[Fact]
public async Task AdvisoryChunksEndpoint_ReturnsNotFoundWhenAdvisoryMissing()
{
await SeedObservationDocumentsAsync(BuildSampleObservationDocuments());
using var client = _factory.CreateClient();
var response = await client.GetAsync("/advisories/cve-2099-9999/chunks?tenant=tenant-a");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var payload = await response.Content.ReadAsStringAsync();
using var document = JsonDocument.Parse(payload);
var root = document.RootElement;
Assert.Equal("https://stellaops.org/problems/not-found", root.GetProperty("type").GetString());
Assert.Equal("Advisory not found", root.GetProperty("title").GetString());
Assert.Contains("cve-2099-9999", root.GetProperty("detail").GetString(), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task AdvisoryIngestEndpoint_PersistsDocumentAndSupportsReadback()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-ingest");
const string upstreamId = "GHSA-INGEST-0001";
var ingestRequest = BuildAdvisoryIngestRequest(
contentHash: "sha256:abc123",
upstreamId: "GHSA-INGEST-0001");
contentHash: null,
upstreamId: upstreamId);
var ingestResponse = await client.PostAsJsonAsync("/ingest/advisory", ingestRequest);
if (ingestResponse.StatusCode != HttpStatusCode.Created)
{
WriteProgramLogs();
}
Assert.Equal(HttpStatusCode.Created, ingestResponse.StatusCode);
var ingestPayload = await ingestResponse.Content.ReadFromJsonAsync<AdvisoryIngestResponse>();
@@ -218,7 +334,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
Assert.True(ingestPayload!.Inserted);
Assert.False(string.IsNullOrWhiteSpace(ingestPayload.Id));
Assert.Equal("tenant-ingest", ingestPayload.Tenant);
Assert.Equal("sha256:abc123", ingestPayload.ContentHash);
Assert.Equal(ComputeDeterministicContentHash(upstreamId), ingestPayload.ContentHash);
Assert.NotNull(ingestResponse.Headers.Location);
var locationValue = ingestResponse.Headers.Location!.ToString();
Assert.False(string.IsNullOrWhiteSpace(locationValue));
@@ -230,8 +346,8 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
Assert.Equal(ingestPayload.Id, decodedSegment);
var duplicateResponse = await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest(
contentHash: "sha256:abc123",
upstreamId: "GHSA-INGEST-0001"));
contentHash: null,
upstreamId: upstreamId));
Assert.Equal(HttpStatusCode.OK, duplicateResponse.StatusCode);
var duplicatePayload = await duplicateResponse.Content.ReadFromJsonAsync<AdvisoryIngestResponse>();
Assert.NotNull(duplicatePayload);
@@ -247,7 +363,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
Assert.NotNull(record);
Assert.Equal(ingestPayload.Id, record!.Id);
Assert.Equal("tenant-ingest", record.Tenant);
Assert.Equal("sha256:abc123", record.Document.Upstream.ContentHash);
Assert.Equal(ComputeDeterministicContentHash(upstreamId), record.Document.Upstream.ContentHash);
}
using (var listRequest = new HttpRequestMessage(HttpMethod.Get, "/advisories/raw?limit=10"))
@@ -451,6 +567,54 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
Assert.Equal(HttpStatusCode.Forbidden, crossTenantResponse.StatusCode);
}
[Fact]
public async Task AdvisoryIngestEndpoint_RejectsTenantOutsideAllowlist()
{
var environment = new Dictionary<string, string?>
{
["CONCELIER_AUTHORITY__ENABLED"] = "true",
["CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK"] = "false",
["CONCELIER_AUTHORITY__ISSUER"] = TestAuthorityIssuer,
["CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA"] = "false",
["CONCELIER_AUTHORITY__AUDIENCES__0"] = TestAuthorityAudience,
["CONCELIER_AUTHORITY__CLIENTID"] = "webservice-tests",
["CONCELIER_AUTHORITY__CLIENTSECRET"] = "unused",
["CONCELIER_AUTHORITY__REQUIREDTENANTS__0"] = "tenant-auth"
};
using var factory = new ConcelierApplicationFactory(
_runner.ConnectionString,
authority =>
{
authority.Enabled = true;
authority.AllowAnonymousFallback = false;
authority.Issuer = TestAuthorityIssuer;
authority.RequireHttpsMetadata = false;
authority.Audiences.Clear();
authority.Audiences.Add(TestAuthorityAudience);
authority.ClientId = "webservice-tests";
authority.ClientSecret = "unused";
authority.RequiredTenants.Clear();
authority.RequiredTenants.Add("tenant-auth");
},
environment);
using var client = factory.CreateClient();
var allowedToken = CreateTestToken("tenant-auth", StellaOpsScopes.AdvisoryIngest);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", allowedToken);
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-auth");
var allowedResponse = await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:allow-1", "GHSA-ALLOW-001"));
Assert.Equal(HttpStatusCode.Created, allowedResponse.StatusCode);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", CreateTestToken("tenant-blocked", StellaOpsScopes.AdvisoryIngest));
client.DefaultRequestHeaders.Remove("X-Stella-Tenant");
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-blocked");
var forbiddenResponse = await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:allow-2", "GHSA-ALLOW-002"));
Assert.Equal(HttpStatusCode.Forbidden, forbiddenResponse.StatusCode);
}
[Fact]
public async Task AdvisoryIngestEndpoint_ReturnsGuardViolationWhenContentHashMissing()
{
@@ -1244,6 +1408,55 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
};
}
private static AdvisoryObservationDocument CreateChunkObservationDocument(
string id,
string tenant,
DateTime createdAt,
string alias,
BsonDocument rawDocument)
{
var document = CreateObservationDocument(
id,
tenant,
createdAt,
aliases: new[] { alias });
var clone = rawDocument.DeepClone().AsBsonDocument;
document.Content.Raw = clone;
document.Upstream.ContentHash = ComputeContentHash(clone);
return document;
}
private static readonly DateTimeOffset DefaultIngestTimestamp = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static string ComputeContentHash(BsonDocument rawDocument)
{
using var sha256 = SHA256.Create();
var canonical = rawDocument.ToJson(new JsonWriterSettings
{
OutputMode = JsonOutputMode.RelaxedExtendedJson
});
var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(canonical));
return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}";
}
private static string ComputeDeterministicContentHash(string upstreamId)
{
var raw = CreateJsonElement($@"{{""id"":""{upstreamId}"",""modified"":""{DefaultIngestTimestamp:O}""}}");
return NormalizeContentHash(null, raw, enforceContentHash: true);
}
private static string NormalizeContentHash(string? value, JsonElement raw, bool enforceContentHash)
{
if (!enforceContentHash)
{
return value ?? string.Empty;
}
using var sha256 = SHA256.Create();
var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(raw.GetRawText()));
return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}";
}
private sealed record ReplayResponse(
string VulnerabilityKey,
DateTimeOffset? AsOf,
@@ -1690,8 +1903,18 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
return $"advisory_raw:{vendorSegment}:{upstreamSegment}:{hashSegment}";
}
private static AdvisoryIngestRequest BuildAdvisoryIngestRequest(string contentHash, string upstreamId)
private void WriteProgramLogs()
{
var entries = _factory.LoggerProvider.Snapshot("StellaOps.Concelier.WebService.Program");
foreach (var entry in entries)
{
_output.WriteLine($"[PROGRAM LOG] {entry.Level}: {entry.Message}");
}
}
private static AdvisoryIngestRequest BuildAdvisoryIngestRequest(string? contentHash, string upstreamId)
{
var normalizedContentHash = contentHash ?? ComputeDeterministicContentHash(upstreamId);
var raw = CreateJsonElement($@"{{""id"":""{upstreamId}"",""modified"":""{DateTime.UtcNow:O}""}}");
var references = new[]
{
@@ -1704,7 +1927,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
upstreamId,
"2025-01-01T00:00:00Z",
DateTimeOffset.UtcNow,
contentHash,
normalizedContentHash,
new AdvisorySignatureRequest(false, null, null, null, null, null),
new Dictionary<string, string> { ["http.method"] = "GET" }),
new AdvisoryContentRequest("osv", "1.3.0", raw, null),