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
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:
@@ -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§ion=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),
|
||||
|
||||
Reference in New Issue
Block a user