Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
feat(telemetry): Record chunk latency, result count, and source count in AdvisoryAiTelemetry fix(endpoint): Include telemetry source count in advisory chunks endpoint response test(metrics): Enhance WebServiceEndpointsTests to validate new metrics for chunk latency, results, and sources refactor(tests): Update test utilities for Deno language analyzer tests chore(tests): Add performance tests for AdvisoryGuardrail with scenarios and blocked phrases docs: Archive Sprint 137 design document for scanner and surface enhancements
2333 lines
103 KiB
C#
2333 lines
103 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
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.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using Mongo2Go;
|
|
using MongoDB.Bson;
|
|
using MongoDB.Bson.IO;
|
|
using MongoDB.Driver;
|
|
using StellaOps.Concelier.Core.Events;
|
|
using StellaOps.Concelier.Core.Jobs;
|
|
using StellaOps.Concelier.Models;
|
|
using StellaOps.Concelier.Merge.Services;
|
|
using StellaOps.Concelier.Storage.Mongo;
|
|
using StellaOps.Concelier.Storage.Mongo.Observations;
|
|
using StellaOps.Concelier.Core.Raw;
|
|
using StellaOps.Concelier.WebService.Jobs;
|
|
using StellaOps.Concelier.WebService.Options;
|
|
using StellaOps.Concelier.WebService.Contracts;
|
|
using Xunit.Sdk;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Auth.Client;
|
|
using Xunit;
|
|
using Xunit.Abstractions;
|
|
using Microsoft.IdentityModel.Protocols;
|
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
|
using StellaOps.Concelier.WebService.Diagnostics;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using StellaOps.Cryptography;
|
|
|
|
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 MongoDbRunner _runner = null!;
|
|
private ConcelierApplicationFactory _factory = null!;
|
|
|
|
public WebServiceEndpointsTests(ITestOutputHelper output)
|
|
{
|
|
_output = output;
|
|
}
|
|
|
|
public Task InitializeAsync()
|
|
{
|
|
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
|
_factory = new ConcelierApplicationFactory(_runner.ConnectionString);
|
|
WarmupFactory(_factory);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task DisposeAsync()
|
|
{
|
|
_factory.Dispose();
|
|
_runner.Dispose();
|
|
return Task.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("mongo", healthPayload.Storage.Driver);
|
|
|
|
var readyPayload = await readyResponse.Content.ReadFromJsonAsync<ReadyPayload>();
|
|
Assert.NotNull(readyPayload);
|
|
Assert.Equal("ready", readyPayload!.Status);
|
|
Assert.Equal("ready", readyPayload.Mongo.Status);
|
|
}
|
|
|
|
[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());
|
|
|
|
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 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 = BsonDocument.Parse(
|
|
"""
|
|
{
|
|
"summary": {
|
|
"intro": "This is a deterministic summary paragraph describing CVE-2025-0001 with remediation context for Advisory AI consumers."
|
|
},
|
|
"details": [
|
|
"Long-form remediation guidance that exceeds the minimum length threshold and mentions affected packages.",
|
|
{
|
|
"body": "Nested context that Advisory AI can cite when rendering downstream explanations."
|
|
}
|
|
]
|
|
}
|
|
""");
|
|
var olderRaw = BsonDocument.Parse(
|
|
"""
|
|
{
|
|
"summary": {
|
|
"intro": "Older paragraph that should be visible when no section filter applies."
|
|
}
|
|
}
|
|
""");
|
|
|
|
var newerCreatedAt = new DateTime(2025, 1, 7, 0, 0, 0, DateTimeKind.Utc);
|
|
var olderCreatedAt = new DateTime(2025, 1, 5, 0, 0, 0, DateTimeKind.Utc);
|
|
var newerHash = ComputeContentHash(newestRaw);
|
|
var olderHash = ComputeContentHash(olderRaw);
|
|
|
|
var documents = new[]
|
|
{
|
|
CreateChunkObservationDocument(
|
|
id: "tenant-a:chunk:newest",
|
|
tenant: "tenant-a",
|
|
createdAt: newerCreatedAt,
|
|
alias: "cve-2025-0001",
|
|
rawDocument: newestRaw),
|
|
CreateChunkObservationDocument(
|
|
id: "tenant-a:chunk:older",
|
|
tenant: "tenant-a",
|
|
createdAt: olderCreatedAt,
|
|
alias: "cve-2025-0001",
|
|
rawDocument: olderRaw)
|
|
};
|
|
|
|
await SeedObservationDocumentsAsync(documents);
|
|
await SeedAdvisoryRawDocumentsAsync(
|
|
CreateAdvisoryRawDocument("tenant-a", "nvd", "tenant-a:chunk:newest", newerHash, newestRaw.DeepClone().AsBsonDocument),
|
|
CreateAdvisoryRawDocument("tenant-a", "nvd", "tenant-a:chunk:older", olderHash, olderRaw.DeepClone().AsBsonDocument));
|
|
|
|
using var client = _factory.CreateClient();
|
|
var response = await client.GetAsync("/advisories/cve-2025-0001/chunks?tenant=tenant-a§ion=summary&format=csaf");
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var payload = await response.Content.ReadAsStringAsync();
|
|
using var document = JsonDocument.Parse(payload);
|
|
var root = document.RootElement;
|
|
|
|
Assert.Equal("cve-2025-0001", root.GetProperty("advisoryKey").GetString());
|
|
Assert.Equal(1, root.GetProperty("total").GetInt32());
|
|
Assert.False(root.GetProperty("truncated").GetBoolean());
|
|
|
|
var chunk = Assert.Single(root.GetProperty("chunks").EnumerateArray());
|
|
Assert.Equal("summary", chunk.GetProperty("section").GetString());
|
|
Assert.Equal("summary.intro", chunk.GetProperty("paragraphId").GetString());
|
|
var text = chunk.GetProperty("text").GetString();
|
|
Assert.False(string.IsNullOrWhiteSpace(text));
|
|
Assert.Contains("deterministic summary paragraph", text, StringComparison.OrdinalIgnoreCase);
|
|
|
|
var metadata = chunk.GetProperty("metadata");
|
|
Assert.Equal("summary.intro", metadata.GetProperty("path").GetString());
|
|
Assert.Equal("csaf", metadata.GetProperty("format").GetString());
|
|
|
|
var sources = root.GetProperty("sources").EnumerateArray().ToArray();
|
|
Assert.Equal(2, sources.Length);
|
|
Assert.Equal("tenant-a:chunk:newest", sources[0].GetProperty("observationId").GetString());
|
|
Assert.Equal("tenant-a:chunk:older", sources[1].GetProperty("observationId").GetString());
|
|
Assert.All(
|
|
sources,
|
|
source => Assert.True(string.Equals("csaf", source.GetProperty("format").GetString(), StringComparison.OrdinalIgnoreCase)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdvisoryChunksEndpoint_ReturnsNotFoundWhenAdvisoryMissing()
|
|
{
|
|
await SeedObservationDocumentsAsync(BuildSampleObservationDocuments());
|
|
|
|
using var client = _factory.CreateClient();
|
|
var response = await client.GetAsync("/advisories/cve-2099-9999/chunks?tenant=tenant-a");
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
var payload = await response.Content.ReadAsStringAsync();
|
|
using var document = JsonDocument.Parse(payload);
|
|
var root = document.RootElement;
|
|
Assert.Equal("https://stellaops.org/problems/not-found", root.GetProperty("type").GetString());
|
|
Assert.Equal("Advisory not found", root.GetProperty("title").GetString());
|
|
Assert.Contains("cve-2099-9999", root.GetProperty("detail").GetString(), StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdvisoryIngestEndpoint_PersistsDocumentAndSupportsReadback()
|
|
{
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-ingest");
|
|
|
|
const string upstreamId = "GHSA-INGEST-0001";
|
|
var ingestRequest = BuildAdvisoryIngestRequest(
|
|
contentHash: 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 BsonDocument
|
|
{
|
|
{ "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 BsonDocument("id", "GHSA-2025-0001:1")),
|
|
CreateAdvisoryRawDocument("tenant-a", "vendor-y", "GHSA-2025-0001", "sha256:002", new BsonDocument("id", "GHSA-2025-0001:2")),
|
|
CreateAdvisoryRawDocument("tenant-b", "vendor-x", "GHSA-2025-0001", "sha256:003", new BsonDocument("id", "GHSA-2025-0001:3")));
|
|
|
|
using var client = _factory.CreateClient();
|
|
var response = await client.GetAsync("/vuln/evidence/advisories/ghsa-2025-0001?tenant=tenant-a");
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
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));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdvisoryEvidenceEndpoint_FiltersByVendor()
|
|
{
|
|
await SeedAdvisoryRawDocumentsAsync(
|
|
CreateAdvisoryRawDocument("tenant-a", "vendor-x", "GHSA-2025-0002", "sha256:101", new BsonDocument("id", "GHSA-2025-0002:1")),
|
|
CreateAdvisoryRawDocument("tenant-a", "vendor-y", "GHSA-2025-0002", "sha256:102", new BsonDocument("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());
|
|
|
|
using var client = _factory.CreateClient();
|
|
|
|
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();
|
|
|
|
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.Contains(segmentMeasurements!, measurement => GetTagValue(measurement, "truncated") == "false");
|
|
|
|
Assert.True(metrics.TryGetValue("advisory_ai_chunk_sources", out var sourceMeasurements));
|
|
Assert.Equal(2, sourceMeasurements!.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdvisoryChunksEndpoint_EmitsGuardrailMetrics()
|
|
{
|
|
var raw = BsonDocument.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 });
|
|
|
|
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)));
|
|
var token = CreateTestToken("tenant-auth", StellaOpsScopes.AdvisoryIngest);
|
|
_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}");
|
|
}
|
|
}
|
|
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();
|
|
var allowedToken = CreateTestToken("tenant-auth", StellaOpsScopes.AdvisoryIngest);
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", allowedToken);
|
|
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-auth");
|
|
|
|
var allowedResponse = await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:allow-1", "GHSA-ALLOW-001"));
|
|
Assert.Equal(HttpStatusCode.Created, allowedResponse.StatusCode);
|
|
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", CreateTestToken("tenant-blocked", StellaOpsScopes.AdvisoryIngest));
|
|
client.DefaultRequestHeaders.Remove("X-Stella-Tenant");
|
|
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-blocked");
|
|
|
|
var forbiddenResponse = await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:allow-2", "GHSA-ALLOW-002"));
|
|
Assert.Equal(HttpStatusCode.Forbidden, forbiddenResponse.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdvisoryIngestEndpoint_ReturnsGuardViolationWhenContentHashMissing()
|
|
{
|
|
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");
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var payload = await response.Content.ReadFromJsonAsync<ReplayResponse>();
|
|
Assert.NotNull(payload);
|
|
var conflict = Assert.Single(payload!.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 MongoClient(_runner.ConnectionString);
|
|
var database = client.GetDatabase(MongoStorageDefaults.DefaultDatabaseName);
|
|
var collection = database.GetCollection<AdvisoryObservationDocument>(MongoStorageDefaults.Collections.AdvisoryObservations);
|
|
|
|
try
|
|
{
|
|
await database.DropCollectionAsync(MongoStorageDefaults.Collections.AdvisoryObservations);
|
|
}
|
|
catch (MongoCommandException ex) when (ex.CodeName == "NamespaceNotFound" || ex.Message.Contains("ns not found", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// Collection does not exist yet; ignore.
|
|
}
|
|
|
|
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().AsBsonDocument))
|
|
.ToArray();
|
|
|
|
await SeedAdvisoryRawDocumentsAsync(rawDocuments);
|
|
}
|
|
|
|
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 = BsonDocument.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,
|
|
BsonDocument rawDocument)
|
|
{
|
|
var document = CreateObservationDocument(
|
|
id,
|
|
tenant,
|
|
createdAt,
|
|
aliases: new[] { alias });
|
|
var clone = rawDocument.DeepClone().AsBsonDocument;
|
|
document.Content.Raw = clone;
|
|
document.Upstream.ContentHash = ComputeContentHash(clone);
|
|
return document;
|
|
}
|
|
|
|
private static readonly DateTimeOffset DefaultIngestTimestamp = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
private static readonly ICryptoHash Hash = CryptoHashFactory.CreateDefault();
|
|
|
|
private static string ComputeContentHash(BsonDocument 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? _previousDsn;
|
|
private readonly string? _previousDriver;
|
|
private readonly string? _previousTimeout;
|
|
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)
|
|
{
|
|
_connectionString = connectionString;
|
|
_authorityConfigure = authorityConfigure;
|
|
_previousDsn = Environment.GetEnvironmentVariable("CONCELIER_STORAGE__DSN");
|
|
_previousDriver = Environment.GetEnvironmentVariable("CONCELIER_STORAGE__DRIVER");
|
|
_previousTimeout = Environment.GetEnvironmentVariable("CONCELIER_STORAGE__COMMANDTIMEOUTSECONDS");
|
|
_previousTelemetryEnabled = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLED");
|
|
_previousTelemetryLogging = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING");
|
|
_previousTelemetryTracing = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING");
|
|
_previousTelemetryMetrics = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS");
|
|
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__DSN", connectionString);
|
|
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__DRIVER", "mongo");
|
|
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__COMMANDTIMEOUTSECONDS", "30");
|
|
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLED", "false");
|
|
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING", "false");
|
|
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING", "false");
|
|
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS", "false");
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
|
{
|
|
builder.ConfigureAppConfiguration((context, configurationBuilder) =>
|
|
{
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
["Plugins:Directory"] = Path.Combine(context.HostingEnvironment.ContentRootPath, "StellaOps.Concelier.PluginBinaries"),
|
|
};
|
|
|
|
configurationBuilder.AddInMemoryCollection(settings!);
|
|
});
|
|
|
|
builder.ConfigureLogging(logging =>
|
|
{
|
|
logging.AddProvider(LoggerProvider);
|
|
});
|
|
|
|
builder.ConfigureServices(services =>
|
|
{
|
|
services.AddSingleton<StubJobCoordinator>();
|
|
services.AddSingleton<IJobCoordinator>(sp => sp.GetRequiredService<StubJobCoordinator>());
|
|
services.PostConfigure<ConcelierOptions>(options =>
|
|
{
|
|
options.Storage.Driver = "mongo";
|
|
options.Storage.Dsn = _connectionString;
|
|
options.Storage.CommandTimeoutSeconds = 30;
|
|
options.Plugins.Directory ??= Path.Combine(AppContext.BaseDirectory, "StellaOps.Concelier.PluginBinaries");
|
|
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);
|
|
});
|
|
});
|
|
|
|
builder.ConfigureTestServices(services =>
|
|
{
|
|
services.AddSingleton<IStartupFilter, RemoteIpStartupFilter>();
|
|
services.PostConfigure<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme, options =>
|
|
{
|
|
options.RequireHttpsMetadata = false;
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKey = TestSigningKey,
|
|
ValidateIssuer = false,
|
|
ValidateAudience = false,
|
|
ValidateLifetime = false,
|
|
NameClaimType = ClaimTypes.Name,
|
|
RoleClaimType = ClaimTypes.Role,
|
|
ClockSkew = TimeSpan.Zero
|
|
};
|
|
var issuer = string.IsNullOrWhiteSpace(options.Authority) ? TestAuthorityIssuer : options.Authority;
|
|
options.ConfigurationManager = new StaticConfigurationManager<OpenIdConnectConfiguration>(new OpenIdConnectConfiguration
|
|
{
|
|
Issuer = issuer
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
base.Dispose(disposing);
|
|
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__DSN", _previousDsn);
|
|
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__DRIVER", _previousDriver);
|
|
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__COMMANDTIMEOUTSECONDS", _previousTimeout);
|
|
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 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 Driver, bool Completed, DateTimeOffset? CompletedAt, double? DurationMs);
|
|
|
|
private sealed record TelemetryPayload(bool Enabled, bool Tracing, bool Metrics, bool Logging);
|
|
|
|
private sealed record ReadyPayload(string Status, DateTimeOffset StartedAt, double UptimeSeconds, ReadyMongoPayload Mongo);
|
|
|
|
private sealed record ReadyMongoPayload(string Status, double? LatencyMs, DateTimeOffset? CheckedAt, string? Error);
|
|
|
|
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 SeedAdvisoryRawDocumentsAsync(params BsonDocument[] documents)
|
|
{
|
|
var client = new MongoClient(_runner.ConnectionString);
|
|
var database = client.GetDatabase(MongoStorageDefaults.DefaultDatabaseName);
|
|
var collection = database.GetCollection<BsonDocument>(MongoStorageDefaults.Collections.AdvisoryRaw);
|
|
await collection.DeleteManyAsync(FilterDefinition<BsonDocument>.Empty);
|
|
if (documents.Length > 0)
|
|
{
|
|
await collection.InsertManyAsync(documents);
|
|
}
|
|
}
|
|
|
|
private static BsonDocument CreateAdvisoryRawDocument(
|
|
string tenant,
|
|
string vendor,
|
|
string upstreamId,
|
|
string contentHash,
|
|
BsonDocument? raw = null,
|
|
string? supersedes = null)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
return new BsonDocument
|
|
{
|
|
{ "_id", BuildRawDocumentId(vendor, upstreamId, contentHash) },
|
|
{ "tenant", tenant },
|
|
{
|
|
"source",
|
|
new BsonDocument
|
|
{
|
|
{ "vendor", vendor },
|
|
{ "connector", "test-connector" },
|
|
{ "version", "1.0.0" }
|
|
}
|
|
},
|
|
{
|
|
"upstream",
|
|
new BsonDocument
|
|
{
|
|
{ "upstream_id", upstreamId },
|
|
{ "document_version", "1" },
|
|
{ "retrieved_at", now },
|
|
{ "content_hash", contentHash },
|
|
{ "signature", new BsonDocument { { "present", false } } },
|
|
{ "provenance", new BsonDocument { { "api", "https://example.test" } } }
|
|
}
|
|
},
|
|
{
|
|
"content",
|
|
new BsonDocument
|
|
{
|
|
{ "format", "osv" },
|
|
{ "raw", raw ?? new BsonDocument("id", upstreamId) }
|
|
}
|
|
},
|
|
{
|
|
"identifiers",
|
|
new BsonDocument
|
|
{
|
|
{ "aliases", new BsonArray(new[] { upstreamId }) },
|
|
{ "primary", upstreamId }
|
|
}
|
|
},
|
|
{
|
|
"linkset",
|
|
new BsonDocument
|
|
{
|
|
{ "aliases", new BsonArray() },
|
|
{ "purls", new BsonArray() },
|
|
{ "cpes", new BsonArray() },
|
|
{ "references", new BsonArray() },
|
|
{ "reconciled_from", new BsonArray() },
|
|
{ "notes", new BsonDocument() }
|
|
}
|
|
},
|
|
{ "advisory_key", upstreamId.ToUpperInvariant() },
|
|
{
|
|
"links",
|
|
new BsonArray
|
|
{
|
|
new BsonDocument
|
|
{
|
|
{ "scheme", "PRIMARY" },
|
|
{ "value", upstreamId.ToUpperInvariant() }
|
|
}
|
|
}
|
|
},
|
|
{ "supersedes", supersedes is null ? BsonNull.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)
|
|
{
|
|
var raw = CreateJsonElement($@"{{""id"":""{upstreamId}"",""modified"":""{DefaultIngestTimestamp:O}""}}");
|
|
var normalizedContentHash = NormalizeContentHash(contentHash, raw, enforceContentHash);
|
|
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 },
|
|
new[] { "pkg:npm/demo@1.0.0" },
|
|
Array.Empty<string>(),
|
|
references,
|
|
Array.Empty<string>(),
|
|
new Dictionary<string, string> { ["note"] = "ingest-test" }));
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|