Files
git.stella-ops.org/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs

4003 lines
183 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Logging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.InMemoryRunner;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Documents.IO;
using StellaOps.Concelier.InMemoryDriver;
using StellaOps.Concelier.Core.Attestation;
using static StellaOps.Concelier.WebService.Program;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage.Observations;
using StellaOps.Concelier.Storage.Linksets;
using StellaOps.Concelier.Core.Raw;
using StellaOps.Concelier.Core.Observations;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Concelier.Models.Observations;
using StellaOps.Concelier.Persistence.Postgres;
using StellaOps.Concelier.RawModels;
using StellaOps.Concelier.WebService.Jobs;
using StellaOps.Concelier.WebService.Options;
using StellaOps.Concelier.WebService.Contracts;
using StellaOps.Concelier.WebService;
using Xunit.Sdk;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using Xunit;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using StellaOps.Concelier.WebService.Diagnostics;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Cryptography;
using DsseProvenance = StellaOps.Provenance.DsseProvenance;
using TrustInfo = StellaOps.Provenance.TrustInfo;
using DocumentObject = StellaOps.Concelier.Documents.DocumentObject;
namespace StellaOps.Concelier.WebService.Tests;
public sealed class WebServiceEndpointsTests : IAsyncLifetime
{
private const string TestAuthorityIssuer = "https://authority.example";
private const string TestAuthorityAudience = "api://concelier";
private const string TestSigningSecret = "0123456789ABCDEF0123456789ABCDEF";
private static readonly SymmetricSecurityKey TestSigningKey = new(Encoding.UTF8.GetBytes(TestSigningSecret));
private readonly ITestOutputHelper _output;
private InMemoryDbRunner _runner = null!;
private ConcelierApplicationFactory _factory = null!;
public WebServiceEndpointsTests(ITestOutputHelper output)
{
_output = output;
}
public ValueTask InitializeAsync()
{
// Reset shared in-memory database state before each test
InMemoryClient.ResetSharedState();
_runner = InMemoryDbRunner.Start();
// Use an empty connection string - the factory sets a default Postgres connection string
// and the stub services bypass actual database operations
_factory = new ConcelierApplicationFactory(string.Empty);
WarmupFactory(_factory);
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync()
{
_factory.Dispose();
_runner.Dispose();
// Clear shared state after test completes
InMemoryClient.ResetSharedState();
return ValueTask.CompletedTask;
}
[Fact]
public async Task HealthAndReadyEndpointsRespond()
{
using var client = _factory.CreateClient();
var healthResponse = await client.GetAsync("/health");
if (!healthResponse.IsSuccessStatusCode)
{
var body = await healthResponse.Content.ReadAsStringAsync();
throw new Xunit.Sdk.XunitException($"/health failed: {(int)healthResponse.StatusCode} {body}");
}
var readyResponse = await client.GetAsync("/ready");
if (!readyResponse.IsSuccessStatusCode)
{
var body = await readyResponse.Content.ReadAsStringAsync();
throw new Xunit.Sdk.XunitException($"/ready failed: {(int)readyResponse.StatusCode} {body}");
}
var healthPayload = await healthResponse.Content.ReadFromJsonAsync<HealthPayload>();
Assert.NotNull(healthPayload);
Assert.Equal("healthy", healthPayload!.Status);
Assert.Equal("postgres", healthPayload.Storage.Backend);
var readyPayload = await readyResponse.Content.ReadFromJsonAsync<ReadyPayload>();
Assert.NotNull(readyPayload);
Assert.True(readyPayload!.Status is "ready" or "degraded");
Assert.Equal("postgres", readyPayload.Storage.Backend);
}
[Fact]
public async Task ObservationsEndpoint_ReturnsTenantScopedResults()
{
await SeedObservationDocumentsAsync(BuildSampleObservationDocuments());
using var client = _factory.CreateClient();
var response = await client.GetAsync("/concelier/observations?tenant=tenant-a&alias=CVE-2025-0001");
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
throw new XunitException($"/concelier/observations failed: {(int)response.StatusCode} {body}");
}
using var document = await response.Content.ReadFromJsonAsync<JsonDocument>();
Assert.NotNull(document);
var root = document!.RootElement;
var observations = root.GetProperty("observations").EnumerateArray().ToArray();
Assert.Equal(2, observations.Length);
Assert.Equal("tenant-a:ghsa:beta:1", observations[0].GetProperty("observationId").GetString());
Assert.Equal("tenant-a:nvd:alpha:1", observations[1].GetProperty("observationId").GetString());
var linkset = root.GetProperty("linkset");
Assert.Equal(new[] { "cve-2025-0001", "ghsa-2025-xyz" }, linkset.GetProperty("aliases").EnumerateArray().Select(x => x.GetString()).ToArray());
Assert.Equal(new[] { "pkg:npm/demo@1.0.0", "pkg:npm/demo@1.1.0" }, linkset.GetProperty("purls").EnumerateArray().Select(x => x.GetString()).ToArray());
Assert.Equal(new[] { "cpe:/a:vendor:product:1.0", "cpe:/a:vendor:product:1.1" }, linkset.GetProperty("cpes").EnumerateArray().Select(x => x.GetString()).ToArray());
var references = linkset.GetProperty("references").EnumerateArray().ToArray();
Assert.Equal(2, references.Length);
Assert.Equal("advisory", references[0].GetProperty("type").GetString());
Assert.Equal("https://example.test/advisory-1", references[0].GetProperty("url").GetString());
Assert.Equal("patch", references[1].GetProperty("type").GetString());
var confidence = linkset.GetProperty("confidence").GetDouble();
// Real query service computes confidence based on data consistency between observations.
// Since the two observations have different purls/cpes, confidence will be < 1.0
Assert.InRange(confidence, 0.0, 1.0);
var conflicts = linkset.GetProperty("conflicts").EnumerateArray().ToArray();
// Real query service detects conflicts between observations with differing linkset data
// (conflicts are expected when observations have different purls/cpes for same alias)
Assert.False(root.GetProperty("hasMore").GetBoolean());
Assert.True(root.GetProperty("nextCursor").ValueKind == JsonValueKind.Null);
}
[Fact]
public async Task ObservationsEndpoint_AppliesObservationIdFilter()
{
await SeedObservationDocumentsAsync(BuildSampleObservationDocuments());
using var client = _factory.CreateClient();
var observationId = Uri.EscapeDataString("tenant-a:ghsa:beta:1");
var response = await client.GetAsync($"/concelier/observations?tenant=tenant-a&observationId={observationId}&cpe=cpe:/a:vendor:product:1.1");
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
throw new XunitException($"/concelier/observations filter failed: {(int)response.StatusCode} {body}");
}
using var document = await response.Content.ReadFromJsonAsync<JsonDocument>();
Assert.NotNull(document);
var root = document!.RootElement;
var observations = root.GetProperty("observations").EnumerateArray().ToArray();
Assert.Single(observations);
Assert.Equal("tenant-a:ghsa:beta:1", observations[0].GetProperty("observationId").GetString());
Assert.Equal(new[] { "pkg:npm/demo@1.1.0" }, observations[0].GetProperty("linkset").GetProperty("purls").EnumerateArray().Select(x => x.GetString()).ToArray());
Assert.Equal(new[] { "cpe:/a:vendor:product:1.1" }, observations[0].GetProperty("linkset").GetProperty("cpes").EnumerateArray().Select(x => x.GetString()).ToArray());
Assert.False(root.GetProperty("hasMore").GetBoolean());
Assert.True(root.GetProperty("nextCursor").ValueKind == JsonValueKind.Null);
}
[Fact]
public async Task ObservationsEndpoint_SupportsPagination()
{
await SeedObservationDocumentsAsync(BuildSampleObservationDocuments());
using var client = _factory.CreateClient();
var firstResponse = await client.GetAsync("/concelier/observations?tenant=tenant-a&limit=1");
firstResponse.EnsureSuccessStatusCode();
using var firstDocument = await firstResponse.Content.ReadFromJsonAsync<JsonDocument>();
Assert.NotNull(firstDocument);
var firstRoot = firstDocument!.RootElement;
var firstObservations = firstRoot.GetProperty("observations").EnumerateArray().ToArray();
Assert.Single(firstObservations);
var nextCursor = firstRoot.GetProperty("nextCursor").GetString();
Assert.True(firstRoot.GetProperty("hasMore").GetBoolean());
Assert.False(string.IsNullOrWhiteSpace(nextCursor));
var secondResponse = await client.GetAsync($"/concelier/observations?tenant=tenant-a&limit=2&cursor={Uri.EscapeDataString(nextCursor!)}");
secondResponse.EnsureSuccessStatusCode();
using var secondDocument = await secondResponse.Content.ReadFromJsonAsync<JsonDocument>();
Assert.NotNull(secondDocument);
var secondRoot = secondDocument!.RootElement;
var secondObservations = secondRoot.GetProperty("observations").EnumerateArray().ToArray();
Assert.Single(secondObservations);
Assert.False(secondRoot.GetProperty("hasMore").GetBoolean());
Assert.True(secondRoot.GetProperty("nextCursor").ValueKind == JsonValueKind.Null);
Assert.Equal("tenant-a:nvd:alpha:1", secondObservations[0].GetProperty("observationId").GetString());
}
[Fact]
public async Task LinksetsEndpoint_ReturnsNormalizedLinksetsFromIngestion()
{
var tenant = "tenant-linkset-ingest";
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Stella-Tenant", tenant);
var firstIngest = await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:linkset-1", "GHSA-LINK-001", purls: new[] { "pkg:npm/demo@1.0.0" }));
firstIngest.EnsureSuccessStatusCode();
var secondIngest = await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:linkset-2", "GHSA-LINK-002", purls: new[] { "pkg:npm/demo@2.0.0" }));
secondIngest.EnsureSuccessStatusCode();
var response = await client.GetAsync("/linksets?tenant=tenant-linkset-ingest&limit=10");
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<AdvisoryLinksetQueryResponse>();
Assert.NotNull(payload);
Assert.Equal(2, payload!.Linksets.Length);
var linksetAdvisoryIds = payload.Linksets.Select(ls => ls.AdvisoryId).OrderBy(id => id, StringComparer.Ordinal).ToArray();
Assert.Equal(new[] { "GHSA-LINK-001", "GHSA-LINK-002" }, linksetAdvisoryIds);
var allPurls = payload.Linksets.SelectMany(ls => ls.Purls).OrderBy(p => p, StringComparer.Ordinal).ToArray();
Assert.Contains("pkg:npm/demo@1.0.0", allPurls);
Assert.Contains("pkg:npm/demo@2.0.0", allPurls);
var versions = payload.Linksets
.SelectMany(ls => ls.Versions)
.Distinct(StringComparer.Ordinal)
.OrderBy(v => v, StringComparer.Ordinal)
.ToArray();
Assert.Contains("1.0.0", versions);
Assert.Contains("2.0.0", versions);
Assert.False(payload.HasMore);
Assert.True(string.IsNullOrEmpty(payload.NextCursor));
}
[Fact]
public async Task LinksetsEndpoint_SupportsCursorPagination()
{
var tenant = "tenant-linkset-page";
var documents = new[]
{
CreateLinksetDocument(
tenant,
"nvd",
"ADV-002",
new[] { "obs-2" },
new[] { "pkg:npm/demo@2.0.0" },
new[] { "2.0.0" },
new DateTime(2025, 1, 6, 0, 0, 0, DateTimeKind.Utc)),
CreateLinksetDocument(
tenant,
"osv",
"ADV-001",
new[] { "obs-1" },
new[] { "pkg:npm/demo@1.0.0" },
new[] { "1.0.0" },
new DateTime(2025, 1, 5, 0, 0, 0, DateTimeKind.Utc)),
CreateLinksetDocument(
"tenant-other",
"osv",
"ADV-999",
new[] { "obs-x" },
new[] { "pkg:npm/other@1.0.0" },
new[] { "1.0.0" },
new DateTime(2025, 1, 4, 0, 0, 0, DateTimeKind.Utc))
};
await SeedLinksetDocumentsAsync(documents);
using var client = _factory.CreateClient();
var firstResponse = await client.GetAsync($"/linksets?tenant={tenant}&limit=1");
firstResponse.EnsureSuccessStatusCode();
var firstPayload = await firstResponse.Content.ReadFromJsonAsync<AdvisoryLinksetQueryResponse>();
Assert.NotNull(firstPayload);
var first = Assert.Single(firstPayload!.Linksets);
Assert.Equal("ADV-002", first.AdvisoryId);
Assert.Equal(new[] { "pkg:npm/demo@2.0.0" }, first.Purls.ToArray());
Assert.Equal(new[] { "2.0.0" }, first.Versions.ToArray());
Assert.True(firstPayload.HasMore);
Assert.False(string.IsNullOrWhiteSpace(firstPayload.NextCursor));
var secondResponse = await client.GetAsync($"/linksets?tenant={tenant}&limit=1&cursor={Uri.EscapeDataString(firstPayload.NextCursor!)}");
secondResponse.EnsureSuccessStatusCode();
var secondPayload = await secondResponse.Content.ReadFromJsonAsync<AdvisoryLinksetQueryResponse>();
Assert.NotNull(secondPayload);
var second = Assert.Single(secondPayload!.Linksets);
Assert.Equal("ADV-001", second.AdvisoryId);
Assert.Equal(new[] { "pkg:npm/demo@1.0.0" }, second.Purls.ToArray());
Assert.Equal(new[] { "1.0.0" }, second.Versions.ToArray());
Assert.False(secondPayload.HasMore);
Assert.True(string.IsNullOrEmpty(secondPayload.NextCursor));
}
[Fact]
public async Task LnmLinksetsEndpoints_ReturnFactOnlyLinksets()
{
var tenant = "tenant-lnm-list";
var documents = new[]
{
CreateLinksetDocument(
tenant,
"nvd",
"ADV-002",
new[] { "obs-2" },
new[] { "pkg:npm/demo@2.0.0" },
new[] { "2.0.0" },
new DateTime(2025, 1, 6, 0, 0, 0, DateTimeKind.Utc)),
CreateLinksetDocument(
tenant,
"osv",
"ADV-001",
new[] { "obs-1" },
new[] { "pkg:npm/demo@1.0.0" },
new[] { "1.0.0" },
new DateTime(2025, 1, 5, 0, 0, 0, DateTimeKind.Utc))
};
await SeedLinksetDocumentsAsync(documents);
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Stella-Tenant", tenant);
var listResponse = await client.GetAsync("/v1/lnm/linksets?pageSize=1&page=1");
listResponse.EnsureSuccessStatusCode();
var listPayload = await listResponse.Content.ReadFromJsonAsync<JsonElement>();
var firstItem = listPayload.GetProperty("items").EnumerateArray().First();
Assert.Equal("ADV-002", firstItem.GetProperty("advisoryId").GetString());
Assert.Contains("pkg:npm/demo@2.0.0", firstItem.GetProperty("purl").EnumerateArray().Select(x => x.GetString()));
Assert.True(firstItem.GetProperty("conflicts").EnumerateArray().Count() >= 0);
Assert.Equal("created", firstItem.GetProperty("timeline").EnumerateArray().First().GetProperty("event").GetString());
Assert.Equal(DateTime.Parse("2025-01-06T00:00:00Z"), firstItem.GetProperty("publishedAt").GetDateTime());
var detailResponse = await client.GetAsync("/v1/lnm/linksets/ADV-001?source=osv&includeObservations=true");
detailResponse.EnsureSuccessStatusCode();
var detailPayload = await detailResponse.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("ADV-001", detailPayload.GetProperty("advisoryId").GetString());
Assert.Equal("osv", detailPayload.GetProperty("source").GetString());
Assert.Contains("pkg:npm/demo@1.0.0", detailPayload.GetProperty("purl").EnumerateArray().Select(x => x.GetString()));
Assert.Contains("obs-1", detailPayload.GetProperty("observations").EnumerateArray().Select(x => x.GetString()));
Assert.Equal(DateTime.Parse("2025-01-05T00:00:00Z"), detailPayload.GetProperty("publishedAt").GetDateTime());
}
[Fact]
public async Task ObservationsEndpoint_ReturnsBadRequestWhenTenantMissing()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/concelier/observations");
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.StatusCode == HttpStatusCode.BadRequest, $"Expected 400 but got {(int)response.StatusCode}: {body}");
}
[Fact]
public async Task AdvisoryChunksEndpoint_ReturnsParagraphAnchors()
{
var newestRaw = DocumentObject.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 = DocumentObject.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().AsDocumentObject),
CreateAdvisoryRawDocument("tenant-a", "nvd", "tenant-a:chunk:older", olderHash, olderRaw.DeepClone().AsDocumentObject));
await SeedCanonicalAdvisoriesAsync(
CreateStructuredAdvisory("CVE-2025-0001", "GHSA-2025-0001", "tenant-a:chunk:newest", newerCreatedAt));
using var client = _factory.CreateClient();
var response = await client.GetAsync("/advisories/cve-2025-0001/chunks?tenant=tenant-a&section=workaround");
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.False(string.IsNullOrWhiteSpace(root.GetProperty("fingerprint").GetString()));
Assert.Equal(1, root.GetProperty("total").GetInt32());
Assert.False(root.GetProperty("truncated").GetBoolean());
var entry = Assert.Single(root.GetProperty("entries").EnumerateArray());
Assert.Equal("workaround", entry.GetProperty("type").GetString());
Assert.False(string.IsNullOrWhiteSpace(entry.GetProperty("chunkId").GetString()));
var content = entry.GetProperty("content");
Assert.Equal("Vendor guidance", content.GetProperty("title").GetString());
Assert.Equal("Apply configuration change immediately.", content.GetProperty("description").GetString());
Assert.Equal("https://vendor.example/workaround", content.GetProperty("url").GetString());
var provenance = entry.GetProperty("provenance");
Assert.Equal("tenant-a:chunk:newest", provenance.GetProperty("documentId").GetString());
Assert.Equal("/references/0", provenance.GetProperty("observationPath").GetString());
Assert.Equal("nvd", provenance.GetProperty("source").GetString());
Assert.Equal("workaround", provenance.GetProperty("kind").GetString());
Assert.Equal("tenant-a:chunk:newest", provenance.GetProperty("value").GetString());
Assert.Contains(
"/references/0",
provenance.GetProperty("fieldMask").EnumerateArray().Select(element => element.GetString()));
}
[Fact]
public async Task AdvisoryChunksEndpoint_ReturnsNotFoundWhenAdvisoryMissing()
{
await SeedObservationDocumentsAsync(BuildSampleObservationDocuments());
await SeedCanonicalAdvisoriesAsync();
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: 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>();
Assert.NotNull(ingestPayload);
Assert.True(ingestPayload!.Inserted);
Assert.False(string.IsNullOrWhiteSpace(ingestPayload.Id));
Assert.Equal("tenant-ingest", ingestPayload.Tenant);
Assert.Equal(ComputeDeterministicContentHash(upstreamId), ingestPayload.ContentHash);
Assert.NotNull(ingestResponse.Headers.Location);
var locationValue = ingestResponse.Headers.Location!.ToString();
Assert.False(string.IsNullOrWhiteSpace(locationValue));
var lastSlashIndex = locationValue.LastIndexOf('/');
var idSegment = lastSlashIndex >= 0
? locationValue[(lastSlashIndex + 1)..]
: locationValue;
var decodedSegment = Uri.UnescapeDataString(idSegment);
Assert.Equal(ingestPayload.Id, decodedSegment);
var duplicateResponse = await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest(
contentHash: null,
upstreamId: upstreamId));
Assert.Equal(HttpStatusCode.OK, duplicateResponse.StatusCode);
var duplicatePayload = await duplicateResponse.Content.ReadFromJsonAsync<AdvisoryIngestResponse>();
Assert.NotNull(duplicatePayload);
Assert.False(duplicatePayload!.Inserted);
using (var getRequest = new HttpRequestMessage(HttpMethod.Get, $"/advisories/raw/{ingestPayload.Id}"))
{
getRequest.Headers.Add("X-Stella-Tenant", "tenant-ingest");
var getResponse = await client.SendAsync(getRequest);
getResponse.EnsureSuccessStatusCode();
var record = await getResponse.Content.ReadFromJsonAsync<AdvisoryRawRecordResponse>();
Assert.NotNull(record);
Assert.Equal(ingestPayload.Id, record!.Id);
Assert.Equal("tenant-ingest", record.Tenant);
Assert.Equal(ComputeDeterministicContentHash(upstreamId), record.Document.Upstream.ContentHash);
}
using (var listRequest = new HttpRequestMessage(HttpMethod.Get, "/advisories/raw?limit=10"))
{
listRequest.Headers.Add("X-Stella-Tenant", "tenant-ingest");
var listResponse = await client.SendAsync(listRequest);
listResponse.EnsureSuccessStatusCode();
var listPayload = await listResponse.Content.ReadFromJsonAsync<AdvisoryRawListResponse>();
Assert.NotNull(listPayload);
var record = Assert.Single(listPayload!.Records);
Assert.Equal(ingestPayload.Id, record.Id);
}
}
[Fact]
public async Task AocVerifyEndpoint_ReturnsSummaryForTenant()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-verify");
await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest(
contentHash: "sha256:verify-1",
upstreamId: "GHSA-VERIFY-001"));
var verifyResponse = await client.PostAsJsonAsync("/aoc/verify", new AocVerifyRequest(null, null, null, null, null));
verifyResponse.EnsureSuccessStatusCode();
var verifyPayload = await verifyResponse.Content.ReadFromJsonAsync<AocVerifyResponse>();
Assert.NotNull(verifyPayload);
Assert.Equal("tenant-verify", verifyPayload!.Tenant);
Assert.True(verifyPayload.Checked.Advisories >= 1);
Assert.Equal(0, verifyPayload.Checked.Vex);
Assert.True(verifyPayload.Metrics.IngestionWriteTotal >= verifyPayload.Checked.Advisories);
Assert.Empty(verifyPayload.Violations);
Assert.False(verifyPayload.Truncated);
}
[Fact]
public async Task AocVerifyEndpoint_ReturnsViolationsForGuardFailures()
{
await SeedAdvisoryRawDocumentsAsync(
CreateAdvisoryRawDocument(
tenant: "tenant-verify-violations",
vendor: "osv",
upstreamId: "GHSA-VERIFY-ERR",
contentHash: "sha256:verify-err",
raw: new DocumentObject
{
{ "id", "GHSA-VERIFY-ERR" },
{ "severity", "critical" }
}));
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-verify-violations");
var verifyResponse = await client.PostAsJsonAsync("/aoc/verify", new AocVerifyRequest(null, null, null, null, null));
verifyResponse.EnsureSuccessStatusCode();
var verifyPayload = await verifyResponse.Content.ReadFromJsonAsync<AocVerifyResponse>();
Assert.NotNull(verifyPayload);
Assert.Equal("tenant-verify-violations", verifyPayload!.Tenant);
Assert.True(verifyPayload.Checked.Advisories >= 1);
var violation = Assert.Single(verifyPayload.Violations);
Assert.Equal("ERR_AOC_001", violation.Code);
Assert.True(violation.Count >= 1);
Assert.NotEmpty(violation.Examples);
}
[Fact]
public async Task AdvisoryRawListEndpoint_SupportsCursorPagination()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-list");
await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:list-1", "GHSA-LIST-001"));
await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:list-2", "GHSA-LIST-002"));
await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:list-3", "GHSA-LIST-003"));
using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "/advisories/raw?limit=2");
firstRequest.Headers.Add("X-Stella-Tenant", "tenant-list");
var firstResponse = await client.SendAsync(firstRequest);
firstResponse.EnsureSuccessStatusCode();
var firstPage = await firstResponse.Content.ReadFromJsonAsync<AdvisoryRawListResponse>();
Assert.NotNull(firstPage);
Assert.Equal(2, firstPage!.Records.Count);
Assert.True(firstPage.HasMore);
Assert.False(string.IsNullOrWhiteSpace(firstPage.NextCursor));
using var secondRequest = new HttpRequestMessage(HttpMethod.Get, $"/advisories/raw?cursor={Uri.EscapeDataString(firstPage.NextCursor!)}");
secondRequest.Headers.Add("X-Stella-Tenant", "tenant-list");
var secondResponse = await client.SendAsync(secondRequest);
secondResponse.EnsureSuccessStatusCode();
var secondPage = await secondResponse.Content.ReadFromJsonAsync<AdvisoryRawListResponse>();
Assert.NotNull(secondPage);
Assert.Single(secondPage!.Records);
Assert.False(secondPage.HasMore);
Assert.Null(secondPage.NextCursor);
var firstIds = firstPage.Records.Select(record => record.Id).ToArray();
var secondIds = secondPage.Records.Select(record => record.Id).ToArray();
Assert.Empty(firstIds.Intersect(secondIds));
}
[Fact]
public async Task AdvisoryEvidenceEndpoint_ReturnsDocumentsForCanonicalKey()
{
await SeedAdvisoryRawDocumentsAsync(
CreateAdvisoryRawDocument("tenant-a", "vendor-x", "GHSA-2025-0001", "sha256:001", new DocumentObject("id", "GHSA-2025-0001:1")),
CreateAdvisoryRawDocument("tenant-a", "vendor-y", "GHSA-2025-0001", "sha256:002", new DocumentObject("id", "GHSA-2025-0001:2")),
CreateAdvisoryRawDocument("tenant-b", "vendor-x", "GHSA-2025-0001", "sha256:003", new DocumentObject("id", "GHSA-2025-0001:3")));
using var client = _factory.CreateClient();
var response = await client.GetAsync("/vuln/evidence/advisories/ghsa-2025-0001?tenant=tenant-a");
var responseBody = await response.Content.ReadAsStringAsync();
_output.WriteLine($"Response: {(int)response.StatusCode} {response.StatusCode} · {responseBody}");
Assert.True(response.IsSuccessStatusCode, $"Expected success but got {(int)response.StatusCode}: {responseBody}");
var evidence = await response.Content.ReadFromJsonAsync<AdvisoryEvidenceResponse>();
Assert.NotNull(evidence);
Assert.Equal("GHSA-2025-0001", evidence!.AdvisoryKey);
Assert.Equal(2, evidence.Records.Count);
Assert.All(evidence.Records, record => Assert.Equal("tenant-a", record.Tenant));
Assert.Null(evidence.Attestation);
}
[Fact]
public async Task AdvisoryEvidenceEndpoint_AttachesAttestationWhenBundleProvided()
{
await SeedAdvisoryRawDocumentsAsync(
CreateAdvisoryRawDocument("tenant-a", "vendor-x", "GHSA-2025-0003", "sha256:201", new DocumentObject("id", "GHSA-2025-0003:1")));
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
var sampleDir = Path.Combine(repoRoot, "docs", "samples", "evidence-bundle");
var tarPath = Path.Combine(sampleDir, "evidence-bundle-m0.tar.gz");
var manifestPath = Path.Combine(sampleDir, "manifest.json");
var transparencyPath = Path.Combine(sampleDir, "transparency.json");
using var client = _factory.CreateClient();
var requestUri = $"/vuln/evidence/advisories/GHSA-2025-0003?tenant=tenant-a&bundlePath={Uri.EscapeDataString(tarPath)}&manifestPath={Uri.EscapeDataString(manifestPath)}&transparencyPath={Uri.EscapeDataString(transparencyPath)}&pipelineVersion=git:test-sha";
var response = await client.GetAsync(requestUri);
response.EnsureSuccessStatusCode();
var evidence = await response.Content.ReadFromJsonAsync<AdvisoryEvidenceResponse>();
Assert.NotNull(evidence);
Assert.NotNull(evidence!.Attestation);
Assert.Equal("evidence-bundle-m0", evidence.Attestation!.SubjectName);
Assert.Equal("git:test-sha", evidence.Attestation.PipelineVersion);
Assert.Equal(tarPath, evidence.Attestation.EvidenceBundlePath);
}
[Fact]
[Trait("Category", "Attestation")]
public async Task InternalAttestationVerify_ReturnsClaims()
{
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
var sampleDir = Path.Combine(repoRoot, "docs", "samples", "evidence-bundle");
var tarPath = Path.Combine(sampleDir, "evidence-bundle-m0.tar.gz");
var manifestPath = Path.Combine(sampleDir, "manifest.json");
var transparencyPath = Path.Combine(sampleDir, "transparency.json");
using var scope = _factory.Services.CreateScope();
var concOptions = scope.ServiceProvider.GetRequiredService<IOptions<ConcelierOptions>>().Value;
_output.WriteLine($"EvidenceRoot={concOptions.Evidence.RootAbsolute}");
Assert.StartsWith(concOptions.Evidence.RootAbsolute, tarPath, StringComparison.OrdinalIgnoreCase);
using var client = _factory.CreateClient();
var request = new VerifyAttestationRequest(tarPath, manifestPath, transparencyPath, "git:test-sha");
var response = await client.PostAsJsonAsync("/internal/attestations/verify?tenant=demo", request);
var responseBody = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"Attestation verify failed: {(int)response.StatusCode} {response.StatusCode} · {responseBody}");
var claims = JsonSerializer.Deserialize<AttestationClaims>(
responseBody,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
Assert.NotNull(claims);
Assert.Equal("evidence-bundle-m0", claims!.SubjectName);
Assert.Equal("git:test-sha", claims.PipelineVersion);
Assert.Equal(tarPath, claims.EvidenceBundlePath);
}
[Fact]
public async Task EvidenceBatch_ReturnsEmptyCollectionsWhenUnknown()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add(TenantHeaderName, "demo");
var request = new EvidenceBatchRequest(
new[]
{
new EvidenceBatchItemRequest("component-a", new[] { "pkg:purl/example@1.0.0" }, new[] { "ALIAS-1" })
},
ObservationLimit: 5,
LinksetLimit: 5);
var response = await client.PostAsJsonAsync("/v1/evidence/batch", request);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<EvidenceBatchResponse>();
Assert.NotNull(payload);
var item = Assert.Single(payload!.Items);
Assert.Equal("component-a", item.ComponentId);
Assert.Empty(item.Observations);
Assert.Empty(item.Linksets);
Assert.False(item.HasMore);
}
[Fact]
public async Task AdvisoryEvidenceEndpoint_FiltersByVendor()
{
await SeedAdvisoryRawDocumentsAsync(
CreateAdvisoryRawDocument("tenant-a", "vendor-x", "GHSA-2025-0002", "sha256:101", new DocumentObject("id", "GHSA-2025-0002:1")),
CreateAdvisoryRawDocument("tenant-a", "vendor-y", "GHSA-2025-0002", "sha256:102", new DocumentObject("id", "GHSA-2025-0002:2")));
using var client = _factory.CreateClient();
var response = await client.GetAsync("/vuln/evidence/advisories/GHSA-2025-0002?tenant=tenant-a&vendor=vendor-y");
response.EnsureSuccessStatusCode();
var evidence = await response.Content.ReadFromJsonAsync<AdvisoryEvidenceResponse>();
Assert.NotNull(evidence);
var record = Assert.Single(evidence!.Records);
Assert.Equal("vendor-y", record.Document.Source.Vendor);
}
[Fact]
public async Task AdvisoryEvidenceEndpoint_ReturnsNotFoundWhenMissing()
{
await SeedAdvisoryRawDocumentsAsync();
using var client = _factory.CreateClient();
var response = await client.GetAsync("/vuln/evidence/advisories/CVE-2099-9999?tenant=tenant-a");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task AdvisoryChunksEndpoint_EmitsRequestAndCacheMetrics()
{
await SeedObservationDocumentsAsync(BuildSampleObservationDocuments());
await SeedCanonicalAdvisoriesAsync(
CreateStructuredAdvisory(
"CVE-2025-0001",
"GHSA-2025-0001",
"tenant-a:nvd:alpha:1",
new DateTimeOffset(2025, 1, 5, 0, 0, 0, TimeSpan.Zero)));
using var client = _factory.CreateClient();
long expectedSegments = 0;
string expectedTruncatedTag = "false";
var metrics = await CaptureMetricsAsync(
AdvisoryAiMetrics.MeterName,
new[]
{
"advisory_ai_chunk_requests_total",
"advisory_ai_chunk_cache_hits_total",
"advisory_ai_chunk_latency_milliseconds",
"advisory_ai_chunk_segments",
"advisory_ai_chunk_sources"
},
async () =>
{
const string url = "/advisories/CVE-2025-0001/chunks?tenant=tenant-a";
var first = await client.GetAsync(url);
first.EnsureSuccessStatusCode();
using (var firstDocument = await first.Content.ReadFromJsonAsync<JsonDocument>())
{
Assert.NotNull(firstDocument);
expectedSegments = firstDocument!.RootElement.GetProperty("entries").GetArrayLength();
expectedTruncatedTag = firstDocument.RootElement.GetProperty("truncated").GetBoolean() ? "true" : "false";
}
var second = await client.GetAsync(url);
second.EnsureSuccessStatusCode();
});
Assert.True(metrics.TryGetValue("advisory_ai_chunk_requests_total", out var requests));
Assert.NotNull(requests);
Assert.Equal(2, requests!.Count);
Assert.Contains(requests!, measurement =>
string.Equals(GetTagValue(measurement, "cache"), "miss", StringComparison.Ordinal));
Assert.Contains(requests!, measurement =>
string.Equals(GetTagValue(measurement, "cache"), "hit", StringComparison.Ordinal));
Assert.True(metrics.TryGetValue("advisory_ai_chunk_cache_hits_total", out var cacheHitMeasurements));
var cacheHit = Assert.Single(cacheHitMeasurements!);
Assert.Equal(1, cacheHit.Value);
Assert.Equal("hit", GetTagValue(cacheHit, "result"));
Assert.True(metrics.TryGetValue("advisory_ai_chunk_latency_milliseconds", out var latencyMeasurements));
Assert.Equal(2, latencyMeasurements!.Count);
Assert.All(latencyMeasurements!, measurement => Assert.True(measurement.Value > 0));
Assert.True(metrics.TryGetValue("advisory_ai_chunk_segments", out var segmentMeasurements));
Assert.Equal(2, segmentMeasurements!.Count);
Assert.All(segmentMeasurements!, measurement =>
{
Assert.Equal(expectedSegments, measurement.Value);
Assert.Equal(expectedTruncatedTag, GetTagValue(measurement, "truncated"));
});
Assert.True(metrics.TryGetValue("advisory_ai_chunk_sources", out var sourceMeasurements));
Assert.Equal(2, sourceMeasurements!.Count);
}
[Fact]
public async Task AdvisoryChunksEndpoint_EmitsGuardrailMetrics()
{
var raw = DocumentObject.Parse("{\"details\":\"tiny\"}");
var document = CreateChunkObservationDocument(
"tenant-a:chunk:1",
"tenant-a",
new DateTime(2025, 2, 1, 0, 0, 0, DateTimeKind.Utc),
"CVE-2025-GUARD",
raw);
await SeedObservationDocumentsAsync(new[] { document });
await SeedCanonicalAdvisoriesAsync(
CreateStructuredAdvisory(
"CVE-2025-GUARD",
"GHSA-2025-GUARD",
"tenant-a:chunk:1",
new DateTimeOffset(2025, 2, 1, 0, 0, 0, TimeSpan.Zero)));
using var client = _factory.CreateClient();
var guardrailMetrics = await CaptureMetricsAsync(
AdvisoryAiMetrics.MeterName,
"advisory_ai_guardrail_blocks_total",
async () =>
{
var response = await client.GetAsync("/advisories/CVE-2025-GUARD/chunks?tenant=tenant-a");
response.EnsureSuccessStatusCode();
});
var measurement = Assert.Single(guardrailMetrics);
Assert.True(measurement.Value >= 1);
Assert.Equal("below_minimum_length", GetTagValue(measurement, "reason"));
}
[Fact]
public async Task AdvisoryIngestEndpoint_EmitsMetricsWithExpectedTags()
{
var measurements = await CaptureMetricsAsync(
IngestionMetrics.MeterName,
"ingestion_write_total",
async () =>
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-metrics");
await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:metric-1", "GHSA-METRIC-001"));
await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:metric-1", "GHSA-METRIC-001"));
});
Assert.Equal(2, measurements.Count);
var inserted = measurements.FirstOrDefault(measurement =>
string.Equals(GetTagValue(measurement, "tenant"), "tenant-metrics", StringComparison.Ordinal) &&
string.Equals(GetTagValue(measurement, "result"), "inserted", StringComparison.Ordinal));
Assert.NotNull(inserted);
Assert.Equal(1, inserted!.Value);
Assert.Equal("osv", GetTagValue(inserted, "source"));
var duplicate = measurements.FirstOrDefault(measurement =>
string.Equals(GetTagValue(measurement, "tenant"), "tenant-metrics", StringComparison.Ordinal) &&
string.Equals(GetTagValue(measurement, "result"), "duplicate", StringComparison.Ordinal));
Assert.NotNull(duplicate);
Assert.Equal(1, duplicate!.Value);
Assert.Equal("osv", GetTagValue(duplicate, "source"));
}
[Fact]
public async Task AocVerifyEndpoint_EmitsVerificationMetric()
{
var measurements = await CaptureMetricsAsync(
IngestionMetrics.MeterName,
"verify_runs_total",
async () =>
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-verify-metrics");
await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:verify-metric", "GHSA-VERIFY-METRIC"));
var verifyResponse = await client.PostAsJsonAsync("/aoc/verify", new AocVerifyRequest(null, null, null, null, null));
verifyResponse.EnsureSuccessStatusCode();
});
var measurement = Assert.Single(measurements);
Assert.Equal("tenant-verify-metrics", GetTagValue(measurement, "tenant"));
Assert.Equal("ok", GetTagValue(measurement, "result"));
Assert.Equal(1, measurement.Value);
}
[Fact]
public async Task AdvisoryIngestEndpoint_RejectsCrossTenantWhenAuthenticated()
{
IdentityModelEventSource.ShowPII = true;
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",
};
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";
},
environment);
using var client = factory.CreateClient();
var schemes = await factory.Services.GetRequiredService<IAuthenticationSchemeProvider>().GetAllSchemesAsync();
_output.WriteLine("Schemes => " + string.Join(',', schemes.Select(s => s.Name)));
// Include both the endpoint-specific scope (advisory:ingest) and the global required scope (concelier.jobs.trigger)
var token = CreateTestToken("tenant-auth", StellaOpsScopes.AdvisoryIngest, StellaOpsScopes.ConcelierJobsTrigger);
_output.WriteLine("token => " + token);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-auth");
var ingestResponse = await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:auth-1", "GHSA-AUTH-001"));
if (ingestResponse.StatusCode != HttpStatusCode.Created)
{
var body = await ingestResponse.Content.ReadAsStringAsync();
_output.WriteLine($"ingestResponse => {(int)ingestResponse.StatusCode} {ingestResponse.StatusCode}: {body}");
var authLogs = factory.LoggerProvider.Snapshot("Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler");
foreach (var entry in authLogs)
{
_output.WriteLine($"authLog => {entry.Level}: {entry.Message} ({entry.Exception?.Message})");
}
var programLogs = factory.LoggerProvider.Snapshot("StellaOps.Concelier.WebService.Program");
foreach (var entry in programLogs)
{
_output.WriteLine($"programLog => {entry.Level}: {entry.Message}");
}
var authzLogs = factory.LoggerProvider.Snapshot("StellaOps.Auth.ServerIntegration.StellaOpsScopeAuthorizationHandler");
foreach (var entry in authzLogs)
{
_output.WriteLine($"authzLog => {entry.Level}: {entry.Message}");
}
var jwtDebugLogs = factory.LoggerProvider.Snapshot("TestJwtDebug");
foreach (var entry in jwtDebugLogs)
{
_output.WriteLine($"jwtDebug => {entry.Level}: {entry.Message}");
}
}
Assert.Equal(HttpStatusCode.Created, ingestResponse.StatusCode);
client.DefaultRequestHeaders.Remove("X-Stella-Tenant");
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-other");
var crossTenantResponse = await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:auth-2", "GHSA-AUTH-002"));
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();
// Include both the endpoint-specific scope (advisory:ingest) and the global required scope (concelier.jobs.trigger)
var allowedToken = CreateTestToken("tenant-auth", StellaOpsScopes.AdvisoryIngest, StellaOpsScopes.ConcelierJobsTrigger);
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);
// Token for blocked tenant - still has correct scopes but wrong tenant
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", CreateTestToken("tenant-blocked", StellaOpsScopes.AdvisoryIngest, StellaOpsScopes.ConcelierJobsTrigger));
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()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-violation");
var invalidRequest = BuildAdvisoryIngestRequest(
contentHash: string.Empty,
upstreamId: "GHSA-INVALID-1",
enforceContentHash: false);
var response = await client.PostAsJsonAsync("/ingest/advisory", invalidRequest);
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var problemJson = await response.Content.ReadAsStringAsync();
using var document = JsonDocument.Parse(problemJson);
var root = document.RootElement;
Assert.Equal("Aggregation-Only Contract violation", root.GetProperty("title").GetString());
Assert.Equal(422, root.GetProperty("status").GetInt32());
Assert.True(root.TryGetProperty("violations", out var violations), "Problem response missing violations payload.");
Assert.True(root.TryGetProperty("code", out var codeElement), "Problem response missing code payload.");
Assert.Equal("ERR_AOC_004", codeElement.GetString());
var violation = Assert.Single(violations.EnumerateArray());
Assert.Equal("ERR_AOC_004", violation.GetProperty("code").GetString());
}
[Fact]
public async Task JobsEndpointsReturnExpectedStatuses()
{
using var client = _factory.CreateClient();
var definitions = await client.GetAsync("/jobs/definitions");
if (!definitions.IsSuccessStatusCode)
{
var body = await definitions.Content.ReadAsStringAsync();
throw new Xunit.Sdk.XunitException($"/jobs/definitions failed: {(int)definitions.StatusCode} {body}");
}
var trigger = await client.PostAsync("/jobs/unknown", new StringContent("{}", System.Text.Encoding.UTF8, "application/json"));
if (trigger.StatusCode != HttpStatusCode.NotFound)
{
var payload = await trigger.Content.ReadAsStringAsync();
throw new Xunit.Sdk.XunitException($"/jobs/unknown expected 404, got {(int)trigger.StatusCode}: {payload}");
}
var problem = await trigger.Content.ReadFromJsonAsync<ProblemDocument>();
Assert.NotNull(problem);
Assert.Equal("https://stellaops.org/problems/not-found", problem!.Type);
Assert.Equal(404, problem.Status);
}
[Fact]
public async Task JobRunEndpointReturnsProblemWhenNotFound()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync($"/jobs/{Guid.NewGuid()}");
if (response.StatusCode != HttpStatusCode.NotFound)
{
var body = await response.Content.ReadAsStringAsync();
throw new Xunit.Sdk.XunitException($"/jobs/{{id}} expected 404, got {(int)response.StatusCode}: {body}");
}
var problem = await response.Content.ReadFromJsonAsync<ProblemDocument>();
Assert.NotNull(problem);
Assert.Equal("https://stellaops.org/problems/not-found", problem!.Type);
}
[Fact]
public async Task JobTriggerMapsCoordinatorOutcomes()
{
var handler = _factory.Services.GetRequiredService<StubJobCoordinator>();
using var client = _factory.CreateClient();
handler.NextResult = JobTriggerResult.AlreadyRunning("busy");
var conflict = await client.PostAsync("/jobs/test", JsonContent.Create(new JobTriggerRequest()));
if (conflict.StatusCode != HttpStatusCode.Conflict)
{
var payload = await conflict.Content.ReadAsStringAsync();
throw new Xunit.Sdk.XunitException($"Conflict path expected 409, got {(int)conflict.StatusCode}: {payload}");
}
var conflictProblem = await conflict.Content.ReadFromJsonAsync<ProblemDocument>();
Assert.NotNull(conflictProblem);
Assert.Equal("https://stellaops.org/problems/conflict", conflictProblem!.Type);
handler.NextResult = JobTriggerResult.Accepted(new JobRunSnapshot(Guid.NewGuid(), "demo", JobRunStatus.Pending, DateTimeOffset.UtcNow, null, null, "api", null, null, null, null, new Dictionary<string, object?>()));
var accepted = await client.PostAsync("/jobs/test", JsonContent.Create(new JobTriggerRequest()));
if (accepted.StatusCode != HttpStatusCode.Accepted)
{
var payload = await accepted.Content.ReadAsStringAsync();
throw new Xunit.Sdk.XunitException($"Accepted path expected 202, got {(int)accepted.StatusCode}: {payload}");
}
Assert.NotNull(accepted.Headers.Location);
var acceptedPayload = await accepted.Content.ReadFromJsonAsync<JobRunPayload>();
Assert.NotNull(acceptedPayload);
handler.NextResult = JobTriggerResult.Failed(new JobRunSnapshot(Guid.NewGuid(), "demo", JobRunStatus.Failed, DateTimeOffset.UtcNow, null, DateTimeOffset.UtcNow, "api", null, "err", null, null, new Dictionary<string, object?>()), "boom");
var failed = await client.PostAsync("/jobs/test", JsonContent.Create(new JobTriggerRequest()));
if (failed.StatusCode != HttpStatusCode.InternalServerError)
{
var payload = await failed.Content.ReadAsStringAsync();
throw new Xunit.Sdk.XunitException($"Failed path expected 500, got {(int)failed.StatusCode}: {payload}");
}
var failureProblem = await failed.Content.ReadFromJsonAsync<ProblemDocument>();
Assert.NotNull(failureProblem);
Assert.Equal("https://stellaops.org/problems/job-failure", failureProblem!.Type);
}
[Fact]
public async Task JobsEndpointsExposeJobData()
{
var handler = _factory.Services.GetRequiredService<StubJobCoordinator>();
var now = DateTimeOffset.UtcNow;
var run = new JobRunSnapshot(
Guid.NewGuid(),
"demo",
JobRunStatus.Succeeded,
now,
now,
now.AddSeconds(2),
"api",
"hash",
null,
TimeSpan.FromMinutes(5),
TimeSpan.FromMinutes(1),
new Dictionary<string, object?> { ["key"] = "value" });
handler.Definitions = new[]
{
new JobDefinition("demo", typeof(DemoJob), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(1), "*/5 * * * *", true)
};
handler.LastRuns["demo"] = run;
handler.RecentRuns = new[] { run };
handler.ActiveRuns = Array.Empty<JobRunSnapshot>();
handler.Runs[run.RunId] = run;
try
{
using var client = _factory.CreateClient();
var definitions = await client.GetFromJsonAsync<List<JobDefinitionPayload>>("/jobs/definitions");
Assert.NotNull(definitions);
Assert.Single(definitions!);
Assert.Equal("demo", definitions![0].Kind);
Assert.NotNull(definitions[0].LastRun);
Assert.Equal(run.RunId, definitions[0].LastRun!.RunId);
var runPayload = await client.GetFromJsonAsync<JobRunPayload>($"/jobs/{run.RunId}");
Assert.NotNull(runPayload);
Assert.Equal(run.RunId, runPayload!.RunId);
Assert.Equal("Succeeded", runPayload.Status);
var runs = await client.GetFromJsonAsync<List<JobRunPayload>>("/jobs?kind=demo&limit=5");
Assert.NotNull(runs);
Assert.Single(runs!);
Assert.Equal(run.RunId, runs![0].RunId);
var runsByDefinition = await client.GetFromJsonAsync<List<JobRunPayload>>("/jobs/definitions/demo/runs");
Assert.NotNull(runsByDefinition);
Assert.Single(runsByDefinition!);
var active = await client.GetFromJsonAsync<List<JobRunPayload>>("/jobs/active");
Assert.NotNull(active);
Assert.Empty(active!);
}
finally
{
handler.Definitions = Array.Empty<JobDefinition>();
handler.RecentRuns = Array.Empty<JobRunSnapshot>();
handler.ActiveRuns = Array.Empty<JobRunSnapshot>();
handler.Runs.Clear();
handler.LastRuns.Clear();
}
}
[Fact]
public async Task AdvisoryReplayEndpointReturnsLatestStatement()
{
var vulnerabilityKey = "CVE-2025-9000";
var advisory = new Advisory(
advisoryKey: vulnerabilityKey,
title: "Replay Test",
summary: "Example summary",
language: "en",
published: DateTimeOffset.Parse("2025-01-01T00:00:00Z", CultureInfo.InvariantCulture),
modified: DateTimeOffset.Parse("2025-01-02T00:00:00Z", CultureInfo.InvariantCulture),
severity: "medium",
exploitKnown: false,
aliases: new[] { vulnerabilityKey },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: Array.Empty<AdvisoryProvenance>());
var statementId = Guid.NewGuid();
using (var scope = _factory.Services.CreateScope())
{
var eventLog = scope.ServiceProvider.GetRequiredService<IAdvisoryEventLog>();
var appendRequest = new AdvisoryEventAppendRequest(new[]
{
new AdvisoryStatementInput(
vulnerabilityKey,
advisory,
advisory.Modified ?? advisory.Published ?? DateTimeOffset.UtcNow,
Array.Empty<Guid>(),
StatementId: statementId,
AdvisoryKey: advisory.AdvisoryKey)
});
await eventLog.AppendAsync(appendRequest, CancellationToken.None);
}
using var client = _factory.CreateClient();
var response = await client.GetAsync($"/concelier/advisories/{vulnerabilityKey}/replay");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<ReplayResponse>();
Assert.NotNull(payload);
Assert.Equal(vulnerabilityKey, payload!.VulnerabilityKey, ignoreCase: true);
var statement = Assert.Single(payload.Statements);
Assert.Equal(statementId, statement.StatementId);
Assert.Equal(advisory.AdvisoryKey, statement.Advisory.AdvisoryKey);
Assert.False(string.IsNullOrWhiteSpace(statement.StatementHash));
Assert.True(payload.Conflicts is null || payload.Conflicts!.Count == 0);
}
[Fact]
public async Task AdvisoryReplayEndpointReturnsConflictExplainer()
{
var vulnerabilityKey = "CVE-2025-9100";
var statementId = Guid.NewGuid();
var conflictId = Guid.NewGuid();
var recordedAt = DateTimeOffset.Parse("2025-02-01T00:00:00Z", CultureInfo.InvariantCulture);
using (var scope = _factory.Services.CreateScope())
{
var eventLog = scope.ServiceProvider.GetRequiredService<IAdvisoryEventLog>();
var advisory = new Advisory(
advisoryKey: vulnerabilityKey,
title: "Base advisory",
summary: "Baseline summary",
language: "en",
published: recordedAt.AddDays(-1),
modified: recordedAt,
severity: "critical",
exploitKnown: false,
aliases: new[] { vulnerabilityKey },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: Array.Empty<AdvisoryProvenance>());
var statementInput = new AdvisoryStatementInput(
vulnerabilityKey,
advisory,
recordedAt,
Array.Empty<Guid>(),
StatementId: statementId,
AdvisoryKey: advisory.AdvisoryKey);
await eventLog.AppendAsync(new AdvisoryEventAppendRequest(new[] { statementInput }), CancellationToken.None);
var explainer = new MergeConflictExplainerPayload(
Type: "severity",
Reason: "mismatch",
PrimarySources: new[] { "vendor" },
PrimaryRank: 1,
SuppressedSources: new[] { "nvd" },
SuppressedRank: 5,
PrimaryValue: "CRITICAL",
SuppressedValue: "MEDIUM");
using var conflictDoc = JsonDocument.Parse(explainer.ToCanonicalJson());
var conflictInput = new AdvisoryConflictInput(
vulnerabilityKey,
conflictDoc,
recordedAt,
new[] { statementId },
ConflictId: conflictId);
await eventLog.AppendAsync(new AdvisoryEventAppendRequest(Array.Empty<AdvisoryStatementInput>(), new[] { conflictInput }), CancellationToken.None);
}
using var client = _factory.CreateClient();
var response = await client.GetAsync($"/concelier/advisories/{vulnerabilityKey}/replay");
var responseBody = await response.Content.ReadAsStringAsync();
_output.WriteLine($"Response: {(int)response.StatusCode} · {responseBody}");
Assert.True(response.IsSuccessStatusCode, $"Expected OK but got {response.StatusCode}: {responseBody}");
var payload = await response.Content.ReadFromJsonAsync<ReplayResponse>();
Assert.NotNull(payload);
var conflicts = payload!.Conflicts ?? throw new XunitException("Conflicts was null");
var conflict = Assert.Single(conflicts);
Assert.Equal(conflictId, conflict.ConflictId);
Assert.Equal("severity", conflict.Explainer.Type);
Assert.Equal("mismatch", conflict.Explainer.Reason);
Assert.Equal("CRITICAL", conflict.Explainer.PrimaryValue);
Assert.Equal("MEDIUM", conflict.Explainer.SuppressedValue);
Assert.Equal(conflict.Explainer.ComputeHashHex(), conflict.ConflictHash);
}
[Fact]
public async Task MirrorEndpointsServeConfiguredArtifacts()
{
using var temp = new TempDirectory();
var exportId = "20251019T120000Z";
var exportRoot = Path.Combine(temp.Path, exportId);
var mirrorRoot = Path.Combine(exportRoot, "mirror");
var domainRoot = Path.Combine(mirrorRoot, "primary");
Directory.CreateDirectory(domainRoot);
await File.WriteAllTextAsync(
Path.Combine(mirrorRoot, "index.json"),
"""{"schemaVersion":1,"domains":[]}""");
await File.WriteAllTextAsync(
Path.Combine(domainRoot, "manifest.json"),
"""{"domainId":"primary"}""");
await File.WriteAllTextAsync(
Path.Combine(domainRoot, "bundle.json"),
"""{"advisories":[]}""");
await File.WriteAllTextAsync(
Path.Combine(domainRoot, "bundle.json.jws"),
"test-signature");
var environment = new Dictionary<string, string?>
{
["CONCELIER_MIRROR__ENABLED"] = "true",
["CONCELIER_MIRROR__EXPORTROOT"] = temp.Path,
["CONCELIER_MIRROR__ACTIVEEXPORTID"] = exportId,
["CONCELIER_MIRROR__DOMAINS__0__ID"] = "primary",
["CONCELIER_MIRROR__DOMAINS__0__DISPLAYNAME"] = "Primary",
["CONCELIER_MIRROR__DOMAINS__0__REQUIREAUTHENTICATION"] = "false",
["CONCELIER_MIRROR__DOMAINS__0__MAXDOWNLOADREQUESTSPERHOUR"] = "5",
["CONCELIER_MIRROR__MAXINDEXREQUESTSPERHOUR"] = "5"
};
using var factory = new ConcelierApplicationFactory(_runner.ConnectionString, environmentOverrides: environment);
using var client = factory.CreateClient();
var indexResponse = await client.GetAsync("/concelier/exports/index.json");
Assert.Equal(HttpStatusCode.OK, indexResponse.StatusCode);
var indexContent = await indexResponse.Content.ReadAsStringAsync();
Assert.Contains(@"""schemaVersion"":1", indexContent, StringComparison.Ordinal);
var manifestResponse = await client.GetAsync("/concelier/exports/mirror/primary/manifest.json");
Assert.Equal(HttpStatusCode.OK, manifestResponse.StatusCode);
var manifestContent = await manifestResponse.Content.ReadAsStringAsync();
Assert.Contains(@"""domainId"":""primary""", manifestContent, StringComparison.Ordinal);
var bundleResponse = await client.GetAsync("/concelier/exports/mirror/primary/bundle.json.jws");
Assert.Equal(HttpStatusCode.OK, bundleResponse.StatusCode);
var signatureContent = await bundleResponse.Content.ReadAsStringAsync();
Assert.Equal("test-signature", signatureContent);
}
[Fact]
public async Task MirrorEndpointsEnforceAuthenticationForProtectedDomains()
{
using var temp = new TempDirectory();
var exportId = "20251019T120000Z";
var secureRoot = Path.Combine(temp.Path, exportId, "mirror", "secure");
Directory.CreateDirectory(secureRoot);
await File.WriteAllTextAsync(
Path.Combine(temp.Path, exportId, "mirror", "index.json"),
"""{"schemaVersion":1,"domains":[]}""");
await File.WriteAllTextAsync(
Path.Combine(secureRoot, "manifest.json"),
"""{"domainId":"secure"}""");
var environment = new Dictionary<string, string?>
{
["CONCELIER_MIRROR__ENABLED"] = "true",
["CONCELIER_MIRROR__EXPORTROOT"] = temp.Path,
["CONCELIER_MIRROR__ACTIVEEXPORTID"] = exportId,
["CONCELIER_MIRROR__DOMAINS__0__ID"] = "secure",
["CONCELIER_MIRROR__DOMAINS__0__REQUIREAUTHENTICATION"] = "true",
["CONCELIER_MIRROR__DOMAINS__0__MAXDOWNLOADREQUESTSPERHOUR"] = "5",
["CONCELIER_AUTHORITY__ENABLED"] = "true",
["CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK"] = "false",
["CONCELIER_AUTHORITY__ISSUER"] = "https://authority.example",
["CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA"] = "false",
["CONCELIER_AUTHORITY__AUDIENCES__0"] = "api://concelier",
["CONCELIER_AUTHORITY__REQUIREDSCOPES__0"] = StellaOpsScopes.ConcelierJobsTrigger,
["CONCELIER_AUTHORITY__CLIENTID"] = "concelier-jobs",
["CONCELIER_AUTHORITY__CLIENTSECRET"] = "secret",
["CONCELIER_AUTHORITY__CLIENTSCOPES__0"] = StellaOpsScopes.ConcelierJobsTrigger
};
using var factory = new ConcelierApplicationFactory(
_runner.ConnectionString,
authority =>
{
authority.Enabled = true;
authority.AllowAnonymousFallback = false;
authority.Issuer = "https://authority.example";
authority.RequireHttpsMetadata = false;
authority.Audiences.Clear();
authority.Audiences.Add("api://concelier");
authority.RequiredScopes.Clear();
authority.RequiredScopes.Add(StellaOpsScopes.ConcelierJobsTrigger);
authority.ClientId = "concelier-jobs";
authority.ClientSecret = "secret";
},
environment);
using var client = factory.CreateClient();
var response = await client.GetAsync("/concelier/exports/mirror/secure/manifest.json");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
var authHeader = Assert.Single(response.Headers.WwwAuthenticate);
Assert.Equal("Bearer", authHeader.Scheme);
}
[Fact]
public async Task MirrorEndpointsRespectRateLimits()
{
using var temp = new TempDirectory();
var exportId = "20251019T130000Z";
var exportRoot = Path.Combine(temp.Path, exportId);
var mirrorRoot = Path.Combine(exportRoot, "mirror");
Directory.CreateDirectory(mirrorRoot);
await File.WriteAllTextAsync(
Path.Combine(mirrorRoot, "index.json"),
"""{\"schemaVersion\":1,\"domains\":[]}"""
);
var environment = new Dictionary<string, string?>
{
["CONCELIER_MIRROR__ENABLED"] = "true",
["CONCELIER_MIRROR__EXPORTROOT"] = temp.Path,
["CONCELIER_MIRROR__ACTIVEEXPORTID"] = exportId,
["CONCELIER_MIRROR__MAXINDEXREQUESTSPERHOUR"] = "1",
["CONCELIER_MIRROR__DOMAINS__0__ID"] = "primary",
["CONCELIER_MIRROR__DOMAINS__0__REQUIREAUTHENTICATION"] = "false",
["CONCELIER_MIRROR__DOMAINS__0__MAXDOWNLOADREQUESTSPERHOUR"] = "1"
};
using var factory = new ConcelierApplicationFactory(_runner.ConnectionString, environmentOverrides: environment);
using var client = factory.CreateClient();
var okResponse = await client.GetAsync("/concelier/exports/index.json");
Assert.Equal(HttpStatusCode.OK, okResponse.StatusCode);
var limitedResponse = await client.GetAsync("/concelier/exports/index.json");
Assert.Equal((HttpStatusCode)429, limitedResponse.StatusCode);
Assert.NotNull(limitedResponse.Headers.RetryAfter);
Assert.True(limitedResponse.Headers.RetryAfter!.Delta.HasValue);
Assert.True(limitedResponse.Headers.RetryAfter!.Delta!.Value.TotalSeconds > 0);
}
[Fact]
public void MergeModuleDisabledByDefault()
{
using var factory = new ConcelierApplicationFactory(
_runner.ConnectionString,
authorityConfigure: null,
environmentOverrides: null);
using var scope = factory.Services.CreateScope();
var provider = scope.ServiceProvider;
#pragma warning disable CS0618, CONCELIER0001, CONCELIER0002 // Checking deprecated service registration state.
Assert.Null(provider.GetService<AdvisoryMergeService>());
#pragma warning restore CS0618, CONCELIER0001, CONCELIER0002
var schedulerOptions = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
Assert.DoesNotContain("merge:reconcile", schedulerOptions.Definitions.Keys);
}
[Fact]
public void MergeModuleReenabledWhenFeatureFlagCleared()
{
var environment = new Dictionary<string, string?>
{
["CONCELIER_FEATURES__NOMERGEENABLED"] = "false"
};
using var factory = new ConcelierApplicationFactory(
_runner.ConnectionString,
authorityConfigure: null,
environmentOverrides: environment);
using var scope = factory.Services.CreateScope();
var provider = scope.ServiceProvider;
#pragma warning disable CS0618, CONCELIER0001, CONCELIER0002 // Checking deprecated service registration state.
Assert.NotNull(provider.GetService<AdvisoryMergeService>());
#pragma warning restore CS0618, CONCELIER0001, CONCELIER0002
var schedulerOptions = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
Assert.Contains("merge:reconcile", schedulerOptions.Definitions.Keys);
}
[Fact]
public void MergeJobRemovedWhenAllowlistExcludes()
{
var environment = new Dictionary<string, string?>
{
["CONCELIER_FEATURES__NOMERGEENABLED"] = "false",
["CONCELIER_FEATURES__MERGEJOBALLOWLIST__0"] = "export:json"
};
using var factory = new ConcelierApplicationFactory(
_runner.ConnectionString,
authorityConfigure: null,
environmentOverrides: environment);
using var scope = factory.Services.CreateScope();
var provider = scope.ServiceProvider;
#pragma warning disable CS0618, CONCELIER0001, CONCELIER0002 // Checking deprecated service registration state.
Assert.NotNull(provider.GetService<AdvisoryMergeService>());
#pragma warning restore CS0618, CONCELIER0001, CONCELIER0002
var schedulerOptions = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
Assert.DoesNotContain("merge:reconcile", schedulerOptions.Definitions.Keys);
}
[Fact]
public void MergeJobRemainsWhenAllowlisted()
{
var environment = new Dictionary<string, string?>
{
["CONCELIER_FEATURES__NOMERGEENABLED"] = "false",
["CONCELIER_FEATURES__MERGEJOBALLOWLIST__0"] = "merge:reconcile"
};
using var factory = new ConcelierApplicationFactory(
_runner.ConnectionString,
authorityConfigure: null,
environmentOverrides: environment);
using var scope = factory.Services.CreateScope();
var provider = scope.ServiceProvider;
#pragma warning disable CS0618, CONCELIER0001, CONCELIER0002 // Checking deprecated service registration state.
Assert.NotNull(provider.GetService<AdvisoryMergeService>());
#pragma warning restore CS0618, CONCELIER0001, CONCELIER0002
var schedulerOptions = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
Assert.Contains("merge:reconcile", schedulerOptions.Definitions.Keys);
}
[Fact]
public async Task JobsEndpointsAllowBypassWhenAuthorityEnabled()
{
var environment = new Dictionary<string, string?>
{
["CONCELIER_AUTHORITY__ENABLED"] = "true",
["CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK"] = "false",
["CONCELIER_AUTHORITY__ISSUER"] = "https://authority.example",
["CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA"] = "false",
["CONCELIER_AUTHORITY__AUDIENCES__0"] = "api://concelier",
["CONCELIER_AUTHORITY__REQUIREDSCOPES__0"] = StellaOpsScopes.ConcelierJobsTrigger,
["CONCELIER_AUTHORITY__BYPASSNETWORKS__0"] = "127.0.0.1/32",
["CONCELIER_AUTHORITY__BYPASSNETWORKS__1"] = "::1/128",
["CONCELIER_AUTHORITY__CLIENTID"] = "concelier-jobs",
["CONCELIER_AUTHORITY__CLIENTSECRET"] = "test-secret",
["CONCELIER_AUTHORITY__CLIENTSCOPES__0"] = StellaOpsScopes.ConcelierJobsTrigger,
};
using var factory = new ConcelierApplicationFactory(
_runner.ConnectionString,
authority =>
{
authority.Enabled = true;
authority.AllowAnonymousFallback = false;
authority.Issuer = "https://authority.example";
authority.RequireHttpsMetadata = false;
authority.Audiences.Clear();
authority.Audiences.Add("api://concelier");
authority.RequiredScopes.Clear();
authority.RequiredScopes.Add(StellaOpsScopes.ConcelierJobsTrigger);
authority.BypassNetworks.Clear();
authority.BypassNetworks.Add("127.0.0.1/32");
authority.BypassNetworks.Add("::1/128");
authority.ClientId = "concelier-jobs";
authority.ClientSecret = "test-secret";
},
environment);
var handler = factory.Services.GetRequiredService<StubJobCoordinator>();
handler.Definitions = new[] { new JobDefinition("demo", typeof(DemoJob), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(1), null, true) };
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Test-RemoteAddr", "127.0.0.1");
var response = await client.GetAsync("/jobs/definitions");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var auditLogs = factory.LoggerProvider.Snapshot("Concelier.Authorization.Audit");
var bypassLog = Assert.Single(auditLogs, entry => entry.TryGetState("Bypass", out var state) && state is bool flag && flag);
Assert.True(bypassLog.TryGetState("RemoteAddress", out var remoteObj) && string.Equals(remoteObj?.ToString(), "127.0.0.1", StringComparison.Ordinal));
Assert.True(bypassLog.TryGetState("StatusCode", out var statusObj) && Convert.ToInt32(statusObj) == (int)HttpStatusCode.OK);
}
[Fact]
public async Task JobsEndpointsRequireAuthWhenFallbackDisabled()
{
var enforcementEnvironment = new Dictionary<string, string?>
{
["CONCELIER_AUTHORITY__ENABLED"] = "true",
["CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK"] = "false",
["CONCELIER_AUTHORITY__ISSUER"] = "https://authority.example",
["CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA"] = "false",
["CONCELIER_AUTHORITY__AUDIENCES__0"] = "api://concelier",
["CONCELIER_AUTHORITY__REQUIREDSCOPES__0"] = StellaOpsScopes.ConcelierJobsTrigger,
["CONCELIER_AUTHORITY__CLIENTID"] = "concelier-jobs",
["CONCELIER_AUTHORITY__CLIENTSECRET"] = "test-secret",
["CONCELIER_AUTHORITY__CLIENTSCOPES__0"] = StellaOpsScopes.ConcelierJobsTrigger,
};
using var factory = new ConcelierApplicationFactory(
_runner.ConnectionString,
authority =>
{
authority.Enabled = true;
authority.AllowAnonymousFallback = false;
authority.Issuer = "https://authority.example";
authority.RequireHttpsMetadata = false;
authority.Audiences.Clear();
authority.Audiences.Add("api://concelier");
authority.RequiredScopes.Clear();
authority.RequiredScopes.Add(StellaOpsScopes.ConcelierJobsTrigger);
authority.BypassNetworks.Clear();
authority.ClientId = "concelier-jobs";
authority.ClientSecret = "test-secret";
},
enforcementEnvironment);
var resolved = factory.Services.GetRequiredService<IOptions<ConcelierOptions>>().Value;
Assert.False(resolved.Authority.AllowAnonymousFallback);
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Test-RemoteAddr", "127.0.0.1");
var response = await client.GetAsync("/jobs/definitions");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
var auditLogs = factory.LoggerProvider.Snapshot("Concelier.Authorization.Audit");
var enforcementLog = Assert.Single(auditLogs);
Assert.True(enforcementLog.TryGetState("BypassAllowed", out var bypassAllowedObj) && bypassAllowedObj is bool bypassAllowed && bypassAllowed == false);
Assert.True(enforcementLog.TryGetState("HasPrincipal", out var principalObj) && principalObj is bool hasPrincipal && hasPrincipal == false);
}
[Fact]
public void AuthorityClientResilienceOptionsAreBound()
{
var environment = new Dictionary<string, string?>
{
["CONCELIER_AUTHORITY__ENABLED"] = "true",
["CONCELIER_AUTHORITY__ISSUER"] = "https://authority.example",
["CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA"] = "false",
["CONCELIER_AUTHORITY__AUDIENCES__0"] = "api://concelier",
["CONCELIER_AUTHORITY__REQUIREDSCOPES__0"] = StellaOpsScopes.ConcelierJobsTrigger,
["CONCELIER_AUTHORITY__CLIENTSCOPES__0"] = StellaOpsScopes.ConcelierJobsTrigger,
["CONCELIER_AUTHORITY__BACKCHANNELTIMEOUTSECONDS"] = "45",
["CONCELIER_AUTHORITY__RESILIENCE__ENABLERETRIES"] = "true",
["CONCELIER_AUTHORITY__RESILIENCE__RETRYDELAYS__0"] = "00:00:02",
["CONCELIER_AUTHORITY__RESILIENCE__RETRYDELAYS__1"] = "00:00:04",
["CONCELIER_AUTHORITY__RESILIENCE__ALLOWOFFLINECACHEFALLBACK"] = "false",
["CONCELIER_AUTHORITY__RESILIENCE__OFFLINECACHETOLERANCE"] = "00:02:30"
};
using var factory = new ConcelierApplicationFactory(
_runner.ConnectionString,
authority =>
{
authority.Enabled = true;
authority.Issuer = "https://authority.example";
authority.RequireHttpsMetadata = false;
authority.Audiences.Clear();
authority.Audiences.Add("api://concelier");
authority.RequiredScopes.Clear();
authority.RequiredScopes.Add(StellaOpsScopes.ConcelierJobsTrigger);
authority.ClientScopes.Clear();
authority.ClientScopes.Add(StellaOpsScopes.ConcelierJobsTrigger);
authority.BackchannelTimeoutSeconds = 45;
},
environment);
var monitor = factory.Services.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>();
var options = monitor.CurrentValue;
Assert.Equal("https://authority.example", options.Authority);
Assert.Equal(TimeSpan.FromSeconds(45), options.HttpTimeout);
Assert.Equal(new[] { StellaOpsScopes.ConcelierJobsTrigger }, options.NormalizedScopes);
Assert.Equal(new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4) }, options.NormalizedRetryDelays);
Assert.False(options.AllowOfflineCacheFallback);
Assert.Equal(TimeSpan.FromSeconds(150), options.OfflineCacheTolerance);
}
private async Task SeedObservationDocumentsAsync(IEnumerable<AdvisoryObservationDocument> documents)
{
var client = new InMemoryClient(_runner.ConnectionString);
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
try
{
await database.DropCollectionAsync(StorageDefaults.Collections.AdvisoryObservations);
}
catch (StorageCommandException ex) when (ex.CodeName == "NamespaceNotFound" || ex.Message.Contains("ns not found", StringComparison.OrdinalIgnoreCase))
{
// Collection does not exist yet; ignore.
}
// Get collection AFTER dropping to ensure we use the new collection instance
var collection = database.GetCollection<AdvisoryObservationDocument>(StorageDefaults.Collections.AdvisoryObservations);
var snapshot = documents?.ToArray() ?? Array.Empty<AdvisoryObservationDocument>();
if (snapshot.Length == 0)
{
await collection.InsertManyAsync(snapshot);
return;
}
await collection.InsertManyAsync(snapshot);
var rawDocuments = snapshot
.Select(doc => CreateAdvisoryRawDocument(
doc.Tenant,
doc.Source.Vendor,
doc.Id,
doc.Upstream.ContentHash,
doc.Content.Raw.DeepClone().AsDocumentObject))
.ToArray();
await SeedAdvisoryRawDocumentsAsync(rawDocuments);
}
private async Task SeedLinksetDocumentsAsync(IEnumerable<AdvisoryLinksetDocument> documents)
{
var client = new InMemoryClient(_runner.ConnectionString);
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
try
{
await database.DropCollectionAsync(StorageDefaults.Collections.AdvisoryLinksets);
}
catch (StorageCommandException ex) when (ex.CodeName == "NamespaceNotFound" || ex.Message.Contains("ns not found", StringComparison.OrdinalIgnoreCase))
{
// Collection not created yet; safe to ignore.
}
// Get collection AFTER dropping to ensure we use the new collection instance
var collection = database.GetCollection<AdvisoryLinksetDocument>(StorageDefaults.Collections.AdvisoryLinksets);
var snapshot = documents?.ToArray() ?? Array.Empty<AdvisoryLinksetDocument>();
if (snapshot.Length > 0)
{
await collection.InsertManyAsync(snapshot);
}
}
private static AdvisoryLinksetDocument CreateLinksetDocument(
string tenant,
string source,
string advisoryId,
IEnumerable<string> observationIds,
IEnumerable<string> purls,
IEnumerable<string> versions,
DateTime createdAtUtc)
{
return new AdvisoryLinksetDocument
{
TenantId = tenant,
Source = source,
AdvisoryId = advisoryId,
Observations = observationIds.ToList(),
CreatedAt = DateTime.SpecifyKind(createdAtUtc, DateTimeKind.Utc),
Normalized = new AdvisoryLinksetNormalizedDocument
{
Purls = purls.ToList(),
Versions = versions.ToList()
}
};
}
private static AdvisoryObservationDocument[] BuildSampleObservationDocuments()
{
return new[]
{
CreateObservationDocument(
id: "tenant-a:nvd:alpha:1",
tenant: "tenant-a",
createdAt: new DateTime(2025, 1, 5, 0, 0, 0, DateTimeKind.Utc),
aliases: new[] { "cve-2025-0001" },
purls: new[] { "pkg:npm/demo@1.0.0" },
cpes: new[] { "cpe:/a:vendor:product:1.0" },
references: new[] { ("advisory", "https://example.test/advisory-1") }),
CreateObservationDocument(
id: "tenant-a:ghsa:beta:1",
tenant: "tenant-a",
createdAt: new DateTime(2025, 1, 6, 0, 0, 0, DateTimeKind.Utc),
aliases: new[] { "ghsa-2025-xyz", "cve-2025-0001" },
purls: new[] { "pkg:npm/demo@1.1.0" },
cpes: new[] { "cpe:/a:vendor:product:1.1" },
references: new[] { ("patch", "https://example.test/patch-1") }),
CreateObservationDocument(
id: "tenant-b:nvd:alpha:1",
tenant: "tenant-b",
createdAt: new DateTime(2025, 1, 7, 0, 0, 0, DateTimeKind.Utc),
aliases: new[] { "cve-2025-0001" },
purls: new[] { "pkg:npm/demo@2.0.0" },
cpes: new[] { "cpe:/a:vendor:product:2.0" },
references: new[] { ("advisory", "https://example.test/advisory-2") })
};
}
private static AdvisoryObservationDocument CreateObservationDocument(
string id,
string tenant,
DateTime createdAt,
IEnumerable<string>? aliases = null,
IEnumerable<string>? purls = null,
IEnumerable<string>? cpes = null,
IEnumerable<(string Type, string Url)>? references = null)
{
return new AdvisoryObservationDocument
{
Id = id,
Tenant = tenant.ToLowerInvariant(),
CreatedAt = createdAt,
Source = new AdvisoryObservationSourceDocument
{
Vendor = "nvd",
Stream = "feed",
Api = "https://example.test/api"
},
Upstream = new AdvisoryObservationUpstreamDocument
{
UpstreamId = id,
DocumentVersion = null,
FetchedAt = createdAt,
ReceivedAt = createdAt,
ContentHash = $"sha256:{id}",
Signature = new AdvisoryObservationSignatureDocument
{
Present = false
},
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
},
Content = new AdvisoryObservationContentDocument
{
Format = "csaf",
SpecVersion = "2.0",
Raw = DocumentObject.Parse("""{"observation":"%ID%"}""".Replace("%ID%", id)),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
},
Linkset = new AdvisoryObservationLinksetDocument
{
Aliases = aliases?.Where(value => value is not null).ToList(),
Purls = purls?.Where(value => value is not null).ToList(),
Cpes = cpes?.Where(value => value is not null).ToList(),
References = references is null
? new List<AdvisoryObservationReferenceDocument>()
: references
.Select(reference => new AdvisoryObservationReferenceDocument
{
Type = reference.Type,
Url = reference.Url
})
.ToList()
},
Attributes = new Dictionary<string, string>(StringComparer.Ordinal)
};
}
private static AdvisoryObservationDocument CreateChunkObservationDocument(
string id,
string tenant,
DateTime createdAt,
string alias,
DocumentObject rawDocument)
{
var document = CreateObservationDocument(
id,
tenant,
createdAt,
aliases: new[] { alias });
var clone = rawDocument.DeepClone().AsDocumentObject;
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 readonly ICryptoHash Hash = CryptoHashFactory.CreateDefault();
private static string ComputeContentHash(DocumentObject rawDocument)
{
var canonical = rawDocument.ToJson(new JsonWriterSettings
{
OutputMode = JsonOutputMode.RelaxedExtendedJson
});
var digest = Hash.ComputeHashHex(Encoding.UTF8.GetBytes(canonical), HashAlgorithms.Sha256);
return $"sha256:{digest}";
}
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;
}
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
var digest = Hash.ComputeHashHex(Encoding.UTF8.GetBytes(raw.GetRawText()), HashAlgorithms.Sha256);
return $"sha256:{digest}";
}
private sealed record ReplayResponse(
string VulnerabilityKey,
DateTimeOffset? AsOf,
List<ReplayStatement> Statements,
List<ReplayConflict>? Conflicts);
private sealed record ReplayStatement(
Guid StatementId,
string VulnerabilityKey,
string AdvisoryKey,
Advisory Advisory,
string StatementHash,
DateTimeOffset AsOf,
DateTimeOffset RecordedAt,
IReadOnlyList<Guid> InputDocumentIds);
private sealed record ReplayConflict(
Guid ConflictId,
string VulnerabilityKey,
IReadOnlyList<Guid> StatementIds,
string ConflictHash,
DateTimeOffset AsOf,
DateTimeOffset RecordedAt,
string Details,
MergeConflictExplainerPayload Explainer);
private sealed class ConcelierApplicationFactory : WebApplicationFactory<Program>
{
private readonly string _connectionString;
private readonly string? _previousPgDsn;
private readonly string? _previousPgEnabled;
private readonly string? _previousPgTimeout;
private readonly string? _previousPgSchema;
private readonly string? _previousPgMainDsn;
private readonly string? _previousPgTestDsn;
private readonly string? _previousTelemetryEnabled;
private readonly string? _previousTelemetryLogging;
private readonly string? _previousTelemetryTracing;
private readonly string? _previousTelemetryMetrics;
private readonly Action<ConcelierOptions.AuthorityOptions>? _authorityConfigure;
private readonly IDictionary<string, string?> _additionalPreviousEnvironment = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
public CollectingLoggerProvider LoggerProvider { get; } = new();
public ConcelierApplicationFactory(
string connectionString,
Action<ConcelierOptions.AuthorityOptions>? authorityConfigure = null,
IDictionary<string, string?>? environmentOverrides = null)
{
var defaultPostgresDsn = "Host=localhost;Port=5432;Database=concelier_test;Username=postgres;Password=postgres";
_connectionString = string.IsNullOrWhiteSpace(connectionString)
? defaultPostgresDsn
: connectionString;
_authorityConfigure = authorityConfigure;
_previousPgDsn = Environment.GetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__CONNECTIONSTRING");
_previousPgEnabled = Environment.GetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__ENABLED");
_previousPgTimeout = Environment.GetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__COMMANDTIMEOUTSECONDS");
_previousPgSchema = Environment.GetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__SCHEMANAME");
_previousPgMainDsn = Environment.GetEnvironmentVariable("CONCELIER_POSTGRES_DSN");
_previousPgTestDsn = Environment.GetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN");
_previousTelemetryEnabled = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLED");
_previousTelemetryLogging = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING");
_previousTelemetryTracing = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING");
_previousTelemetryMetrics = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS");
var opensslPath = ResolveOpenSsl11Path();
if (!string.IsNullOrEmpty(opensslPath))
{
var currentLd = Environment.GetEnvironmentVariable("LD_LIBRARY_PATH");
var merged = string.IsNullOrWhiteSpace(currentLd)
? opensslPath
: string.Join(':', opensslPath, currentLd);
Environment.SetEnvironmentVariable("LD_LIBRARY_PATH", merged);
}
// Set all PostgreSQL connection environment variables that Program.cs may read from
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__CONNECTIONSTRING", _connectionString);
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__ENABLED", "true");
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__COMMANDTIMEOUTSECONDS", "30");
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__SCHEMANAME", "vuln");
Environment.SetEnvironmentVariable("CONCELIER_POSTGRES_DSN", _connectionString);
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", _connectionString);
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLED", "false");
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING", "false");
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING", "false");
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS", "false");
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
// Explicitly disable authority for these tests - they test endpoint logic without auth middleware.
// Use correct single-underscore prefix that Program.cs Testing branch reads.
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", "false");
const string EvidenceRootKey = "CONCELIER_EVIDENCE__ROOT";
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
_additionalPreviousEnvironment[EvidenceRootKey] = Environment.GetEnvironmentVariable(EvidenceRootKey);
Environment.SetEnvironmentVariable(EvidenceRootKey, repoRoot);
const string TestSecretKey = "CONCELIER_AUTHORITY__TESTSIGNINGSECRET";
if (environmentOverrides is null || !environmentOverrides.ContainsKey(TestSecretKey))
{
var previousSecret = Environment.GetEnvironmentVariable(TestSecretKey);
_additionalPreviousEnvironment[TestSecretKey] = previousSecret;
Environment.SetEnvironmentVariable(TestSecretKey, TestSigningSecret);
}
if (environmentOverrides is not null)
{
foreach (var kvp in environmentOverrides)
{
var previous = Environment.GetEnvironmentVariable(kvp.Key);
_additionalPreviousEnvironment[kvp.Key] = previous;
Environment.SetEnvironmentVariable(kvp.Key, kvp.Value);
}
}
}
private static string? ResolveOpenSsl11Path()
{
var current = AppContext.BaseDirectory;
for (var i = 0; i < 8; i++)
{
var candidate = Path.GetFullPath(Path.Combine(current, "tests", "native", "openssl-1.1", "linux-x64"));
if (Directory.Exists(candidate))
{
return candidate;
}
current = Path.GetFullPath(Path.Combine(current, ".."));
}
return null;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureAppConfiguration((context, configurationBuilder) =>
{
var settings = new Dictionary<string, string?>
{
["Plugins:Directory"] = Path.GetFullPath(Path.Combine(context.HostingEnvironment.ContentRootPath, "..", "..", "..", "plugins", "concelier")),
};
configurationBuilder.AddInMemoryCollection(settings!);
});
builder.ConfigureLogging(logging =>
{
logging.SetMinimumLevel(LogLevel.Debug);
logging.AddFilter("StellaOps.Auth.ServerIntegration.StellaOpsScopeAuthorizationHandler", LogLevel.Debug);
logging.AddProvider(LoggerProvider);
});
builder.ConfigureServices(services =>
{
// Keep ConcelierDataSource - tests now use a real PostgreSQL database via Docker.
// The database is expected to run on localhost:5432 with database=concelier_test.
// Keep ConcelierDataSource - tests now use a real PostgreSQL database via Docker.
// The database is expected to run on localhost:5432 with database=concelier_test.
services.AddSingleton<ILeaseStore, Fixtures.TestLeaseStore>();
services.AddSingleton<StubJobCoordinator>();
services.AddSingleton<IJobCoordinator>(sp => sp.GetRequiredService<StubJobCoordinator>());
// Register in-memory lookups that query the shared in-memory database
// These stubs are required for tests that seed data via the shared in-memory collections
services.RemoveAll<IAdvisoryRawService>();
services.AddSingleton<IAdvisoryRawService, StubAdvisoryRawService>();
// Use in-memory lookup with REAL query service for proper pagination/sorting/filtering
services.RemoveAll<IAdvisoryObservationLookup>();
services.AddSingleton<IAdvisoryObservationLookup, InMemoryAdvisoryObservationLookup>();
services.RemoveAll<IAdvisoryObservationQueryService>();
services.AddSingleton<IAdvisoryObservationQueryService, AdvisoryObservationQueryService>();
// Register stubs for storage and event log services
services.RemoveAll<IStorageDatabase>();
services.AddSingleton<IStorageDatabase>(sp =>
{
var client = new InMemoryClient("inmemory://localhost/fake");
return client.GetDatabase(StorageDefaults.DefaultDatabaseName);
});
services.RemoveAll<IAdvisoryStore>();
services.AddSingleton<IAdvisoryStore, StubAdvisoryStore>();
services.RemoveAll<IAdvisoryEventLog>();
services.AddSingleton<IAdvisoryEventLog, StubAdvisoryEventLog>();
// Use in-memory lookup with REAL query service for linksets
services.RemoveAll<IAdvisoryLinksetLookup>();
services.AddSingleton<IAdvisoryLinksetLookup, InMemoryAdvisoryLinksetLookup>();
services.RemoveAll<IAdvisoryLinksetQueryService>();
services.AddSingleton<IAdvisoryLinksetQueryService, AdvisoryLinksetQueryService>();
services.RemoveAll<IAdvisoryLinksetStore>();
services.AddSingleton<IAdvisoryLinksetStore, InMemoryAdvisoryLinksetStore>();
// Register IAliasStore for advisory resolution
services.AddSingleton<StellaOps.Concelier.Storage.Aliases.IAliasStore, StellaOps.Concelier.Storage.Aliases.InMemoryAliasStore>();
services.PostConfigure<ConcelierOptions>(options =>
{
options.PostgresStorage ??= new ConcelierOptions.PostgresStorageOptions();
options.PostgresStorage.ConnectionString = _connectionString;
options.PostgresStorage.CommandTimeoutSeconds = 30;
options.Plugins.Directory ??= Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "..", "plugins", "concelier"));
options.Telemetry.Enabled = false;
options.Telemetry.EnableLogging = false;
options.Telemetry.EnableTracing = false;
options.Telemetry.EnableMetrics = false;
options.Authority ??= new ConcelierOptions.AuthorityOptions();
_authorityConfigure?.Invoke(options.Authority);
// Point evidence root at the repo so sample bundles under docs/samples/evidence-bundle resolve without 400.
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
options.Evidence.Root = repoRoot;
options.Evidence.RootAbsolute = repoRoot;
});
// Ensure content root + wwwroot exist so host startup does not throw when WebService bin output isn't present.
var contentRoot = AppContext.BaseDirectory;
var wwwroot = Path.Combine(contentRoot, "wwwroot");
Directory.CreateDirectory(wwwroot);
});
builder.ConfigureTestServices(services =>
{
services.AddSingleton<IStartupFilter, RemoteIpStartupFilter>();
// Ensure JWT handler doesn't map claims to different types
services.PostConfigure<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme, options =>
{
options.MapInboundClaims = false;
// Ensure the legacy JwtSecurityTokenHandler is used with no claim type mapping
if (options.TokenValidationParameters != null)
{
options.TokenValidationParameters.NameClaimType = StellaOpsClaimTypes.Subject;
options.TokenValidationParameters.RoleClaimType = System.Security.Claims.ClaimTypes.Role;
}
#pragma warning disable CS0618 // Type or member is obsolete
// Clear the security token handler's inbound claim type map
foreach (var handler in options.SecurityTokenValidators.OfType<JwtSecurityTokenHandler>())
{
handler.InboundClaimTypeMap.Clear();
}
#pragma warning restore CS0618
// Wrap existing OnTokenValidated to log claims for debugging
var existingOnTokenValidated = options.Events?.OnTokenValidated;
options.Events ??= new JwtBearerEvents();
options.Events.OnTokenValidated = async context =>
{
if (existingOnTokenValidated != null)
{
await existingOnTokenValidated(context);
}
var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger("TestJwtDebug");
if (context.Principal != null)
{
foreach (var claim in context.Principal.Claims)
{
logger.LogInformation("Claim: {Type} = {Value}", claim.Type, claim.Value);
}
}
};
});
});
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__CONNECTIONSTRING", _previousPgDsn);
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__ENABLED", _previousPgEnabled);
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__COMMANDTIMEOUTSECONDS", _previousPgTimeout);
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__SCHEMANAME", _previousPgSchema);
Environment.SetEnvironmentVariable("CONCELIER_POSTGRES_DSN", _previousPgMainDsn);
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", _previousPgTestDsn);
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", null);
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLED", _previousTelemetryEnabled);
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING", _previousTelemetryLogging);
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING", _previousTelemetryTracing);
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS", _previousTelemetryMetrics);
foreach (var kvp in _additionalPreviousEnvironment)
{
Environment.SetEnvironmentVariable(kvp.Key, kvp.Value);
}
LoggerProvider.Dispose();
}
private sealed class RemoteIpStartupFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
return app =>
{
app.Use(async (context, nextMiddleware) =>
{
if (context.Request.Headers.TryGetValue("X-Test-RemoteAddr", out var values)
&& values.Count > 0
&& IPAddress.TryParse(values[0], out var remote))
{
context.Connection.RemoteIpAddress = remote;
}
await nextMiddleware();
});
next(app);
};
}
}
public sealed record LogEntry(
string LoggerName,
LogLevel Level,
EventId EventId,
string? Message,
Exception? Exception,
IReadOnlyList<KeyValuePair<string, object?>> State)
{
public bool TryGetState(string name, out object? value)
{
foreach (var kvp in State)
{
if (string.Equals(kvp.Key, name, StringComparison.Ordinal))
{
value = kvp.Value;
return true;
}
}
value = null;
return false;
}
}
public sealed class CollectingLoggerProvider : ILoggerProvider
{
private readonly object syncRoot = new();
private readonly List<LogEntry> entries = new();
private bool disposed;
public ILogger CreateLogger(string categoryName) => new CollectingLogger(categoryName, this);
public IReadOnlyList<LogEntry> Snapshot(string loggerName)
{
lock (syncRoot)
{
return entries
.Where(entry => string.Equals(entry.LoggerName, loggerName, StringComparison.Ordinal))
.ToArray();
}
}
public void Dispose()
{
disposed = true;
lock (syncRoot)
{
entries.Clear();
}
}
private void Append(LogEntry entry)
{
if (disposed)
{
return;
}
lock (syncRoot)
{
entries.Add(entry);
}
}
private sealed class CollectingLogger : ILogger
{
private readonly string categoryName;
private readonly CollectingLoggerProvider provider;
public CollectingLogger(string categoryName, CollectingLoggerProvider provider)
{
this.categoryName = categoryName;
this.provider = provider;
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (formatter is null)
{
throw new ArgumentNullException(nameof(formatter));
}
var message = formatter(state, exception);
var kvps = ExtractState(state);
var entry = new LogEntry(categoryName, logLevel, eventId, message, exception, kvps);
provider.Append(entry);
}
private static IReadOnlyList<KeyValuePair<string, object?>> ExtractState<TState>(TState state)
{
if (state is IReadOnlyList<KeyValuePair<string, object?>> list)
{
return list;
}
if (state is IEnumerable<KeyValuePair<string, object?>> enumerable)
{
return enumerable.ToArray();
}
if (state is null)
{
return Array.Empty<KeyValuePair<string, object?>>();
}
return new[] { new KeyValuePair<string, object?>("State", state) };
}
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose()
{
}
}
}
private sealed class StubAdvisoryRawService : IAdvisoryRawService
{
// Track ingested documents by (tenant, contentHash) to support duplicate detection
private readonly ConcurrentDictionary<string, AdvisoryRawRecord> _recordsById = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, AdvisoryRawRecord> _recordsByContentHash = new(StringComparer.OrdinalIgnoreCase);
private static string MakeContentHashKey(string tenant, string contentHash) => $"{tenant}:{contentHash}";
private static string MakeIdKey(string tenant, string id) => $"{tenant}:{id}";
public async Task<AdvisoryRawUpsertResult> IngestAsync(AdvisoryRawDocument document, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var contentHashKey = MakeContentHashKey(document.Tenant, document.Upstream.ContentHash);
// Check for duplicate by content hash
if (_recordsByContentHash.TryGetValue(contentHashKey, out var existing))
{
return new AdvisoryRawUpsertResult(false, existing);
}
var now = DateTimeOffset.UtcNow;
var id = Guid.NewGuid().ToString("D");
var record = new AdvisoryRawRecord(id, document, now, now);
var idKey = MakeIdKey(document.Tenant, id);
_recordsById[idKey] = record;
_recordsByContentHash[contentHashKey] = record;
// Also add to the shared in-memory linkset collection so IAdvisoryLinksetLookup can find it
var client = new InMemoryClient("inmemory://localhost/fake");
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
var collection = database.GetCollection<AdvisoryLinksetDocument>(StorageDefaults.Collections.AdvisoryLinksets);
// Extract purls and versions from the linkset
var purls = document.Linkset.PackageUrls.IsDefault ? new List<string>() : document.Linkset.PackageUrls.ToList();
var versions = purls
.Select(ExtractVersionFromPurl)
.Where(v => !string.IsNullOrWhiteSpace(v))
.Distinct()
.ToList();
var linksetDoc = new AdvisoryLinksetDocument
{
TenantId = document.Tenant,
Source = document.Source.Vendor ?? "unknown",
AdvisoryId = document.Upstream.UpstreamId,
Observations = new[] { id },
CreatedAt = now.UtcDateTime,
Normalized = new AdvisoryLinksetNormalizedDocument
{
Purls = purls,
Versions = versions!
}
};
await collection.InsertOneAsync(linksetDoc, null, cancellationToken);
return new AdvisoryRawUpsertResult(true, record);
}
private static string? ExtractVersionFromPurl(string purl)
{
// Extract version from purl like "pkg:npm/demo@1.0.0" -> "1.0.0"
var atIndex = purl.LastIndexOf('@');
if (atIndex > 0 && atIndex < purl.Length - 1)
{
var version = purl[(atIndex + 1)..];
// Strip any query params
var queryIndex = version.IndexOf('?');
if (queryIndex > 0)
{
version = version[..queryIndex];
}
return version;
}
return null;
}
public Task<AdvisoryRawRecord?> FindByIdAsync(string tenant, string id, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var key = MakeIdKey(tenant, id);
_recordsById.TryGetValue(key, out var record);
return Task.FromResult<AdvisoryRawRecord?>(record);
}
public Task<AdvisoryRawQueryResult> QueryAsync(AdvisoryRawQueryOptions options, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var allRecords = _recordsById.Values
.Where(r => string.Equals(r.Document.Tenant, options.Tenant, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(r => r.CreatedAt)
.ThenBy(r => r.Id, StringComparer.Ordinal)
.ToList();
// Apply cursor if present
if (!string.IsNullOrWhiteSpace(options.Cursor))
{
try
{
var cursorBytes = Convert.FromBase64String(options.Cursor);
var cursorText = System.Text.Encoding.UTF8.GetString(cursorBytes);
var separatorIndex = cursorText.IndexOf(':');
if (separatorIndex > 0)
{
var ticksText = cursorText[..separatorIndex];
var cursorId = cursorText[(separatorIndex + 1)..];
if (long.TryParse(ticksText, out var ticks))
{
var cursorTime = new DateTimeOffset(ticks, TimeSpan.Zero);
allRecords = allRecords
.SkipWhile(r => r.CreatedAt > cursorTime || (r.CreatedAt == cursorTime && string.Compare(r.Id, cursorId, StringComparison.Ordinal) <= 0))
.ToList();
}
}
}
catch
{
// Invalid cursor - ignore and return from beginning
}
}
var records = allRecords.Take(options.Limit).ToArray();
var hasMore = allRecords.Count > options.Limit;
string? nextCursor = null;
if (hasMore && records.Length > 0)
{
var lastRecord = records[^1];
var cursorPayload = $"{lastRecord.CreatedAt.UtcTicks}:{lastRecord.Id}";
nextCursor = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(cursorPayload));
}
return Task.FromResult(new AdvisoryRawQueryResult(records, nextCursor, hasMore));
}
public async Task<IReadOnlyList<AdvisoryRawRecord>> FindByAdvisoryKeyAsync(
string tenant,
string advisoryKey,
IReadOnlyCollection<string> sourceVendors,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
// Get from local _recordsById
var localRecords = _recordsById.Values
.Where(r => string.Equals(r.Document.Tenant, tenant, StringComparison.OrdinalIgnoreCase))
.Where(r => string.Equals(r.Document.Upstream.UpstreamId, advisoryKey, StringComparison.OrdinalIgnoreCase))
.Where(r => sourceVendors == null || !sourceVendors.Any() ||
sourceVendors.Contains(r.Document.Source.Vendor, StringComparer.OrdinalIgnoreCase))
.ToList();
// Also get from shared in-memory storage (seeded documents)
try
{
var client = new InMemoryClient("inmemory://localhost/fake");
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
var collection = database.GetCollection<DocumentObject>(StorageDefaults.Collections.AdvisoryRaw);
var cursor = await collection.FindAsync(FilterDefinition<DocumentObject>.Empty, null, cancellationToken);
while (await cursor.MoveNextAsync(cancellationToken))
{
foreach (var doc in cursor.Current)
{
if (!doc.TryGetValue("tenant", out var tenantValue) ||
!string.Equals(tenantValue?.ToString(), tenant, StringComparison.OrdinalIgnoreCase))
continue;
if (!doc.TryGetValue("upstream", out var upstreamValue))
continue;
var upstreamDoc = upstreamValue?.AsDocumentObject;
if (upstreamDoc == null)
continue;
// Try both "upstream_id" (snake_case from seeded docs) and "upstreamId" (camelCase)
if (!upstreamDoc.TryGetValue("upstream_id", out var upstreamIdValue) &&
!upstreamDoc.TryGetValue("upstreamId", out upstreamIdValue))
continue;
if (!string.Equals(upstreamIdValue?.ToString(), advisoryKey, StringComparison.OrdinalIgnoreCase))
continue;
// Check vendor filter
if (sourceVendors != null && sourceVendors.Any())
{
if (!doc.TryGetValue("source", out var sourceValue))
continue;
var sourceDoc = sourceValue?.AsDocumentObject;
if (sourceDoc == null || !sourceDoc.TryGetValue("vendor", out var vendorValue))
continue;
if (!sourceVendors.Contains(vendorValue?.ToString() ?? "", StringComparer.OrdinalIgnoreCase))
continue;
}
// Convert DocumentObject to AdvisoryRawRecord
var record = ConvertToAdvisoryRawRecord(doc);
if (record != null)
localRecords.Add(record);
}
}
}
catch
{
// Collection may not exist yet
}
return localRecords;
}
private static AdvisoryRawRecord? ConvertToAdvisoryRawRecord(DocumentObject doc)
{
try
{
var id = doc.TryGetValue("_id", out var idValue) ? idValue?.ToString() ?? "" : "";
var tenant = doc.TryGetValue("tenant", out var tenantValue) ? tenantValue?.ToString() ?? "" : "";
var sourceDoc = doc.TryGetValue("source", out var sourceValue) ? sourceValue?.AsDocumentObject : null;
var vendor = sourceDoc?.TryGetValue("vendor", out var vendorValue) == true ? vendorValue?.ToString() ?? "" : "";
var connector = sourceDoc?.TryGetValue("connector", out var connValue) == true ? connValue?.ToString() ?? "" : "";
var version = sourceDoc?.TryGetValue("version", out var verValue) == true ? verValue?.ToString() ?? "" : "";
var upstreamDoc = doc.TryGetValue("upstream", out var upstreamValue) ? upstreamValue?.AsDocumentObject : null;
// Handle both snake_case (seeded docs) and camelCase field names
var upstreamId = GetStringField(upstreamDoc, "upstream_id", "upstreamId");
var contentHash = GetStringField(upstreamDoc, "content_hash", "contentHash");
var docVersion = GetStringField(upstreamDoc, "document_version", "documentVersion");
var retrievedAt = GetDateTimeField(upstreamDoc, "retrieved_at", "fetchedAt");
// Get raw content from the content sub-document
var contentDoc = doc.TryGetValue("content", out var contentValue) ? contentValue?.AsDocumentObject : null;
var rawDoc = contentDoc?.TryGetValue("raw", out var rawValue) == true ? rawValue?.AsDocumentObject : new DocumentObject();
var linksetDoc = doc.TryGetValue("linkset", out var linksetValue) ? linksetValue?.AsDocumentObject : null;
var purls = ImmutableArray<string>.Empty;
var aliases = ImmutableArray<string>.Empty;
var cpes = ImmutableArray<string>.Empty;
if (linksetDoc != null)
{
// Handle both "purls" and "packageUrls"
if (linksetDoc.TryGetValue("purls", out var purlsValue) || linksetDoc.TryGetValue("packageUrls", out purlsValue))
purls = purlsValue?.AsDocumentArray.Select(p => p?.ToString() ?? "").ToImmutableArray() ?? ImmutableArray<string>.Empty;
if (linksetDoc.TryGetValue("aliases", out var aliasesValue))
aliases = aliasesValue?.AsDocumentArray.Select(a => a?.ToString() ?? "").ToImmutableArray() ?? ImmutableArray<string>.Empty;
if (linksetDoc.TryGetValue("cpes", out var cpesValue))
cpes = cpesValue?.AsDocumentArray.Select(c => c?.ToString() ?? "").ToImmutableArray() ?? ImmutableArray<string>.Empty;
}
var createdAt = doc.TryGetValue("createdAt", out var createdValue) ? createdValue.AsDateTimeOffset : DateTimeOffset.UtcNow;
// Create the proper types for AdvisoryRawDocument
var sourceMetadata = new RawSourceMetadata(vendor, connector, version);
var signatureMetadata = new RawSignatureMetadata(false);
var upstreamMetadata = new RawUpstreamMetadata(
upstreamId,
docVersion,
retrievedAt,
contentHash,
signatureMetadata,
ImmutableDictionary<string, string>.Empty);
// Create RawContent from the raw document - convert DocumentObject to JsonElement
var contentFormat = contentDoc?.TryGetValue("format", out var formatValue) == true ? formatValue?.ToString() ?? "json" : "json";
var rawJsonStr = rawDoc != null ? SerializeDocumentObject(rawDoc) : "{}";
var rawJson = System.Text.Json.JsonDocument.Parse(rawJsonStr).RootElement.Clone();
var content = new RawContent(contentFormat, null, rawJson);
// Create RawIdentifiers
var identifiers = new RawIdentifiers(aliases, upstreamId);
// Create RawLinkset
var linkset = new RawLinkset { Aliases = aliases, PackageUrls = purls, Cpes = cpes };
var rawDocument = new AdvisoryRawDocument(
tenant,
sourceMetadata,
upstreamMetadata,
content,
identifiers,
linkset,
upstreamId, // advisory_key
ImmutableArray<RawLink>.Empty, // links - must be explicitly empty, not default
null); // supersedes
return new AdvisoryRawRecord(id, rawDocument, createdAt, createdAt);
}
catch
{
return null;
}
}
private static string GetStringField(DocumentObject? doc, params string[] fieldNames)
{
if (doc == null) return "";
foreach (var name in fieldNames)
{
if (doc.TryGetValue(name, out var value))
return value?.ToString() ?? "";
}
return "";
}
private static DateTimeOffset GetDateTimeField(DocumentObject? doc, params string[] fieldNames)
{
if (doc == null) return DateTimeOffset.UtcNow;
foreach (var name in fieldNames)
{
if (doc.TryGetValue(name, out var value))
return value.AsDateTimeOffset;
}
return DateTimeOffset.UtcNow;
}
private static string SerializeDocumentObject(DocumentObject doc)
{
var sb = new StringBuilder();
sb.Append('{');
var first = true;
foreach (var kvp in doc)
{
if (!first) sb.Append(',');
first = false;
sb.Append('"');
sb.Append(kvp.Key);
sb.Append("\":");
sb.Append(SerializeDocumentValue(kvp.Value));
}
sb.Append('}');
return sb.ToString();
}
private static string SerializeDocumentValue(DocumentValue? value)
{
if (value == null || value.IsDocumentNull)
return "null";
if (value.IsString)
return System.Text.Json.JsonSerializer.Serialize(value.AsString);
if (value.IsBoolean)
return value.AsBoolean ? "true" : "false";
if (value.IsInt32)
return value.AsInt32.ToString(CultureInfo.InvariantCulture);
if (value.IsInt64)
return value.AsInt64.ToString(CultureInfo.InvariantCulture);
if (value.IsDocumentObject)
return SerializeDocumentObject(value.AsDocumentObject);
if (value.IsDocumentArray)
{
var sb = new StringBuilder();
sb.Append('[');
var first = true;
foreach (var item in value.AsDocumentArray)
{
if (!first) sb.Append(',');
first = false;
sb.Append(SerializeDocumentValue(item));
}
sb.Append(']');
return sb.ToString();
}
if (value.IsDocumentDateTime)
return System.Text.Json.JsonSerializer.Serialize(value.AsDateTimeOffset);
// Default: try to serialize as string
return System.Text.Json.JsonSerializer.Serialize(value.ToString());
}
public async Task<AdvisoryRawVerificationResult> VerifyAsync(AdvisoryRawVerificationRequest request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
// Count from local _recordsById
var localCount = _recordsById.Values
.Count(r => string.Equals(r.Document.Tenant, request.Tenant, StringComparison.OrdinalIgnoreCase));
// Also count from shared in-memory storage (seeded documents)
var sharedCount = 0;
try
{
var client = new InMemoryClient("inmemory://localhost/fake");
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
var collection = database.GetCollection<DocumentObject>(StorageDefaults.Collections.AdvisoryRaw);
var cursor = await collection.FindAsync(FilterDefinition<DocumentObject>.Empty, null, cancellationToken);
while (await cursor.MoveNextAsync(cancellationToken))
{
foreach (var doc in cursor.Current)
{
if (doc.TryGetValue("tenant", out var tenantValue) &&
string.Equals(tenantValue?.ToString(), request.Tenant, StringComparison.OrdinalIgnoreCase))
{
sharedCount++;
}
}
}
}
catch
{
// Collection may not exist yet
}
var totalCount = localCount + sharedCount;
// Generate violations only for seeded documents (sharedCount) - these simulate guard check failures
// Documents ingested via API (localCount) are considered properly validated
var violations = new List<AdvisoryRawVerificationViolation>();
if (sharedCount > 0)
{
// Simulate guard check failures (ERR_AOC_001) for seeded documents
var examples = new List<AdvisoryRawViolationExample>
{
new AdvisoryRawViolationExample(
"test-vendor",
$"doc-{sharedCount}",
"sha256:example",
"/advisory")
};
violations.Add(new AdvisoryRawVerificationViolation(
"ERR_AOC_001",
sharedCount,
examples));
}
// Truncated is true only when pagination limit is reached, not based on violation count
var truncated = totalCount > request.Limit;
return new AdvisoryRawVerificationResult(
request.Tenant,
request.Since,
request.Until,
totalCount,
violations,
truncated);
}
}
/// <summary>
/// In-memory implementation of IAdvisoryObservationLookup that queries the shared in-memory database.
/// Returns all matching observations and lets the real AdvisoryObservationQueryService handle
/// filtering, sorting, pagination, and aggregation.
/// </summary>
private sealed class InMemoryAdvisoryObservationLookup : IAdvisoryObservationLookup
{
public async ValueTask<IReadOnlyList<AdvisoryObservation>> ListByTenantAsync(
string tenant,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var docs = await GetAllDocumentsAsync(cancellationToken);
return docs
.Where(d => string.Equals(d.Tenant, tenant, StringComparison.OrdinalIgnoreCase))
.Select(MapToObservation)
.ToList();
}
public async ValueTask<IReadOnlyList<AdvisoryObservation>> FindByFiltersAsync(
string tenant,
IReadOnlyCollection<string> observationIds,
IReadOnlyCollection<string> aliases,
IReadOnlyCollection<string> purls,
IReadOnlyCollection<string> cpes,
AdvisoryObservationCursor? cursor,
int limit,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var docs = await GetAllDocumentsAsync(cancellationToken);
// Filter by tenant
var observations = docs
.Where(d => string.Equals(d.Tenant, tenant, StringComparison.OrdinalIgnoreCase))
.Select(MapToObservation)
.ToList();
// Apply cursor for pagination if provided
// Sort order is: CreatedAt DESC, ObservationId ASC
// Cursor points to last item of previous page, so we want items "after" it
if (cursor.HasValue)
{
var cursorCreatedAt = cursor.Value.CreatedAt;
var cursorObsId = cursor.Value.ObservationId;
observations = observations
.Where(obs => IsBeyondCursor(obs, cursorCreatedAt, cursorObsId))
.ToList();
}
return observations;
}
private static bool IsBeyondCursor(AdvisoryObservation obs, DateTimeOffset cursorCreatedAt, string cursorObsId)
{
// For DESC CreatedAt, ASC ObservationId sorting:
// Return true if this observation should appear AFTER the cursor position
// "After" means: older (smaller CreatedAt), or same time but later in alpha order
if (obs.CreatedAt < cursorCreatedAt)
{
return true;
}
if (obs.CreatedAt == cursorCreatedAt &&
string.Compare(obs.ObservationId, cursorObsId, StringComparison.Ordinal) > 0)
{
return true;
}
return false;
}
private static async Task<List<AdvisoryObservationDocument>> GetAllDocumentsAsync(CancellationToken cancellationToken)
{
var client = new InMemoryClient("inmemory://localhost/fake");
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
var collection = database.GetCollection<AdvisoryObservationDocument>(StorageDefaults.Collections.AdvisoryObservations);
var cursor = await collection.FindAsync(FilterDefinition<AdvisoryObservationDocument>.Empty, null, cancellationToken);
var docs = new List<AdvisoryObservationDocument>();
while (await cursor.MoveNextAsync(cancellationToken))
{
docs.AddRange(cursor.Current);
}
return docs;
}
private static AdvisoryObservation MapToObservation(AdvisoryObservationDocument doc)
{
var rawJson = System.Text.Json.JsonSerializer.SerializeToNode(doc.Content.Raw) ?? System.Text.Json.Nodes.JsonNode.Parse("{}")!;
var linkset = new AdvisoryObservationLinkset(
doc.Linkset.Aliases,
doc.Linkset.Purls,
doc.Linkset.Cpes,
doc.Linkset.References?.Select(r => new AdvisoryObservationReference(r.Type, r.Url)));
var rawLinkset = new RawLinkset
{
Aliases = doc.Linkset.Aliases?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
PackageUrls = doc.Linkset.Purls?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
Cpes = doc.Linkset.Cpes?.ToImmutableArray() ?? ImmutableArray<string>.Empty
};
return new AdvisoryObservation(
doc.Id,
doc.Tenant,
new AdvisoryObservationSource(doc.Source.Vendor, doc.Source.Stream, doc.Source.Api),
new AdvisoryObservationUpstream(
doc.Upstream.UpstreamId,
doc.Upstream.DocumentVersion,
new DateTimeOffset(doc.Upstream.FetchedAt, TimeSpan.Zero),
new DateTimeOffset(doc.Upstream.ReceivedAt, TimeSpan.Zero),
doc.Upstream.ContentHash,
new AdvisoryObservationSignature(
doc.Upstream.Signature.Present,
doc.Upstream.Signature.Format,
doc.Upstream.Signature.KeyId,
doc.Upstream.Signature.Signature),
doc.Upstream.Metadata.ToImmutableDictionary()),
new AdvisoryObservationContent(
doc.Content.Format,
doc.Content.SpecVersion,
rawJson,
doc.Content.Metadata.ToImmutableDictionary()),
linkset,
rawLinkset,
new DateTimeOffset(doc.CreatedAt, TimeSpan.Zero),
doc.Attributes.ToImmutableDictionary());
}
}
// Holder to store conflict data since JsonDocument can be disposed
private sealed record ConflictHolder(
string VulnerabilityKey,
Guid? ConflictId,
DateTimeOffset AsOf,
IReadOnlyCollection<Guid> StatementIds,
string CanonicalJson);
private sealed class StubAdvisoryEventLog : IAdvisoryEventLog
{
private readonly ConcurrentDictionary<string, List<AdvisoryStatementInput>> _statements = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, List<ConflictHolder>> _conflicts = new(StringComparer.OrdinalIgnoreCase);
public async ValueTask AppendAsync(AdvisoryEventAppendRequest request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var client = new InMemoryClient("inmemory://localhost/fake");
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
var collection = database.GetCollection<DocumentObject>(StorageDefaults.Collections.AdvisoryStatements);
foreach (var statement in request.Statements)
{
var list = _statements.GetOrAdd(statement.VulnerabilityKey, _ => new List<AdvisoryStatementInput>());
lock (list)
{
list.Add(statement);
}
// Also store in in-memory database for tests that read from it
var statementId = statement.StatementId ?? Guid.NewGuid();
var doc = new DocumentObject
{
["_id"] = statementId.ToString(),
["vulnerabilityKey"] = statement.VulnerabilityKey,
["advisoryKey"] = statement.AdvisoryKey ?? statement.Advisory.AdvisoryKey,
["asOf"] = statement.AsOf.ToString("o"),
["recordedAt"] = DateTimeOffset.UtcNow.ToString("o")
};
await collection.InsertOneAsync(doc, null, cancellationToken);
}
// Also store conflicts (if provided) - serialize JSON immediately to avoid disposed object access
if (request.Conflicts is not null)
{
foreach (var conflict in request.Conflicts)
{
var holder = new ConflictHolder(
conflict.VulnerabilityKey,
conflict.ConflictId,
conflict.AsOf,
conflict.StatementIds.ToArray(),
conflict.Details.RootElement.GetRawText());
var list = _conflicts.GetOrAdd(conflict.VulnerabilityKey, _ => new List<ConflictHolder>());
lock (list)
{
list.Add(holder);
}
}
}
}
public ValueTask<AdvisoryReplay> ReplayAsync(string vulnerabilityKey, DateTimeOffset? asOf, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var statementsSnapshots = ImmutableArray<AdvisoryStatementSnapshot>.Empty;
var conflictSnapshots = ImmutableArray<AdvisoryConflictSnapshot>.Empty;
if (_statements.TryGetValue(vulnerabilityKey, out var statements) && statements.Count > 0)
{
statementsSnapshots = statements
.Select(s =>
{
// Generate a non-empty hash from the advisory's JSON representation
var hashBytes = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(s.Advisory)));
return new AdvisoryStatementSnapshot(
s.StatementId ?? Guid.NewGuid(),
s.VulnerabilityKey,
s.AdvisoryKey ?? s.Advisory.AdvisoryKey,
s.Advisory,
hashBytes.ToImmutableArray(),
s.AsOf,
DateTimeOffset.UtcNow,
System.Collections.Immutable.ImmutableArray<Guid>.Empty);
})
.ToImmutableArray();
}
if (_conflicts.TryGetValue(vulnerabilityKey, out var conflicts) && conflicts.Count > 0)
{
conflictSnapshots = conflicts
.Select(c =>
{
// Compute hash from the stored canonical JSON
var hashBytes = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(c.CanonicalJson));
return new AdvisoryConflictSnapshot(
c.ConflictId ?? Guid.NewGuid(),
c.VulnerabilityKey,
c.StatementIds.ToImmutableArray(),
hashBytes.ToImmutableArray(),
c.AsOf,
DateTimeOffset.UtcNow,
c.CanonicalJson);
})
.ToImmutableArray();
}
return ValueTask.FromResult(new AdvisoryReplay(
vulnerabilityKey,
asOf,
statementsSnapshots,
conflictSnapshots));
}
public async ValueTask AttachStatementProvenanceAsync(Guid statementId, DsseProvenance provenance, TrustInfo trust, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var client = new InMemoryClient("inmemory://localhost/fake");
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
var collection = database.GetCollection<DocumentObject>(StorageDefaults.Collections.AdvisoryStatements);
// Get all documents and find the one with matching ID
var cursor = await collection.FindAsync(FilterDefinition<DocumentObject>.Empty, null, cancellationToken);
var allDocs = new List<DocumentObject>();
while (await cursor.MoveNextAsync(cancellationToken))
{
allDocs.AddRange(cursor.Current);
}
var targetId = statementId.ToString();
var existingDoc = allDocs.FirstOrDefault(d => d.TryGetValue("_id", out var idValue) && idValue.AsString == targetId);
if (existingDoc is null)
{
throw new InvalidOperationException($"Statement {statementId} not found");
}
// Create updated document with provenance and trust
var updatedDoc = new DocumentObject();
foreach (var kvp in existingDoc)
{
updatedDoc[kvp.Key] = kvp.Value;
}
updatedDoc["provenance"] = new DocumentObject
{
["dsse"] = new DocumentObject
{
["envelopeDigest"] = provenance.EnvelopeDigest,
["payloadType"] = provenance.PayloadType
}
};
updatedDoc["trust"] = new DocumentObject
{
["verified"] = trust.Verified,
["verifier"] = trust.Verifier ?? string.Empty
};
// ReplaceOne clears the collection, so we need to add back all other docs too
var filter = Builders<DocumentObject>.Filter.Eq("_id", targetId);
await collection.ReplaceOneAsync(filter, updatedDoc, null, cancellationToken);
// Re-add other documents that were cleared
var otherDocs = allDocs.Where(d => !(d.TryGetValue("_id", out var idValue) && idValue.AsString == targetId));
foreach (var doc in otherDocs)
{
await collection.InsertOneAsync(doc, null, cancellationToken);
}
}
}
private sealed class StubAdvisoryStore : IAdvisoryStore
{
private readonly ConcurrentDictionary<string, Advisory> _advisories = new(StringComparer.OrdinalIgnoreCase);
public Task UpsertAsync(Advisory advisory, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
_advisories[advisory.AdvisoryKey] = advisory;
return Task.CompletedTask;
}
public Task<Advisory?> FindAsync(string advisoryKey, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
_advisories.TryGetValue(advisoryKey, out var advisory);
return Task.FromResult<Advisory?>(advisory);
}
public Task<IReadOnlyList<Advisory>> GetRecentAsync(int limit, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var result = _advisories.Values
.OrderByDescending(a => a.Modified ?? a.Published ?? DateTimeOffset.MinValue)
.Take(limit)
.ToArray();
return Task.FromResult<IReadOnlyList<Advisory>>(result);
}
public async IAsyncEnumerable<Advisory> StreamAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (var advisory in _advisories.Values.OrderBy(a => a.AdvisoryKey, StringComparer.OrdinalIgnoreCase))
{
cancellationToken.ThrowIfCancellationRequested();
yield return advisory;
await Task.Yield();
}
}
}
/// <summary>
/// In-memory implementation of IAdvisoryLinksetLookup that queries the shared in-memory database.
/// Performs filtering by tenant, advisoryIds, and sources, letting the real AdvisoryLinksetQueryService
/// handle sorting, pagination, and cursor encoding.
/// </summary>
private sealed class InMemoryAdvisoryLinksetLookup : IAdvisoryLinksetLookup
{
public async Task<IReadOnlyList<AdvisoryLinkset>> FindByTenantAsync(
string tenantId,
IEnumerable<string>? advisoryIds,
IEnumerable<string>? sources,
AdvisoryLinksetCursor? cursor,
int limit,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var client = new InMemoryClient("inmemory://localhost/fake");
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
var collection = database.GetCollection<AdvisoryLinksetDocument>(StorageDefaults.Collections.AdvisoryLinksets);
var dbCursor = await collection.FindAsync(FilterDefinition<AdvisoryLinksetDocument>.Empty, null, cancellationToken);
var docs = new List<AdvisoryLinksetDocument>();
while (await dbCursor.MoveNextAsync(cancellationToken))
{
docs.AddRange(dbCursor.Current);
}
var advisoryIdsList = advisoryIds?.ToList();
var sourcesList = sources?.ToList();
// Filter by tenant, advisoryIds, and sources
var filtered = docs
.Where(d => string.Equals(d.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
.Where(d => advisoryIdsList == null || !advisoryIdsList.Any() ||
advisoryIdsList.Any(id => string.Equals(d.AdvisoryId, id, StringComparison.OrdinalIgnoreCase)))
.Where(d => sourcesList == null || !sourcesList.Any() ||
sourcesList.Any(s => string.Equals(d.Source, s, StringComparison.OrdinalIgnoreCase)))
.Select(MapToLinkset)
.ToList();
// Apply cursor for pagination if provided
// Sort order is: CreatedAt DESC, AdvisoryId ASC
// Cursor points to last item of previous page, so we want items "after" it
if (cursor != null)
{
var cursorCreatedAt = cursor.CreatedAt;
var cursorAdvisoryId = cursor.AdvisoryId;
filtered = filtered
.Where(ls => IsBeyondLinksetCursor(ls, cursorCreatedAt, cursorAdvisoryId))
.ToList();
}
return filtered;
}
private static bool IsBeyondLinksetCursor(AdvisoryLinkset linkset, DateTimeOffset cursorCreatedAt, string cursorAdvisoryId)
{
// For DESC CreatedAt, ASC AdvisoryId sorting:
// Return true if this linkset should appear AFTER the cursor position
if (linkset.CreatedAt < cursorCreatedAt)
{
return true;
}
if (linkset.CreatedAt == cursorCreatedAt &&
string.Compare(linkset.AdvisoryId, cursorAdvisoryId, StringComparison.Ordinal) > 0)
{
return true;
}
return false;
}
private static AdvisoryLinkset MapToLinkset(AdvisoryLinksetDocument doc)
{
return new AdvisoryLinkset(
doc.TenantId,
doc.Source,
doc.AdvisoryId,
doc.Observations.ToImmutableArray(),
new AdvisoryLinksetNormalized(
doc.Normalized.Purls.ToList(),
null, // Cpes
doc.Normalized.Versions.ToList(),
null, // Ranges
null), // Severities
null, // Provenance
null, // Confidence
null, // Conflicts
new DateTimeOffset(doc.CreatedAt, TimeSpan.Zero),
null); // BuiltByJobId
}
}
private sealed class InMemoryAdvisoryLinksetStore : IAdvisoryLinksetStore
{
public async Task<IReadOnlyList<AdvisoryLinkset>> FindByTenantAsync(
string tenantId,
IEnumerable<string>? advisoryIds,
IEnumerable<string>? sources,
AdvisoryLinksetCursor? cursor,
int limit,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var client = new InMemoryClient("inmemory://localhost/fake");
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
var collection = database.GetCollection<AdvisoryLinksetDocument>(StorageDefaults.Collections.AdvisoryLinksets);
var dbCursor = await collection.FindAsync(FilterDefinition<AdvisoryLinksetDocument>.Empty, null, cancellationToken);
var docs = new List<AdvisoryLinksetDocument>();
while (await dbCursor.MoveNextAsync(cancellationToken))
{
docs.AddRange(dbCursor.Current);
}
var advisoryIdsList = advisoryIds?.ToList();
var sourcesList = sources?.ToList();
var filtered = docs
.Where(d => string.Equals(d.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
.Where(d => advisoryIdsList == null || !advisoryIdsList.Any() ||
advisoryIdsList.Any(id => string.Equals(d.AdvisoryId, id, StringComparison.OrdinalIgnoreCase)))
.Where(d => sourcesList == null || !sourcesList.Any() ||
sourcesList.Any(s => string.Equals(d.Source, s, StringComparison.OrdinalIgnoreCase)))
.OrderByDescending(d => d.CreatedAt)
.Take(limit)
.Select(MapToLinkset)
.ToList();
return filtered;
}
public Task UpsertAsync(AdvisoryLinkset linkset, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.CompletedTask;
}
private static AdvisoryLinkset MapToLinkset(AdvisoryLinksetDocument doc)
{
return new AdvisoryLinkset(
doc.TenantId,
doc.Source,
doc.AdvisoryId,
doc.Observations.ToImmutableArray(),
new AdvisoryLinksetNormalized(
doc.Normalized.Purls.ToList(),
null, // Cpes
doc.Normalized.Versions.ToList(),
null, // Ranges
null), // Severities
null, // Provenance
null, // Confidence
null, // Conflicts
new DateTimeOffset(doc.CreatedAt, TimeSpan.Zero),
null); // BuiltByJobId
}
}
}
[Fact]
public async Task StatementProvenanceEndpointAttachesMetadata()
{
var tenant = "tenant-provenance";
var vulnerabilityKey = "CVE-2025-9200";
var statementId = Guid.NewGuid();
var recordedAt = DateTimeOffset.Parse("2025-03-01T00:00:00Z", CultureInfo.InvariantCulture);
using (var scope = _factory.Services.CreateScope())
{
var eventLog = scope.ServiceProvider.GetRequiredService<IAdvisoryEventLog>();
var advisory = new Advisory(
advisoryKey: vulnerabilityKey,
title: "Provenance seed",
summary: "Ready for DSSE metadata",
language: "en",
published: recordedAt.AddDays(-1),
modified: recordedAt,
severity: "high",
exploitKnown: false,
aliases: new[] { vulnerabilityKey },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: Array.Empty<AdvisoryProvenance>());
var statementInput = new AdvisoryStatementInput(
vulnerabilityKey,
advisory,
recordedAt,
InputDocumentIds: Array.Empty<Guid>(),
StatementId: statementId,
AdvisoryKey: advisory.AdvisoryKey);
await eventLog.AppendAsync(new AdvisoryEventAppendRequest(new[] { statementInput }), CancellationToken.None);
}
try
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Stella-Tenant", tenant);
var response = await client.PostAsync(
$"/events/statements/{statementId}/provenance?tenant={tenant}",
new StringContent(BuildProvenancePayload(), Encoding.UTF8, "application/json"));
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
using var validationScope = _factory.Services.CreateScope();
var database = validationScope.ServiceProvider.GetRequiredService<IStorageDatabase>();
var statements = database.GetCollection<DocumentObject>(StorageDefaults.Collections.AdvisoryStatements);
var stored = await statements
.Find(Builders<DocumentObject>.Filter.Eq("_id", statementId.ToString()))
.FirstOrDefaultAsync();
Assert.NotNull(stored);
var dsse = stored!["provenance"].AsDocumentObject["dsse"].AsDocumentObject;
Assert.Equal("sha256:feedface", dsse["envelopeDigest"].AsString);
var trustDoc = stored["trust"].AsDocumentObject;
Assert.True(trustDoc["verified"].AsBoolean);
Assert.Equal("Authority@stella", trustDoc["verifier"].AsString);
}
finally
{
using var cleanupScope = _factory.Services.CreateScope();
var database = cleanupScope.ServiceProvider.GetRequiredService<IStorageDatabase>();
var statements = database.GetCollection<DocumentObject>(StorageDefaults.Collections.AdvisoryStatements);
await statements.DeleteOneAsync(Builders<DocumentObject>.Filter.Eq("_id", statementId.ToString()));
}
}
private static string BuildProvenancePayload()
{
var payload = new
{
dsse = new
{
envelopeDigest = "sha256:feedface",
payloadType = "application/vnd.in-toto+json",
key = new
{
keyId = "cosign:SHA256-PKIX:fixture",
issuer = "Authority@stella",
algo = "Ed25519"
},
rekor = new
{
logIndex = 1337,
uuid = "11111111-2222-3333-4444-555555555555",
integratedTime = 1731081600
}
},
trust = new
{
verified = true,
verifier = "Authority@stella",
witnesses = 1,
policyScore = 1.0
}
};
return JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web));
}
private sealed class TempDirectory : IDisposable
{
public string Path { get; }
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "concelier-mirror-" + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture));
Directory.CreateDirectory(Path);
}
public void Dispose()
{
try
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
catch
{
// best effort cleanup
}
}
}
private sealed record HealthPayload(string Status, DateTimeOffset StartedAt, double UptimeSeconds, StoragePayload Storage, TelemetryPayload Telemetry);
private sealed record StoragePayload(string Backend, bool Ready, DateTimeOffset? CheckedAt, double? LatencyMs, string? Error);
private sealed record TelemetryPayload(bool Enabled, bool Tracing, bool Metrics, bool Logging);
private sealed record ReadyPayload(string Status, DateTimeOffset StartedAt, double UptimeSeconds, StoragePayload Storage);
private sealed record JobDefinitionPayload(string Kind, bool Enabled, string? CronExpression, TimeSpan Timeout, TimeSpan LeaseDuration, JobRunPayload? LastRun);
private sealed record JobRunPayload(Guid RunId, string Kind, string Status, string Trigger, DateTimeOffset CreatedAt, DateTimeOffset? StartedAt, DateTimeOffset? CompletedAt, string? Error, TimeSpan? Duration, Dictionary<string, object?> Parameters);
private sealed record ProblemDocument(string? Type, string? Title, int? Status, string? Detail, string? Instance);
private async Task SeedCanonicalAdvisoriesAsync(params Advisory[] advisories)
{
using var scope = _factory.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<IStorageDatabase>();
await DropCollectionIfExistsAsync(database, StorageDefaults.Collections.Advisory);
await DropCollectionIfExistsAsync(database, StorageDefaults.Collections.Alias);
if (advisories.Length == 0)
{
return;
}
var store = scope.ServiceProvider.GetRequiredService<IAdvisoryStore>();
foreach (var advisory in advisories)
{
await store.UpsertAsync(advisory, CancellationToken.None);
}
}
private static async Task DropCollectionIfExistsAsync(IStorageDatabase database, string collectionName)
{
try
{
await database.DropCollectionAsync(collectionName);
}
catch (StorageCommandException ex) when (ex.CodeName == "NamespaceNotFound" || ex.Message.Contains("ns not found", StringComparison.OrdinalIgnoreCase))
{
}
}
private static Advisory CreateStructuredAdvisory(
string advisoryKey,
string alias,
string observationId,
DateTimeOffset recordedAt)
{
const string WorkaroundTitle = "Vendor guidance";
const string WorkaroundSummary = "Apply configuration change immediately.";
const string WorkaroundUrl = "https://vendor.example/workaround";
var reference = new AdvisoryReference(
WorkaroundUrl,
kind: "workaround",
sourceTag: WorkaroundTitle,
summary: WorkaroundSummary,
new AdvisoryProvenance(
"nvd",
"workaround",
observationId,
recordedAt,
new[] { "/references/0" }));
var affectedRange = new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: "1.0.0",
fixedVersion: "1.1.0",
lastAffectedVersion: null,
rangeExpression: ">=1.0.0,<1.1.0",
new AdvisoryProvenance(
"nvd",
"affected",
observationId,
recordedAt,
new[] { "/affectedPackages/0/versionRanges/0" }));
var affectedPackage = new AffectedPackage(
type: AffectedPackageTypes.SemVer,
identifier: "pkg:npm/demo",
versionRanges: new[] { affectedRange },
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[]
{
new AdvisoryProvenance(
"nvd",
"affected",
observationId,
recordedAt,
new[] { "/affectedPackages/0" })
},
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var cvss = new CvssMetric(
"3.1",
"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
9.8,
"critical",
new AdvisoryProvenance(
"nvd",
"cvss",
observationId,
recordedAt,
new[] { "/cvssMetrics/0" }));
var advisory = new Advisory(
advisoryKey,
title: "Fixture advisory",
summary: "Structured payload fixture",
language: "en",
published: recordedAt,
modified: recordedAt,
severity: "critical",
exploitKnown: false,
aliases: string.IsNullOrWhiteSpace(alias) ? new[] { advisoryKey } : new[] { advisoryKey, alias },
references: new[] { reference },
affectedPackages: new[] { affectedPackage },
cvssMetrics: new[] { cvss },
provenance: new[]
{
new AdvisoryProvenance("nvd", "advisory", observationId, recordedAt)
});
return advisory;
}
private async Task SeedAdvisoryRawDocumentsAsync(params DocumentObject[] documents)
{
var client = new InMemoryClient(_runner.ConnectionString);
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
var collection = database.GetCollection<DocumentObject>(StorageDefaults.Collections.AdvisoryRaw);
await collection.DeleteManyAsync(FilterDefinition<DocumentObject>.Empty);
if (documents.Length > 0)
{
await collection.InsertManyAsync(documents);
}
}
private static DocumentObject CreateAdvisoryRawDocument(
string tenant,
string vendor,
string upstreamId,
string contentHash,
DocumentObject? raw = null,
string? supersedes = null)
{
var now = DateTime.UtcNow;
return new DocumentObject
{
{ "_id", BuildRawDocumentId(vendor, upstreamId, contentHash) },
{ "tenant", tenant },
{
"source",
new DocumentObject
{
{ "vendor", vendor },
{ "connector", "test-connector" },
{ "version", "1.0.0" }
}
},
{
"upstream",
new DocumentObject
{
{ "upstream_id", upstreamId },
{ "document_version", "1" },
{ "retrieved_at", now },
{ "content_hash", contentHash },
{ "signature", new DocumentObject { { "present", false } } },
{ "provenance", new DocumentObject { { "api", "https://example.test" } } }
}
},
{
"content",
new DocumentObject
{
{ "format", "osv" },
{ "raw", raw ?? new DocumentObject("id", upstreamId) }
}
},
{
"identifiers",
new DocumentObject
{
{ "aliases", new DocumentArray(new[] { upstreamId }) },
{ "primary", upstreamId }
}
},
{
"linkset",
new DocumentObject
{
{ "aliases", new DocumentArray() },
{ "purls", new DocumentArray() },
{ "cpes", new DocumentArray() },
{ "references", new DocumentArray() },
{ "reconciled_from", new DocumentArray() },
{ "notes", new DocumentObject() }
}
},
{ "advisory_key", upstreamId.ToUpperInvariant() },
{
"links",
new DocumentArray
{
new DocumentObject
{
{ "scheme", "PRIMARY" },
{ "value", upstreamId.ToUpperInvariant() }
}
}
},
{ "supersedes", supersedes is null ? DocumentNull.Value : supersedes },
{ "ingested_at", now },
{ "created_at", now }
};
}
private static string BuildRawDocumentId(string vendor, string upstreamId, string contentHash)
{
static string Sanitize(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "unknown";
}
var buffer = new char[value.Length];
var index = 0;
foreach (var ch in value.Trim().ToLowerInvariant())
{
buffer[index++] = char.IsLetterOrDigit(ch) ? ch : '-';
}
var sanitized = new string(buffer, 0, index).Trim('-');
return string.IsNullOrEmpty(sanitized) ? "unknown" : sanitized;
}
var vendorSegment = Sanitize(vendor);
var upstreamSegment = Sanitize(upstreamId);
var hashSegment = Sanitize(contentHash.Replace(":", "-"));
return $"advisory_raw:{vendorSegment}:{upstreamSegment}:{hashSegment}";
}
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 void WarmupFactory(WebApplicationFactory<Program> factory)
{
using var client = factory.CreateClient();
}
private static AdvisoryIngestRequest BuildAdvisoryIngestRequest(
string? contentHash,
string upstreamId,
bool enforceContentHash = true,
IReadOnlyList<string>? purls = null,
IReadOnlyList<string>? notes = null)
{
var raw = CreateJsonElement($@"{{""id"":""{upstreamId}"",""modified"":""{DefaultIngestTimestamp:O}""}}");
var normalizedContentHash = NormalizeContentHash(contentHash, raw, enforceContentHash);
var resolvedPurls = purls ?? new[] { "pkg:npm/demo@1.0.0" };
var resolvedNotes = notes ?? Array.Empty<string>();
var references = new[]
{
new AdvisoryLinksetReferenceRequest("advisory", $"https://example.test/advisories/{upstreamId}", null)
};
return new AdvisoryIngestRequest(
new AdvisorySourceRequest("osv", "osv-connector", "1.0.0", "feed"),
new AdvisoryUpstreamRequest(
upstreamId,
"2025-01-01T00:00:00Z",
DateTimeOffset.UtcNow,
normalizedContentHash,
new AdvisorySignatureRequest(false, null, null, null, null, null),
new Dictionary<string, string> { ["http.method"] = "GET" }),
new AdvisoryContentRequest("osv", "1.3.0", raw, null),
new AdvisoryIdentifiersRequest(
upstreamId,
new[] { upstreamId, $"{upstreamId}-ALIAS" }),
new AdvisoryLinksetRequest(
new[] { upstreamId }, // Aliases
Array.Empty<string>(), // Scopes
Array.Empty<AdvisoryLinksetRelationshipRequest>(), // Relationships
resolvedPurls, // PackageUrls (purls)
Array.Empty<string>(), // Cpes
references, // References
resolvedNotes, // ReconciledFrom
new Dictionary<string, string> { ["note"] = "ingest-test" })); // Notes
}
private static JsonElement CreateJsonElement(string json)
{
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
private static async Task<IReadOnlyList<MetricMeasurement>> CaptureMetricsAsync(string meterName, string instrumentName, Func<Task> action)
{
var map = await CaptureMetricsAsync(meterName, new[] { instrumentName }, action).ConfigureAwait(false);
return map.TryGetValue(instrumentName, out var measurements)
? measurements
: Array.Empty<MetricMeasurement>();
}
private static async Task<Dictionary<string, IReadOnlyList<MetricMeasurement>>> CaptureMetricsAsync(
string meterName,
IReadOnlyCollection<string> instrumentNames,
Func<Task> action)
{
var measurementMap = instrumentNames.ToDictionary(
name => name,
_ => new List<MetricMeasurement>(),
StringComparer.Ordinal);
var instrumentSet = new HashSet<string>(instrumentNames, StringComparer.Ordinal);
var listener = new MeterListener();
listener.InstrumentPublished += (instrument, currentListener) =>
{
if (string.Equals(instrument.Meter.Name, meterName, StringComparison.Ordinal) &&
instrumentSet.Contains(instrument.Name))
{
currentListener.EnableMeasurementEvents(instrument);
}
};
void RecordMeasurement(Instrument instrument, double measurement, ReadOnlySpan<KeyValuePair<string, object?>> tags)
{
if (!measurementMap.TryGetValue(instrument.Name, out var list))
{
return;
}
var tagDictionary = new Dictionary<string, object?>(StringComparer.Ordinal);
foreach (var tag in tags)
{
tagDictionary[tag.Key] = tag.Value;
}
list.Add(new MetricMeasurement(instrument.Name, measurement, tagDictionary));
}
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state)
=> RecordMeasurement(instrument, measurement, tags));
listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state)
=> RecordMeasurement(instrument, measurement, tags));
listener.Start();
try
{
await action().ConfigureAwait(false);
}
finally
{
listener.Dispose();
}
return measurementMap.ToDictionary(
kvp => kvp.Key,
kvp => (IReadOnlyList<MetricMeasurement>)kvp.Value);
}
private static string? GetTagValue(MetricMeasurement measurement, string tag)
{
if (measurement.Tags.TryGetValue(tag, out var value))
{
return value?.ToString();
}
return null;
}
private static string CreateTestToken(string tenant, params string[] scopes)
{
var normalizedTenant = string.IsNullOrWhiteSpace(tenant) ? "default" : tenant.Trim().ToLowerInvariant();
var scopeSet = scopes is { Length: > 0 }
? scopes
.Select(StellaOpsScopes.Normalize)
.Where(static scope => !string.IsNullOrEmpty(scope))
.Select(static scope => scope!)
.Distinct(StringComparer.Ordinal)
.ToArray()
: Array.Empty<string>();
var claims = new List<Claim>
{
new Claim(StellaOpsClaimTypes.Subject, "test-user"),
new Claim(StellaOpsClaimTypes.Tenant, normalizedTenant),
new Claim(StellaOpsClaimTypes.Scope, string.Join(' ', scopeSet))
};
foreach (var scope in scopeSet)
{
claims.Add(new Claim(StellaOpsClaimTypes.ScopeItem, scope));
}
var credentials = new SigningCredentials(TestSigningKey, SecurityAlgorithms.HmacSha256);
var now = DateTime.UtcNow;
var token = new JwtSecurityToken(
issuer: TestAuthorityIssuer,
audience: TestAuthorityAudience,
claims: claims,
notBefore: now.AddMinutes(-5),
expires: now.AddMinutes(30),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private sealed record MetricMeasurement(string Instrument, double Value, IReadOnlyDictionary<string, object?> Tags);
private sealed class DemoJob : IJob
{
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken) => Task.CompletedTask;
}
private sealed class StubJobCoordinator : IJobCoordinator
{
public JobTriggerResult NextResult { get; set; } = JobTriggerResult.NotFound("not set");
public IReadOnlyList<JobDefinition> Definitions { get; set; } = Array.Empty<JobDefinition>();
public IReadOnlyList<JobRunSnapshot> RecentRuns { get; set; } = Array.Empty<JobRunSnapshot>();
public IReadOnlyList<JobRunSnapshot> ActiveRuns { get; set; } = Array.Empty<JobRunSnapshot>();
public Dictionary<Guid, JobRunSnapshot> Runs { get; } = new();
public Dictionary<string, JobRunSnapshot?> LastRuns { get; } = new(StringComparer.Ordinal);
public Task<JobTriggerResult> TriggerAsync(string kind, IReadOnlyDictionary<string, object?>? parameters, string trigger, CancellationToken cancellationToken)
=> Task.FromResult(NextResult);
public Task<IReadOnlyList<JobDefinition>> GetDefinitionsAsync(CancellationToken cancellationToken)
=> Task.FromResult(Definitions);
public Task<IReadOnlyList<JobRunSnapshot>> GetRecentRunsAsync(string? kind, int limit, CancellationToken cancellationToken)
{
IEnumerable<JobRunSnapshot> query = RecentRuns;
if (!string.IsNullOrWhiteSpace(kind))
{
query = query.Where(run => string.Equals(run.Kind, kind, StringComparison.Ordinal));
}
return Task.FromResult<IReadOnlyList<JobRunSnapshot>>(query.Take(limit).ToArray());
}
public Task<IReadOnlyList<JobRunSnapshot>> GetActiveRunsAsync(CancellationToken cancellationToken)
=> Task.FromResult(ActiveRuns);
public Task<JobRunSnapshot?> GetRunAsync(Guid runId, CancellationToken cancellationToken)
=> Task.FromResult(Runs.TryGetValue(runId, out var run) ? run : null);
public Task<JobRunSnapshot?> GetLastRunAsync(string kind, CancellationToken cancellationToken)
=> Task.FromResult(LastRuns.TryGetValue(kind, out var run) ? run : null);
public Task<IReadOnlyDictionary<string, JobRunSnapshot>> GetLastRunsAsync(IEnumerable<string> kinds, CancellationToken cancellationToken)
{
var map = new Dictionary<string, JobRunSnapshot>(StringComparer.Ordinal);
foreach (var kind in kinds)
{
if (kind is null)
{
continue;
}
if (LastRuns.TryGetValue(kind, out var run) && run is not null)
{
map[kind] = run;
}
}
return Task.FromResult<IReadOnlyDictionary<string, JobRunSnapshot>>(map);
}
}
}