3428 lines
153 KiB
C#
3428 lines
153 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Immutable;
|
|
using System.Diagnostics;
|
|
using System.Diagnostics.Metrics;
|
|
using System.Globalization;
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Net.Http.Headers;
|
|
using System.Security.Claims;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.AspNetCore.TestHost;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.IdentityModel.Logging;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Concelier.InMemoryRunner;
|
|
using StellaOps.Concelier.Documents;
|
|
using StellaOps.Concelier.Documents.IO;
|
|
using StellaOps.Concelier.InMemoryDriver;
|
|
using StellaOps.Concelier.Core.Attestation;
|
|
using static StellaOps.Concelier.WebService.Program;
|
|
using StellaOps.Concelier.Core.Events;
|
|
using StellaOps.Concelier.Core.Jobs;
|
|
using StellaOps.Concelier.Models;
|
|
using StellaOps.Concelier.Merge.Services;
|
|
using StellaOps.Concelier.Storage;
|
|
using StellaOps.Concelier.Storage.Advisories;
|
|
using StellaOps.Concelier.Storage.Observations;
|
|
using StellaOps.Concelier.Storage.Linksets;
|
|
using StellaOps.Concelier.Core.Raw;
|
|
using StellaOps.Concelier.Core.Observations;
|
|
using StellaOps.Concelier.Core.Linksets;
|
|
using StellaOps.Concelier.Models.Observations;
|
|
using StellaOps.Concelier.Persistence.Postgres;
|
|
using StellaOps.Concelier.RawModels;
|
|
using StellaOps.Concelier.WebService.Jobs;
|
|
using StellaOps.Concelier.WebService.Options;
|
|
using StellaOps.Concelier.WebService.Contracts;
|
|
using StellaOps.Concelier.WebService;
|
|
using Xunit.Sdk;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Auth.Client;
|
|
using Xunit;
|
|
using Microsoft.IdentityModel.Protocols;
|
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
|
using StellaOps.Concelier.WebService.Diagnostics;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using StellaOps.Cryptography;
|
|
using DsseProvenance = StellaOps.Provenance.DsseProvenance;
|
|
using TrustInfo = StellaOps.Provenance.TrustInfo;
|
|
using DocumentObject = StellaOps.Concelier.Documents.DocumentObject;
|
|
|
|
namespace StellaOps.Concelier.WebService.Tests;
|
|
|
|
public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
|
{
|
|
private const string TestAuthorityIssuer = "https://authority.example";
|
|
private const string TestAuthorityAudience = "api://concelier";
|
|
private const string TestSigningSecret = "0123456789ABCDEF0123456789ABCDEF";
|
|
private static readonly SymmetricSecurityKey TestSigningKey = new(Encoding.UTF8.GetBytes(TestSigningSecret));
|
|
|
|
private readonly ITestOutputHelper _output;
|
|
private InMemoryDbRunner _runner = null!;
|
|
private ConcelierApplicationFactory _factory = null!;
|
|
|
|
public WebServiceEndpointsTests(ITestOutputHelper output)
|
|
{
|
|
_output = output;
|
|
}
|
|
|
|
public ValueTask InitializeAsync()
|
|
{
|
|
// Reset shared in-memory database state before each test
|
|
InMemoryClient.ResetSharedState();
|
|
_runner = InMemoryDbRunner.Start();
|
|
// Use an empty connection string - the factory sets a default Postgres connection string
|
|
// and the stub services bypass actual database operations
|
|
_factory = new ConcelierApplicationFactory(string.Empty);
|
|
WarmupFactory(_factory);
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
_factory.Dispose();
|
|
_runner.Dispose();
|
|
// Clear shared state after test completes
|
|
InMemoryClient.ResetSharedState();
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HealthAndReadyEndpointsRespond()
|
|
{
|
|
using var client = _factory.CreateClient();
|
|
|
|
var healthResponse = await client.GetAsync("/health");
|
|
if (!healthResponse.IsSuccessStatusCode)
|
|
{
|
|
var body = await healthResponse.Content.ReadAsStringAsync();
|
|
throw new Xunit.Sdk.XunitException($"/health failed: {(int)healthResponse.StatusCode} {body}");
|
|
}
|
|
|
|
var readyResponse = await client.GetAsync("/ready");
|
|
if (!readyResponse.IsSuccessStatusCode)
|
|
{
|
|
var body = await readyResponse.Content.ReadAsStringAsync();
|
|
throw new Xunit.Sdk.XunitException($"/ready failed: {(int)readyResponse.StatusCode} {body}");
|
|
}
|
|
|
|
var healthPayload = await healthResponse.Content.ReadFromJsonAsync<HealthPayload>();
|
|
Assert.NotNull(healthPayload);
|
|
Assert.Equal("healthy", healthPayload!.Status);
|
|
Assert.Equal("postgres", healthPayload.Storage.Backend);
|
|
|
|
var readyPayload = await readyResponse.Content.ReadFromJsonAsync<ReadyPayload>();
|
|
Assert.NotNull(readyPayload);
|
|
Assert.True(readyPayload!.Status is "ready" or "degraded");
|
|
Assert.Equal("postgres", readyPayload.Storage.Backend);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ObservationsEndpoint_ReturnsTenantScopedResults()
|
|
{
|
|
await SeedObservationDocumentsAsync(BuildSampleObservationDocuments());
|
|
|
|
using var client = _factory.CreateClient();
|
|
|
|
var response = await client.GetAsync("/concelier/observations?tenant=tenant-a&alias=CVE-2025-0001");
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
throw new XunitException($"/concelier/observations failed: {(int)response.StatusCode} {body}");
|
|
}
|
|
|
|
using var document = await response.Content.ReadFromJsonAsync<JsonDocument>();
|
|
Assert.NotNull(document);
|
|
var root = document!.RootElement;
|
|
var observations = root.GetProperty("observations").EnumerateArray().ToArray();
|
|
Assert.Equal(2, observations.Length);
|
|
Assert.Equal("tenant-a:ghsa:beta:1", observations[0].GetProperty("observationId").GetString());
|
|
Assert.Equal("tenant-a:nvd:alpha:1", observations[1].GetProperty("observationId").GetString());
|
|
|
|
var linkset = root.GetProperty("linkset");
|
|
Assert.Equal(new[] { "cve-2025-0001", "ghsa-2025-xyz" }, linkset.GetProperty("aliases").EnumerateArray().Select(x => x.GetString()).ToArray());
|
|
Assert.Equal(new[] { "pkg:npm/demo@1.0.0", "pkg:npm/demo@1.1.0" }, linkset.GetProperty("purls").EnumerateArray().Select(x => x.GetString()).ToArray());
|
|
Assert.Equal(new[] { "cpe:/a:vendor:product:1.0", "cpe:/a:vendor:product:1.1" }, linkset.GetProperty("cpes").EnumerateArray().Select(x => x.GetString()).ToArray());
|
|
|
|
var references = linkset.GetProperty("references").EnumerateArray().ToArray();
|
|
Assert.Equal(2, references.Length);
|
|
Assert.Equal("advisory", references[0].GetProperty("type").GetString());
|
|
Assert.Equal("https://example.test/advisory-1", references[0].GetProperty("url").GetString());
|
|
Assert.Equal("patch", references[1].GetProperty("type").GetString());
|
|
|
|
var confidence = linkset.GetProperty("confidence").GetDouble();
|
|
// Real query service computes confidence based on data consistency between observations.
|
|
// Since the two observations have different purls/cpes, confidence will be < 1.0
|
|
Assert.InRange(confidence, 0.0, 1.0);
|
|
|
|
var conflicts = linkset.GetProperty("conflicts").EnumerateArray().ToArray();
|
|
// Real query service detects conflicts between observations with differing linkset data
|
|
// (conflicts are expected when observations have different purls/cpes for same alias)
|
|
|
|
Assert.False(root.GetProperty("hasMore").GetBoolean());
|
|
Assert.True(root.GetProperty("nextCursor").ValueKind == JsonValueKind.Null);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ObservationsEndpoint_AppliesObservationIdFilter()
|
|
{
|
|
await SeedObservationDocumentsAsync(BuildSampleObservationDocuments());
|
|
|
|
using var client = _factory.CreateClient();
|
|
var observationId = Uri.EscapeDataString("tenant-a:ghsa:beta:1");
|
|
var response = await client.GetAsync($"/concelier/observations?tenant=tenant-a&observationId={observationId}&cpe=cpe:/a:vendor:product:1.1");
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
throw new XunitException($"/concelier/observations filter failed: {(int)response.StatusCode} {body}");
|
|
}
|
|
|
|
using var document = await response.Content.ReadFromJsonAsync<JsonDocument>();
|
|
Assert.NotNull(document);
|
|
var root = document!.RootElement;
|
|
var observations = root.GetProperty("observations").EnumerateArray().ToArray();
|
|
Assert.Single(observations);
|
|
Assert.Equal("tenant-a:ghsa:beta:1", observations[0].GetProperty("observationId").GetString());
|
|
Assert.Equal(new[] { "pkg:npm/demo@1.1.0" }, observations[0].GetProperty("linkset").GetProperty("purls").EnumerateArray().Select(x => x.GetString()).ToArray());
|
|
Assert.Equal(new[] { "cpe:/a:vendor:product:1.1" }, observations[0].GetProperty("linkset").GetProperty("cpes").EnumerateArray().Select(x => x.GetString()).ToArray());
|
|
|
|
Assert.False(root.GetProperty("hasMore").GetBoolean());
|
|
Assert.True(root.GetProperty("nextCursor").ValueKind == JsonValueKind.Null);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ObservationsEndpoint_SupportsPagination()
|
|
{
|
|
await SeedObservationDocumentsAsync(BuildSampleObservationDocuments());
|
|
|
|
using var client = _factory.CreateClient();
|
|
|
|
var firstResponse = await client.GetAsync("/concelier/observations?tenant=tenant-a&limit=1");
|
|
firstResponse.EnsureSuccessStatusCode();
|
|
using var firstDocument = await firstResponse.Content.ReadFromJsonAsync<JsonDocument>();
|
|
Assert.NotNull(firstDocument);
|
|
var firstRoot = firstDocument!.RootElement;
|
|
var firstObservations = firstRoot.GetProperty("observations").EnumerateArray().ToArray();
|
|
Assert.Single(firstObservations);
|
|
var nextCursor = firstRoot.GetProperty("nextCursor").GetString();
|
|
Assert.True(firstRoot.GetProperty("hasMore").GetBoolean());
|
|
Assert.False(string.IsNullOrWhiteSpace(nextCursor));
|
|
|
|
var secondResponse = await client.GetAsync($"/concelier/observations?tenant=tenant-a&limit=2&cursor={Uri.EscapeDataString(nextCursor!)}");
|
|
secondResponse.EnsureSuccessStatusCode();
|
|
using var secondDocument = await secondResponse.Content.ReadFromJsonAsync<JsonDocument>();
|
|
Assert.NotNull(secondDocument);
|
|
var secondRoot = secondDocument!.RootElement;
|
|
var secondObservations = secondRoot.GetProperty("observations").EnumerateArray().ToArray();
|
|
Assert.Single(secondObservations);
|
|
Assert.False(secondRoot.GetProperty("hasMore").GetBoolean());
|
|
Assert.True(secondRoot.GetProperty("nextCursor").ValueKind == JsonValueKind.Null);
|
|
Assert.Equal("tenant-a:nvd:alpha:1", secondObservations[0].GetProperty("observationId").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LinksetsEndpoint_ReturnsNormalizedLinksetsFromIngestion()
|
|
{
|
|
var tenant = "tenant-linkset-ingest";
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Stella-Tenant", tenant);
|
|
|
|
var firstIngest = await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:linkset-1", "GHSA-LINK-001", purls: new[] { "pkg:npm/demo@1.0.0" }));
|
|
firstIngest.EnsureSuccessStatusCode();
|
|
|
|
var secondIngest = await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:linkset-2", "GHSA-LINK-002", purls: new[] { "pkg:npm/demo@2.0.0" }));
|
|
secondIngest.EnsureSuccessStatusCode();
|
|
|
|
var response = await client.GetAsync("/linksets?tenant=tenant-linkset-ingest&limit=10");
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var payload = await response.Content.ReadFromJsonAsync<AdvisoryLinksetQueryResponse>();
|
|
Assert.NotNull(payload);
|
|
Assert.Equal(2, payload!.Linksets.Length);
|
|
|
|
var linksetAdvisoryIds = payload.Linksets.Select(ls => ls.AdvisoryId).OrderBy(id => id, StringComparer.Ordinal).ToArray();
|
|
Assert.Equal(new[] { "GHSA-LINK-001", "GHSA-LINK-002" }, linksetAdvisoryIds);
|
|
|
|
var allPurls = payload.Linksets.SelectMany(ls => ls.Purls).OrderBy(p => p, StringComparer.Ordinal).ToArray();
|
|
Assert.Contains("pkg:npm/demo@1.0.0", allPurls);
|
|
Assert.Contains("pkg:npm/demo@2.0.0", allPurls);
|
|
|
|
var versions = payload.Linksets
|
|
.SelectMany(ls => ls.Versions)
|
|
.Distinct(StringComparer.Ordinal)
|
|
.OrderBy(v => v, StringComparer.Ordinal)
|
|
.ToArray();
|
|
Assert.Contains("1.0.0", versions);
|
|
Assert.Contains("2.0.0", versions);
|
|
|
|
Assert.False(payload.HasMore);
|
|
Assert.True(string.IsNullOrEmpty(payload.NextCursor));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LinksetsEndpoint_SupportsCursorPagination()
|
|
{
|
|
var tenant = "tenant-linkset-page";
|
|
var documents = new[]
|
|
{
|
|
CreateLinksetDocument(
|
|
tenant,
|
|
"nvd",
|
|
"ADV-002",
|
|
new[] { "obs-2" },
|
|
new[] { "pkg:npm/demo@2.0.0" },
|
|
new[] { "2.0.0" },
|
|
new DateTime(2025, 1, 6, 0, 0, 0, DateTimeKind.Utc)),
|
|
CreateLinksetDocument(
|
|
tenant,
|
|
"osv",
|
|
"ADV-001",
|
|
new[] { "obs-1" },
|
|
new[] { "pkg:npm/demo@1.0.0" },
|
|
new[] { "1.0.0" },
|
|
new DateTime(2025, 1, 5, 0, 0, 0, DateTimeKind.Utc)),
|
|
CreateLinksetDocument(
|
|
"tenant-other",
|
|
"osv",
|
|
"ADV-999",
|
|
new[] { "obs-x" },
|
|
new[] { "pkg:npm/other@1.0.0" },
|
|
new[] { "1.0.0" },
|
|
new DateTime(2025, 1, 4, 0, 0, 0, DateTimeKind.Utc))
|
|
};
|
|
|
|
await SeedLinksetDocumentsAsync(documents);
|
|
|
|
using var client = _factory.CreateClient();
|
|
|
|
var firstResponse = await client.GetAsync($"/linksets?tenant={tenant}&limit=1");
|
|
firstResponse.EnsureSuccessStatusCode();
|
|
var firstPayload = await firstResponse.Content.ReadFromJsonAsync<AdvisoryLinksetQueryResponse>();
|
|
Assert.NotNull(firstPayload);
|
|
var first = Assert.Single(firstPayload!.Linksets);
|
|
Assert.Equal("ADV-002", first.AdvisoryId);
|
|
Assert.Equal(new[] { "pkg:npm/demo@2.0.0" }, first.Purls.ToArray());
|
|
Assert.Equal(new[] { "2.0.0" }, first.Versions.ToArray());
|
|
Assert.True(firstPayload.HasMore);
|
|
Assert.False(string.IsNullOrWhiteSpace(firstPayload.NextCursor));
|
|
|
|
var secondResponse = await client.GetAsync($"/linksets?tenant={tenant}&limit=1&cursor={Uri.EscapeDataString(firstPayload.NextCursor!)}");
|
|
secondResponse.EnsureSuccessStatusCode();
|
|
var secondPayload = await secondResponse.Content.ReadFromJsonAsync<AdvisoryLinksetQueryResponse>();
|
|
Assert.NotNull(secondPayload);
|
|
var second = Assert.Single(secondPayload!.Linksets);
|
|
Assert.Equal("ADV-001", second.AdvisoryId);
|
|
Assert.Equal(new[] { "pkg:npm/demo@1.0.0" }, second.Purls.ToArray());
|
|
Assert.Equal(new[] { "1.0.0" }, second.Versions.ToArray());
|
|
Assert.False(secondPayload.HasMore);
|
|
Assert.True(string.IsNullOrEmpty(secondPayload.NextCursor));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LnmLinksetsEndpoints_ReturnFactOnlyLinksets()
|
|
{
|
|
var tenant = "tenant-lnm-list";
|
|
var documents = new[]
|
|
{
|
|
CreateLinksetDocument(
|
|
tenant,
|
|
"nvd",
|
|
"ADV-002",
|
|
new[] { "obs-2" },
|
|
new[] { "pkg:npm/demo@2.0.0" },
|
|
new[] { "2.0.0" },
|
|
new DateTime(2025, 1, 6, 0, 0, 0, DateTimeKind.Utc)),
|
|
CreateLinksetDocument(
|
|
tenant,
|
|
"osv",
|
|
"ADV-001",
|
|
new[] { "obs-1" },
|
|
new[] { "pkg:npm/demo@1.0.0" },
|
|
new[] { "1.0.0" },
|
|
new DateTime(2025, 1, 5, 0, 0, 0, DateTimeKind.Utc))
|
|
};
|
|
|
|
await SeedLinksetDocumentsAsync(documents);
|
|
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Stella-Tenant", tenant);
|
|
|
|
var listResponse = await client.GetAsync("/v1/lnm/linksets?pageSize=1&page=1");
|
|
listResponse.EnsureSuccessStatusCode();
|
|
|
|
var listPayload = await listResponse.Content.ReadFromJsonAsync<JsonElement>();
|
|
var firstItem = listPayload.GetProperty("items").EnumerateArray().First();
|
|
Assert.Equal("ADV-002", firstItem.GetProperty("advisoryId").GetString());
|
|
Assert.Contains("pkg:npm/demo@2.0.0", firstItem.GetProperty("purl").EnumerateArray().Select(x => x.GetString()));
|
|
Assert.True(firstItem.GetProperty("conflicts").EnumerateArray().Count() >= 0);
|
|
Assert.Equal("created", firstItem.GetProperty("timeline").EnumerateArray().First().GetProperty("event").GetString());
|
|
Assert.Equal(DateTime.Parse("2025-01-06T00:00:00Z"), firstItem.GetProperty("publishedAt").GetDateTime());
|
|
|
|
var detailResponse = await client.GetAsync("/v1/lnm/linksets/ADV-001?source=osv&includeObservations=true");
|
|
detailResponse.EnsureSuccessStatusCode();
|
|
|
|
var detailPayload = await detailResponse.Content.ReadFromJsonAsync<JsonElement>();
|
|
Assert.Equal("ADV-001", detailPayload.GetProperty("advisoryId").GetString());
|
|
Assert.Equal("osv", detailPayload.GetProperty("source").GetString());
|
|
Assert.Contains("pkg:npm/demo@1.0.0", detailPayload.GetProperty("purl").EnumerateArray().Select(x => x.GetString()));
|
|
Assert.Contains("obs-1", detailPayload.GetProperty("observations").EnumerateArray().Select(x => x.GetString()));
|
|
Assert.Equal(DateTime.Parse("2025-01-05T00:00:00Z"), detailPayload.GetProperty("publishedAt").GetDateTime());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ObservationsEndpoint_ReturnsBadRequestWhenTenantMissing()
|
|
{
|
|
using var client = _factory.CreateClient();
|
|
var response = await client.GetAsync("/concelier/observations");
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
Assert.True(response.StatusCode == HttpStatusCode.BadRequest, $"Expected 400 but got {(int)response.StatusCode}: {body}");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdvisoryChunksEndpoint_ReturnsParagraphAnchors()
|
|
{
|
|
var newestRaw = DocumentObject.Parse(
|
|
"""
|
|
{
|
|
"summary": {
|
|
"intro": "This is a deterministic summary paragraph describing CVE-2025-0001 with remediation context for Advisory AI consumers."
|
|
},
|
|
"details": [
|
|
"Long-form remediation guidance that exceeds the minimum length threshold and mentions affected packages.",
|
|
{
|
|
"body": "Nested context that Advisory AI can cite when rendering downstream explanations."
|
|
}
|
|
]
|
|
}
|
|
""");
|
|
var olderRaw = DocumentObject.Parse(
|
|
"""
|
|
{
|
|
"summary": {
|
|
"intro": "Older paragraph that should be visible when no section filter applies."
|
|
}
|
|
}
|
|
""");
|
|
|
|
var newerCreatedAt = new DateTime(2025, 1, 7, 0, 0, 0, DateTimeKind.Utc);
|
|
var olderCreatedAt = new DateTime(2025, 1, 5, 0, 0, 0, DateTimeKind.Utc);
|
|
var newerHash = ComputeContentHash(newestRaw);
|
|
var olderHash = ComputeContentHash(olderRaw);
|
|
|
|
var documents = new[]
|
|
{
|
|
CreateChunkObservationDocument(
|
|
id: "tenant-a:chunk:newest",
|
|
tenant: "tenant-a",
|
|
createdAt: newerCreatedAt,
|
|
alias: "cve-2025-0001",
|
|
rawDocument: newestRaw),
|
|
CreateChunkObservationDocument(
|
|
id: "tenant-a:chunk:older",
|
|
tenant: "tenant-a",
|
|
createdAt: olderCreatedAt,
|
|
alias: "cve-2025-0001",
|
|
rawDocument: olderRaw)
|
|
};
|
|
|
|
await SeedObservationDocumentsAsync(documents);
|
|
await SeedAdvisoryRawDocumentsAsync(
|
|
CreateAdvisoryRawDocument("tenant-a", "nvd", "tenant-a:chunk:newest", newerHash, newestRaw.DeepClone().AsDocumentObject),
|
|
CreateAdvisoryRawDocument("tenant-a", "nvd", "tenant-a:chunk:older", olderHash, olderRaw.DeepClone().AsDocumentObject));
|
|
|
|
await SeedCanonicalAdvisoriesAsync(
|
|
CreateStructuredAdvisory("CVE-2025-0001", "GHSA-2025-0001", "tenant-a:chunk:newest", newerCreatedAt));
|
|
|
|
using var client = _factory.CreateClient();
|
|
var response = await client.GetAsync("/advisories/cve-2025-0001/chunks?tenant=tenant-a§ion=workaround");
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var payload = await response.Content.ReadAsStringAsync();
|
|
using var document = JsonDocument.Parse(payload);
|
|
var root = document.RootElement;
|
|
|
|
Assert.Equal("CVE-2025-0001", root.GetProperty("advisoryKey").GetString());
|
|
Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("fingerprint").GetString()));
|
|
Assert.Equal(1, root.GetProperty("total").GetInt32());
|
|
Assert.False(root.GetProperty("truncated").GetBoolean());
|
|
|
|
var entry = Assert.Single(root.GetProperty("entries").EnumerateArray());
|
|
Assert.Equal("workaround", entry.GetProperty("type").GetString());
|
|
Assert.False(string.IsNullOrWhiteSpace(entry.GetProperty("chunkId").GetString()));
|
|
|
|
var content = entry.GetProperty("content");
|
|
Assert.Equal("Vendor guidance", content.GetProperty("title").GetString());
|
|
Assert.Equal("Apply configuration change immediately.", content.GetProperty("description").GetString());
|
|
Assert.Equal("https://vendor.example/workaround", content.GetProperty("url").GetString());
|
|
|
|
var provenance = entry.GetProperty("provenance");
|
|
Assert.Equal("tenant-a:chunk:newest", provenance.GetProperty("documentId").GetString());
|
|
Assert.Equal("/references/0", provenance.GetProperty("observationPath").GetString());
|
|
Assert.Equal("nvd", provenance.GetProperty("source").GetString());
|
|
Assert.Equal("workaround", provenance.GetProperty("kind").GetString());
|
|
Assert.Equal("tenant-a:chunk:newest", provenance.GetProperty("value").GetString());
|
|
Assert.Contains(
|
|
"/references/0",
|
|
provenance.GetProperty("fieldMask").EnumerateArray().Select(element => element.GetString()));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdvisoryChunksEndpoint_ReturnsNotFoundWhenAdvisoryMissing()
|
|
{
|
|
await SeedObservationDocumentsAsync(BuildSampleObservationDocuments());
|
|
await SeedCanonicalAdvisoriesAsync();
|
|
|
|
using var client = _factory.CreateClient();
|
|
var response = await client.GetAsync("/advisories/cve-2099-9999/chunks?tenant=tenant-a");
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
var payload = await response.Content.ReadAsStringAsync();
|
|
using var document = JsonDocument.Parse(payload);
|
|
var root = document.RootElement;
|
|
Assert.Equal("https://stellaops.org/problems/not-found", root.GetProperty("type").GetString());
|
|
Assert.Equal("Advisory not found", root.GetProperty("title").GetString());
|
|
Assert.Contains("cve-2099-9999", root.GetProperty("detail").GetString(), StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdvisoryIngestEndpoint_PersistsDocumentAndSupportsReadback()
|
|
{
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-ingest");
|
|
|
|
const string upstreamId = "GHSA-INGEST-0001";
|
|
var ingestRequest = BuildAdvisoryIngestRequest(
|
|
contentHash: null,
|
|
upstreamId: upstreamId);
|
|
|
|
var ingestResponse = await client.PostAsJsonAsync("/ingest/advisory", ingestRequest);
|
|
if (ingestResponse.StatusCode != HttpStatusCode.Created)
|
|
{
|
|
WriteProgramLogs();
|
|
}
|
|
Assert.Equal(HttpStatusCode.Created, ingestResponse.StatusCode);
|
|
|
|
var ingestPayload = await ingestResponse.Content.ReadFromJsonAsync<AdvisoryIngestResponse>();
|
|
Assert.NotNull(ingestPayload);
|
|
Assert.True(ingestPayload!.Inserted);
|
|
Assert.False(string.IsNullOrWhiteSpace(ingestPayload.Id));
|
|
Assert.Equal("tenant-ingest", ingestPayload.Tenant);
|
|
Assert.Equal(ComputeDeterministicContentHash(upstreamId), ingestPayload.ContentHash);
|
|
Assert.NotNull(ingestResponse.Headers.Location);
|
|
var locationValue = ingestResponse.Headers.Location!.ToString();
|
|
Assert.False(string.IsNullOrWhiteSpace(locationValue));
|
|
var lastSlashIndex = locationValue.LastIndexOf('/');
|
|
var idSegment = lastSlashIndex >= 0
|
|
? locationValue[(lastSlashIndex + 1)..]
|
|
: locationValue;
|
|
var decodedSegment = Uri.UnescapeDataString(idSegment);
|
|
Assert.Equal(ingestPayload.Id, decodedSegment);
|
|
|
|
var duplicateResponse = await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest(
|
|
contentHash: null,
|
|
upstreamId: upstreamId));
|
|
Assert.Equal(HttpStatusCode.OK, duplicateResponse.StatusCode);
|
|
var duplicatePayload = await duplicateResponse.Content.ReadFromJsonAsync<AdvisoryIngestResponse>();
|
|
Assert.NotNull(duplicatePayload);
|
|
Assert.False(duplicatePayload!.Inserted);
|
|
|
|
using (var getRequest = new HttpRequestMessage(HttpMethod.Get, $"/advisories/raw/{ingestPayload.Id}"))
|
|
{
|
|
getRequest.Headers.Add("X-Stella-Tenant", "tenant-ingest");
|
|
var getResponse = await client.SendAsync(getRequest);
|
|
getResponse.EnsureSuccessStatusCode();
|
|
|
|
var record = await getResponse.Content.ReadFromJsonAsync<AdvisoryRawRecordResponse>();
|
|
Assert.NotNull(record);
|
|
Assert.Equal(ingestPayload.Id, record!.Id);
|
|
Assert.Equal("tenant-ingest", record.Tenant);
|
|
Assert.Equal(ComputeDeterministicContentHash(upstreamId), record.Document.Upstream.ContentHash);
|
|
}
|
|
|
|
using (var listRequest = new HttpRequestMessage(HttpMethod.Get, "/advisories/raw?limit=10"))
|
|
{
|
|
listRequest.Headers.Add("X-Stella-Tenant", "tenant-ingest");
|
|
var listResponse = await client.SendAsync(listRequest);
|
|
listResponse.EnsureSuccessStatusCode();
|
|
|
|
var listPayload = await listResponse.Content.ReadFromJsonAsync<AdvisoryRawListResponse>();
|
|
Assert.NotNull(listPayload);
|
|
var record = Assert.Single(listPayload!.Records);
|
|
Assert.Equal(ingestPayload.Id, record.Id);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AocVerifyEndpoint_ReturnsSummaryForTenant()
|
|
{
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-verify");
|
|
|
|
await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest(
|
|
contentHash: "sha256:verify-1",
|
|
upstreamId: "GHSA-VERIFY-001"));
|
|
|
|
var verifyResponse = await client.PostAsJsonAsync("/aoc/verify", new AocVerifyRequest(null, null, null, null, null));
|
|
verifyResponse.EnsureSuccessStatusCode();
|
|
|
|
var verifyPayload = await verifyResponse.Content.ReadFromJsonAsync<AocVerifyResponse>();
|
|
Assert.NotNull(verifyPayload);
|
|
Assert.Equal("tenant-verify", verifyPayload!.Tenant);
|
|
Assert.True(verifyPayload.Checked.Advisories >= 1);
|
|
Assert.Equal(0, verifyPayload.Checked.Vex);
|
|
Assert.True(verifyPayload.Metrics.IngestionWriteTotal >= verifyPayload.Checked.Advisories);
|
|
Assert.Empty(verifyPayload.Violations);
|
|
Assert.False(verifyPayload.Truncated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AocVerifyEndpoint_ReturnsViolationsForGuardFailures()
|
|
{
|
|
await SeedAdvisoryRawDocumentsAsync(
|
|
CreateAdvisoryRawDocument(
|
|
tenant: "tenant-verify-violations",
|
|
vendor: "osv",
|
|
upstreamId: "GHSA-VERIFY-ERR",
|
|
contentHash: "sha256:verify-err",
|
|
raw: new DocumentObject
|
|
{
|
|
{ "id", "GHSA-VERIFY-ERR" },
|
|
{ "severity", "critical" }
|
|
}));
|
|
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-verify-violations");
|
|
|
|
var verifyResponse = await client.PostAsJsonAsync("/aoc/verify", new AocVerifyRequest(null, null, null, null, null));
|
|
verifyResponse.EnsureSuccessStatusCode();
|
|
|
|
var verifyPayload = await verifyResponse.Content.ReadFromJsonAsync<AocVerifyResponse>();
|
|
Assert.NotNull(verifyPayload);
|
|
Assert.Equal("tenant-verify-violations", verifyPayload!.Tenant);
|
|
Assert.True(verifyPayload.Checked.Advisories >= 1);
|
|
var violation = Assert.Single(verifyPayload.Violations);
|
|
Assert.Equal("ERR_AOC_001", violation.Code);
|
|
Assert.True(violation.Count >= 1);
|
|
Assert.NotEmpty(violation.Examples);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdvisoryRawListEndpoint_SupportsCursorPagination()
|
|
{
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-list");
|
|
|
|
await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:list-1", "GHSA-LIST-001"));
|
|
await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:list-2", "GHSA-LIST-002"));
|
|
await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:list-3", "GHSA-LIST-003"));
|
|
|
|
using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "/advisories/raw?limit=2");
|
|
firstRequest.Headers.Add("X-Stella-Tenant", "tenant-list");
|
|
var firstResponse = await client.SendAsync(firstRequest);
|
|
firstResponse.EnsureSuccessStatusCode();
|
|
|
|
var firstPage = await firstResponse.Content.ReadFromJsonAsync<AdvisoryRawListResponse>();
|
|
Assert.NotNull(firstPage);
|
|
Assert.Equal(2, firstPage!.Records.Count);
|
|
Assert.True(firstPage.HasMore);
|
|
Assert.False(string.IsNullOrWhiteSpace(firstPage.NextCursor));
|
|
|
|
using var secondRequest = new HttpRequestMessage(HttpMethod.Get, $"/advisories/raw?cursor={Uri.EscapeDataString(firstPage.NextCursor!)}");
|
|
secondRequest.Headers.Add("X-Stella-Tenant", "tenant-list");
|
|
var secondResponse = await client.SendAsync(secondRequest);
|
|
secondResponse.EnsureSuccessStatusCode();
|
|
|
|
var secondPage = await secondResponse.Content.ReadFromJsonAsync<AdvisoryRawListResponse>();
|
|
Assert.NotNull(secondPage);
|
|
Assert.Single(secondPage!.Records);
|
|
Assert.False(secondPage.HasMore);
|
|
Assert.Null(secondPage.NextCursor);
|
|
|
|
var firstIds = firstPage.Records.Select(record => record.Id).ToArray();
|
|
var secondIds = secondPage.Records.Select(record => record.Id).ToArray();
|
|
Assert.Empty(firstIds.Intersect(secondIds));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdvisoryEvidenceEndpoint_ReturnsDocumentsForCanonicalKey()
|
|
{
|
|
await SeedAdvisoryRawDocumentsAsync(
|
|
CreateAdvisoryRawDocument("tenant-a", "vendor-x", "GHSA-2025-0001", "sha256:001", new DocumentObject("id", "GHSA-2025-0001:1")),
|
|
CreateAdvisoryRawDocument("tenant-a", "vendor-y", "GHSA-2025-0001", "sha256:002", new DocumentObject("id", "GHSA-2025-0001:2")),
|
|
CreateAdvisoryRawDocument("tenant-b", "vendor-x", "GHSA-2025-0001", "sha256:003", new DocumentObject("id", "GHSA-2025-0001:3")));
|
|
|
|
using var client = _factory.CreateClient();
|
|
var response = await client.GetAsync("/vuln/evidence/advisories/ghsa-2025-0001?tenant=tenant-a");
|
|
|
|
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));
|
|
Assert.Null(evidence.Attestation);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdvisoryEvidenceEndpoint_AttachesAttestationWhenBundleProvided()
|
|
{
|
|
await SeedAdvisoryRawDocumentsAsync(
|
|
CreateAdvisoryRawDocument("tenant-a", "vendor-x", "GHSA-2025-0003", "sha256:201", new DocumentObject("id", "GHSA-2025-0003:1")));
|
|
|
|
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
|
|
var sampleDir = Path.Combine(repoRoot, "docs", "samples", "evidence-bundle");
|
|
var tarPath = Path.Combine(sampleDir, "evidence-bundle-m0.tar.gz");
|
|
var manifestPath = Path.Combine(sampleDir, "manifest.json");
|
|
var transparencyPath = Path.Combine(sampleDir, "transparency.json");
|
|
|
|
using var client = _factory.CreateClient();
|
|
var requestUri = $"/vuln/evidence/advisories/GHSA-2025-0003?tenant=tenant-a&bundlePath={Uri.EscapeDataString(tarPath)}&manifestPath={Uri.EscapeDataString(manifestPath)}&transparencyPath={Uri.EscapeDataString(transparencyPath)}&pipelineVersion=git:test-sha";
|
|
var response = await client.GetAsync(requestUri);
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
var evidence = await response.Content.ReadFromJsonAsync<AdvisoryEvidenceResponse>();
|
|
|
|
Assert.NotNull(evidence);
|
|
Assert.NotNull(evidence!.Attestation);
|
|
Assert.Equal("evidence-bundle-m0", evidence.Attestation!.SubjectName);
|
|
Assert.Equal("git:test-sha", evidence.Attestation.PipelineVersion);
|
|
Assert.Equal(tarPath, evidence.Attestation.EvidenceBundlePath);
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Category", "Attestation")]
|
|
public async Task InternalAttestationVerify_ReturnsClaims()
|
|
{
|
|
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
|
|
var sampleDir = Path.Combine(repoRoot, "docs", "samples", "evidence-bundle");
|
|
var tarPath = Path.Combine(sampleDir, "evidence-bundle-m0.tar.gz");
|
|
var manifestPath = Path.Combine(sampleDir, "manifest.json");
|
|
var transparencyPath = Path.Combine(sampleDir, "transparency.json");
|
|
|
|
using var scope = _factory.Services.CreateScope();
|
|
var concOptions = scope.ServiceProvider.GetRequiredService<IOptions<ConcelierOptions>>().Value;
|
|
_output.WriteLine($"EvidenceRoot={concOptions.Evidence.RootAbsolute}");
|
|
Assert.StartsWith(concOptions.Evidence.RootAbsolute, tarPath, StringComparison.OrdinalIgnoreCase);
|
|
|
|
using var client = _factory.CreateClient();
|
|
var request = new VerifyAttestationRequest(tarPath, manifestPath, transparencyPath, "git:test-sha");
|
|
|
|
var response = await client.PostAsJsonAsync("/internal/attestations/verify?tenant=demo", request);
|
|
|
|
var responseBody = await response.Content.ReadAsStringAsync();
|
|
Assert.True(response.IsSuccessStatusCode, $"Attestation verify failed: {(int)response.StatusCode} {response.StatusCode} · {responseBody}");
|
|
|
|
var claims = JsonSerializer.Deserialize<AttestationClaims>(
|
|
responseBody,
|
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
|
|
|
Assert.NotNull(claims);
|
|
Assert.Equal("evidence-bundle-m0", claims!.SubjectName);
|
|
Assert.Equal("git:test-sha", claims.PipelineVersion);
|
|
Assert.Equal(tarPath, claims.EvidenceBundlePath);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvidenceBatch_ReturnsEmptyCollectionsWhenUnknown()
|
|
{
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add(TenantHeaderName, "demo");
|
|
|
|
var request = new EvidenceBatchRequest(
|
|
new[]
|
|
{
|
|
new EvidenceBatchItemRequest("component-a", new[] { "pkg:purl/example@1.0.0" }, new[] { "ALIAS-1" })
|
|
},
|
|
ObservationLimit: 5,
|
|
LinksetLimit: 5);
|
|
|
|
var response = await client.PostAsJsonAsync("/v1/evidence/batch", request);
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
var payload = await response.Content.ReadFromJsonAsync<EvidenceBatchResponse>();
|
|
|
|
Assert.NotNull(payload);
|
|
var item = Assert.Single(payload!.Items);
|
|
Assert.Equal("component-a", item.ComponentId);
|
|
Assert.Empty(item.Observations);
|
|
Assert.Empty(item.Linksets);
|
|
Assert.False(item.HasMore);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdvisoryEvidenceEndpoint_FiltersByVendor()
|
|
{
|
|
await SeedAdvisoryRawDocumentsAsync(
|
|
CreateAdvisoryRawDocument("tenant-a", "vendor-x", "GHSA-2025-0002", "sha256:101", new DocumentObject("id", "GHSA-2025-0002:1")),
|
|
CreateAdvisoryRawDocument("tenant-a", "vendor-y", "GHSA-2025-0002", "sha256:102", new DocumentObject("id", "GHSA-2025-0002:2")));
|
|
|
|
using var client = _factory.CreateClient();
|
|
var response = await client.GetAsync("/vuln/evidence/advisories/GHSA-2025-0002?tenant=tenant-a&vendor=vendor-y");
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
var evidence = await response.Content.ReadFromJsonAsync<AdvisoryEvidenceResponse>();
|
|
|
|
Assert.NotNull(evidence);
|
|
var record = Assert.Single(evidence!.Records);
|
|
Assert.Equal("vendor-y", record.Document.Source.Vendor);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdvisoryEvidenceEndpoint_ReturnsNotFoundWhenMissing()
|
|
{
|
|
await SeedAdvisoryRawDocumentsAsync();
|
|
|
|
using var client = _factory.CreateClient();
|
|
var response = await client.GetAsync("/vuln/evidence/advisories/CVE-2099-9999?tenant=tenant-a");
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdvisoryChunksEndpoint_EmitsRequestAndCacheMetrics()
|
|
{
|
|
await SeedObservationDocumentsAsync(BuildSampleObservationDocuments());
|
|
await SeedCanonicalAdvisoriesAsync(
|
|
CreateStructuredAdvisory(
|
|
"CVE-2025-0001",
|
|
"GHSA-2025-0001",
|
|
"tenant-a:nvd:alpha:1",
|
|
new DateTimeOffset(2025, 1, 5, 0, 0, 0, TimeSpan.Zero)));
|
|
|
|
using var client = _factory.CreateClient();
|
|
|
|
long expectedSegments = 0;
|
|
string expectedTruncatedTag = "false";
|
|
|
|
var metrics = await CaptureMetricsAsync(
|
|
AdvisoryAiMetrics.MeterName,
|
|
new[]
|
|
{
|
|
"advisory_ai_chunk_requests_total",
|
|
"advisory_ai_chunk_cache_hits_total",
|
|
"advisory_ai_chunk_latency_milliseconds",
|
|
"advisory_ai_chunk_segments",
|
|
"advisory_ai_chunk_sources"
|
|
},
|
|
async () =>
|
|
{
|
|
const string url = "/advisories/CVE-2025-0001/chunks?tenant=tenant-a";
|
|
var first = await client.GetAsync(url);
|
|
first.EnsureSuccessStatusCode();
|
|
|
|
using (var firstDocument = await first.Content.ReadFromJsonAsync<JsonDocument>())
|
|
{
|
|
Assert.NotNull(firstDocument);
|
|
expectedSegments = firstDocument!.RootElement.GetProperty("entries").GetArrayLength();
|
|
expectedTruncatedTag = firstDocument.RootElement.GetProperty("truncated").GetBoolean() ? "true" : "false";
|
|
}
|
|
|
|
var second = await client.GetAsync(url);
|
|
second.EnsureSuccessStatusCode();
|
|
});
|
|
|
|
Assert.True(metrics.TryGetValue("advisory_ai_chunk_requests_total", out var requests));
|
|
Assert.NotNull(requests);
|
|
Assert.Equal(2, requests!.Count);
|
|
|
|
Assert.Contains(requests!, measurement =>
|
|
string.Equals(GetTagValue(measurement, "cache"), "miss", StringComparison.Ordinal));
|
|
|
|
Assert.Contains(requests!, measurement =>
|
|
string.Equals(GetTagValue(measurement, "cache"), "hit", StringComparison.Ordinal));
|
|
|
|
Assert.True(metrics.TryGetValue("advisory_ai_chunk_cache_hits_total", out var cacheHitMeasurements));
|
|
var cacheHit = Assert.Single(cacheHitMeasurements!);
|
|
Assert.Equal(1, cacheHit.Value);
|
|
Assert.Equal("hit", GetTagValue(cacheHit, "result"));
|
|
|
|
Assert.True(metrics.TryGetValue("advisory_ai_chunk_latency_milliseconds", out var latencyMeasurements));
|
|
Assert.Equal(2, latencyMeasurements!.Count);
|
|
Assert.All(latencyMeasurements!, measurement => Assert.True(measurement.Value > 0));
|
|
|
|
Assert.True(metrics.TryGetValue("advisory_ai_chunk_segments", out var segmentMeasurements));
|
|
Assert.Equal(2, segmentMeasurements!.Count);
|
|
Assert.All(segmentMeasurements!, measurement =>
|
|
{
|
|
Assert.Equal(expectedSegments, measurement.Value);
|
|
Assert.Equal(expectedTruncatedTag, GetTagValue(measurement, "truncated"));
|
|
});
|
|
|
|
Assert.True(metrics.TryGetValue("advisory_ai_chunk_sources", out var sourceMeasurements));
|
|
Assert.Equal(2, sourceMeasurements!.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdvisoryChunksEndpoint_EmitsGuardrailMetrics()
|
|
{
|
|
var raw = DocumentObject.Parse("{\"details\":\"tiny\"}");
|
|
var document = CreateChunkObservationDocument(
|
|
"tenant-a:chunk:1",
|
|
"tenant-a",
|
|
new DateTime(2025, 2, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
"CVE-2025-GUARD",
|
|
raw);
|
|
|
|
await SeedObservationDocumentsAsync(new[] { document });
|
|
await SeedCanonicalAdvisoriesAsync(
|
|
CreateStructuredAdvisory(
|
|
"CVE-2025-GUARD",
|
|
"GHSA-2025-GUARD",
|
|
"tenant-a:chunk:1",
|
|
new DateTimeOffset(2025, 2, 1, 0, 0, 0, TimeSpan.Zero)));
|
|
|
|
using var client = _factory.CreateClient();
|
|
|
|
var guardrailMetrics = await CaptureMetricsAsync(
|
|
AdvisoryAiMetrics.MeterName,
|
|
"advisory_ai_guardrail_blocks_total",
|
|
async () =>
|
|
{
|
|
var response = await client.GetAsync("/advisories/CVE-2025-GUARD/chunks?tenant=tenant-a");
|
|
response.EnsureSuccessStatusCode();
|
|
});
|
|
|
|
var measurement = Assert.Single(guardrailMetrics);
|
|
Assert.True(measurement.Value >= 1);
|
|
Assert.Equal("below_minimum_length", GetTagValue(measurement, "reason"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdvisoryIngestEndpoint_EmitsMetricsWithExpectedTags()
|
|
{
|
|
var measurements = await CaptureMetricsAsync(
|
|
IngestionMetrics.MeterName,
|
|
"ingestion_write_total",
|
|
async () =>
|
|
{
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-metrics");
|
|
|
|
await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:metric-1", "GHSA-METRIC-001"));
|
|
await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:metric-1", "GHSA-METRIC-001"));
|
|
});
|
|
|
|
Assert.Equal(2, measurements.Count);
|
|
|
|
var inserted = measurements.FirstOrDefault(measurement =>
|
|
string.Equals(GetTagValue(measurement, "tenant"), "tenant-metrics", StringComparison.Ordinal) &&
|
|
string.Equals(GetTagValue(measurement, "result"), "inserted", StringComparison.Ordinal));
|
|
Assert.NotNull(inserted);
|
|
Assert.Equal(1, inserted!.Value);
|
|
Assert.Equal("osv", GetTagValue(inserted, "source"));
|
|
|
|
var duplicate = measurements.FirstOrDefault(measurement =>
|
|
string.Equals(GetTagValue(measurement, "tenant"), "tenant-metrics", StringComparison.Ordinal) &&
|
|
string.Equals(GetTagValue(measurement, "result"), "duplicate", StringComparison.Ordinal));
|
|
Assert.NotNull(duplicate);
|
|
Assert.Equal(1, duplicate!.Value);
|
|
Assert.Equal("osv", GetTagValue(duplicate, "source"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AocVerifyEndpoint_EmitsVerificationMetric()
|
|
{
|
|
var measurements = await CaptureMetricsAsync(
|
|
IngestionMetrics.MeterName,
|
|
"verify_runs_total",
|
|
async () =>
|
|
{
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-verify-metrics");
|
|
|
|
await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:verify-metric", "GHSA-VERIFY-METRIC"));
|
|
var verifyResponse = await client.PostAsJsonAsync("/aoc/verify", new AocVerifyRequest(null, null, null, null, null));
|
|
verifyResponse.EnsureSuccessStatusCode();
|
|
});
|
|
|
|
var measurement = Assert.Single(measurements);
|
|
Assert.Equal("tenant-verify-metrics", GetTagValue(measurement, "tenant"));
|
|
Assert.Equal("ok", GetTagValue(measurement, "result"));
|
|
Assert.Equal(1, measurement.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdvisoryIngestEndpoint_RejectsCrossTenantWhenAuthenticated()
|
|
{
|
|
IdentityModelEventSource.ShowPII = true;
|
|
var environment = new Dictionary<string, string?>
|
|
{
|
|
["CONCELIER_AUTHORITY__ENABLED"] = "true",
|
|
["CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK"] = "false",
|
|
["CONCELIER_AUTHORITY__ISSUER"] = TestAuthorityIssuer,
|
|
["CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA"] = "false",
|
|
["CONCELIER_AUTHORITY__AUDIENCES__0"] = TestAuthorityAudience,
|
|
["CONCELIER_AUTHORITY__CLIENTID"] = "webservice-tests",
|
|
["CONCELIER_AUTHORITY__CLIENTSECRET"] = "unused",
|
|
};
|
|
|
|
using var factory = new ConcelierApplicationFactory(
|
|
_runner.ConnectionString,
|
|
authority =>
|
|
{
|
|
authority.Enabled = true;
|
|
authority.AllowAnonymousFallback = false;
|
|
authority.Issuer = TestAuthorityIssuer;
|
|
authority.RequireHttpsMetadata = false;
|
|
authority.Audiences.Clear();
|
|
authority.Audiences.Add(TestAuthorityAudience);
|
|
authority.ClientId = "webservice-tests";
|
|
authority.ClientSecret = "unused";
|
|
},
|
|
environment);
|
|
|
|
using var client = factory.CreateClient();
|
|
var schemes = await factory.Services.GetRequiredService<IAuthenticationSchemeProvider>().GetAllSchemesAsync();
|
|
_output.WriteLine("Schemes => " + string.Join(',', schemes.Select(s => s.Name)));
|
|
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 conflicts = payload!.Conflicts ?? throw new XunitException("Conflicts was null");
|
|
var conflict = Assert.Single(conflicts);
|
|
Assert.Equal(conflictId, conflict.ConflictId);
|
|
Assert.Equal("severity", conflict.Explainer.Type);
|
|
Assert.Equal("mismatch", conflict.Explainer.Reason);
|
|
Assert.Equal("CRITICAL", conflict.Explainer.PrimaryValue);
|
|
Assert.Equal("MEDIUM", conflict.Explainer.SuppressedValue);
|
|
Assert.Equal(conflict.Explainer.ComputeHashHex(), conflict.ConflictHash);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MirrorEndpointsServeConfiguredArtifacts()
|
|
{
|
|
using var temp = new TempDirectory();
|
|
var exportId = "20251019T120000Z";
|
|
var exportRoot = Path.Combine(temp.Path, exportId);
|
|
var mirrorRoot = Path.Combine(exportRoot, "mirror");
|
|
var domainRoot = Path.Combine(mirrorRoot, "primary");
|
|
Directory.CreateDirectory(domainRoot);
|
|
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(mirrorRoot, "index.json"),
|
|
"""{"schemaVersion":1,"domains":[]}""");
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(domainRoot, "manifest.json"),
|
|
"""{"domainId":"primary"}""");
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(domainRoot, "bundle.json"),
|
|
"""{"advisories":[]}""");
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(domainRoot, "bundle.json.jws"),
|
|
"test-signature");
|
|
|
|
var environment = new Dictionary<string, string?>
|
|
{
|
|
["CONCELIER_MIRROR__ENABLED"] = "true",
|
|
["CONCELIER_MIRROR__EXPORTROOT"] = temp.Path,
|
|
["CONCELIER_MIRROR__ACTIVEEXPORTID"] = exportId,
|
|
["CONCELIER_MIRROR__DOMAINS__0__ID"] = "primary",
|
|
["CONCELIER_MIRROR__DOMAINS__0__DISPLAYNAME"] = "Primary",
|
|
["CONCELIER_MIRROR__DOMAINS__0__REQUIREAUTHENTICATION"] = "false",
|
|
["CONCELIER_MIRROR__DOMAINS__0__MAXDOWNLOADREQUESTSPERHOUR"] = "5",
|
|
["CONCELIER_MIRROR__MAXINDEXREQUESTSPERHOUR"] = "5"
|
|
};
|
|
|
|
using var factory = new ConcelierApplicationFactory(_runner.ConnectionString, environmentOverrides: environment);
|
|
using var client = factory.CreateClient();
|
|
|
|
var indexResponse = await client.GetAsync("/concelier/exports/index.json");
|
|
Assert.Equal(HttpStatusCode.OK, indexResponse.StatusCode);
|
|
var indexContent = await indexResponse.Content.ReadAsStringAsync();
|
|
Assert.Contains(@"""schemaVersion"":1", indexContent, StringComparison.Ordinal);
|
|
|
|
var manifestResponse = await client.GetAsync("/concelier/exports/mirror/primary/manifest.json");
|
|
Assert.Equal(HttpStatusCode.OK, manifestResponse.StatusCode);
|
|
var manifestContent = await manifestResponse.Content.ReadAsStringAsync();
|
|
Assert.Contains(@"""domainId"":""primary""", manifestContent, StringComparison.Ordinal);
|
|
|
|
var bundleResponse = await client.GetAsync("/concelier/exports/mirror/primary/bundle.json.jws");
|
|
Assert.Equal(HttpStatusCode.OK, bundleResponse.StatusCode);
|
|
var signatureContent = await bundleResponse.Content.ReadAsStringAsync();
|
|
Assert.Equal("test-signature", signatureContent);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MirrorEndpointsEnforceAuthenticationForProtectedDomains()
|
|
{
|
|
using var temp = new TempDirectory();
|
|
var exportId = "20251019T120000Z";
|
|
var secureRoot = Path.Combine(temp.Path, exportId, "mirror", "secure");
|
|
Directory.CreateDirectory(secureRoot);
|
|
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(temp.Path, exportId, "mirror", "index.json"),
|
|
"""{"schemaVersion":1,"domains":[]}""");
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(secureRoot, "manifest.json"),
|
|
"""{"domainId":"secure"}""");
|
|
|
|
var environment = new Dictionary<string, string?>
|
|
{
|
|
["CONCELIER_MIRROR__ENABLED"] = "true",
|
|
["CONCELIER_MIRROR__EXPORTROOT"] = temp.Path,
|
|
["CONCELIER_MIRROR__ACTIVEEXPORTID"] = exportId,
|
|
["CONCELIER_MIRROR__DOMAINS__0__ID"] = "secure",
|
|
["CONCELIER_MIRROR__DOMAINS__0__REQUIREAUTHENTICATION"] = "true",
|
|
["CONCELIER_MIRROR__DOMAINS__0__MAXDOWNLOADREQUESTSPERHOUR"] = "5",
|
|
["CONCELIER_AUTHORITY__ENABLED"] = "true",
|
|
["CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK"] = "false",
|
|
["CONCELIER_AUTHORITY__ISSUER"] = "https://authority.example",
|
|
["CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA"] = "false",
|
|
["CONCELIER_AUTHORITY__AUDIENCES__0"] = "api://concelier",
|
|
["CONCELIER_AUTHORITY__REQUIREDSCOPES__0"] = StellaOpsScopes.ConcelierJobsTrigger,
|
|
["CONCELIER_AUTHORITY__CLIENTID"] = "concelier-jobs",
|
|
["CONCELIER_AUTHORITY__CLIENTSECRET"] = "secret",
|
|
["CONCELIER_AUTHORITY__CLIENTSCOPES__0"] = StellaOpsScopes.ConcelierJobsTrigger
|
|
};
|
|
|
|
using var factory = new ConcelierApplicationFactory(
|
|
_runner.ConnectionString,
|
|
authority =>
|
|
{
|
|
authority.Enabled = true;
|
|
authority.AllowAnonymousFallback = false;
|
|
authority.Issuer = "https://authority.example";
|
|
authority.RequireHttpsMetadata = false;
|
|
authority.Audiences.Clear();
|
|
authority.Audiences.Add("api://concelier");
|
|
authority.RequiredScopes.Clear();
|
|
authority.RequiredScopes.Add(StellaOpsScopes.ConcelierJobsTrigger);
|
|
authority.ClientId = "concelier-jobs";
|
|
authority.ClientSecret = "secret";
|
|
},
|
|
environment);
|
|
|
|
using var client = factory.CreateClient();
|
|
var response = await client.GetAsync("/concelier/exports/mirror/secure/manifest.json");
|
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
|
var authHeader = Assert.Single(response.Headers.WwwAuthenticate);
|
|
Assert.Equal("Bearer", authHeader.Scheme);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MirrorEndpointsRespectRateLimits()
|
|
{
|
|
using var temp = new TempDirectory();
|
|
var exportId = "20251019T130000Z";
|
|
var exportRoot = Path.Combine(temp.Path, exportId);
|
|
var mirrorRoot = Path.Combine(exportRoot, "mirror");
|
|
Directory.CreateDirectory(mirrorRoot);
|
|
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(mirrorRoot, "index.json"),
|
|
"""{\"schemaVersion\":1,\"domains\":[]}"""
|
|
);
|
|
|
|
var environment = new Dictionary<string, string?>
|
|
{
|
|
["CONCELIER_MIRROR__ENABLED"] = "true",
|
|
["CONCELIER_MIRROR__EXPORTROOT"] = temp.Path,
|
|
["CONCELIER_MIRROR__ACTIVEEXPORTID"] = exportId,
|
|
["CONCELIER_MIRROR__MAXINDEXREQUESTSPERHOUR"] = "1",
|
|
["CONCELIER_MIRROR__DOMAINS__0__ID"] = "primary",
|
|
["CONCELIER_MIRROR__DOMAINS__0__REQUIREAUTHENTICATION"] = "false",
|
|
["CONCELIER_MIRROR__DOMAINS__0__MAXDOWNLOADREQUESTSPERHOUR"] = "1"
|
|
};
|
|
|
|
using var factory = new ConcelierApplicationFactory(_runner.ConnectionString, environmentOverrides: environment);
|
|
using var client = factory.CreateClient();
|
|
|
|
var okResponse = await client.GetAsync("/concelier/exports/index.json");
|
|
Assert.Equal(HttpStatusCode.OK, okResponse.StatusCode);
|
|
|
|
var limitedResponse = await client.GetAsync("/concelier/exports/index.json");
|
|
Assert.Equal((HttpStatusCode)429, limitedResponse.StatusCode);
|
|
Assert.NotNull(limitedResponse.Headers.RetryAfter);
|
|
Assert.True(limitedResponse.Headers.RetryAfter!.Delta.HasValue);
|
|
Assert.True(limitedResponse.Headers.RetryAfter!.Delta!.Value.TotalSeconds > 0);
|
|
}
|
|
|
|
[Fact]
|
|
public void MergeModuleDisabledByDefault()
|
|
{
|
|
using var factory = new ConcelierApplicationFactory(
|
|
_runner.ConnectionString,
|
|
authorityConfigure: null,
|
|
environmentOverrides: null);
|
|
using var scope = factory.Services.CreateScope();
|
|
var provider = scope.ServiceProvider;
|
|
|
|
#pragma warning disable CS0618, CONCELIER0001, CONCELIER0002 // Checking deprecated service registration state.
|
|
Assert.Null(provider.GetService<AdvisoryMergeService>());
|
|
#pragma warning restore CS0618, CONCELIER0001, CONCELIER0002
|
|
|
|
var schedulerOptions = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
|
|
Assert.DoesNotContain("merge:reconcile", schedulerOptions.Definitions.Keys);
|
|
}
|
|
|
|
[Fact]
|
|
public void MergeModuleReenabledWhenFeatureFlagCleared()
|
|
{
|
|
var environment = new Dictionary<string, string?>
|
|
{
|
|
["CONCELIER_FEATURES__NOMERGEENABLED"] = "false"
|
|
};
|
|
|
|
using var factory = new ConcelierApplicationFactory(
|
|
_runner.ConnectionString,
|
|
authorityConfigure: null,
|
|
environmentOverrides: environment);
|
|
using var scope = factory.Services.CreateScope();
|
|
var provider = scope.ServiceProvider;
|
|
|
|
#pragma warning disable CS0618, CONCELIER0001, CONCELIER0002 // Checking deprecated service registration state.
|
|
Assert.NotNull(provider.GetService<AdvisoryMergeService>());
|
|
#pragma warning restore CS0618, CONCELIER0001, CONCELIER0002
|
|
|
|
var schedulerOptions = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
|
|
Assert.Contains("merge:reconcile", schedulerOptions.Definitions.Keys);
|
|
}
|
|
|
|
[Fact]
|
|
public void MergeJobRemovedWhenAllowlistExcludes()
|
|
{
|
|
var environment = new Dictionary<string, string?>
|
|
{
|
|
["CONCELIER_FEATURES__NOMERGEENABLED"] = "false",
|
|
["CONCELIER_FEATURES__MERGEJOBALLOWLIST__0"] = "export:json"
|
|
};
|
|
|
|
using var factory = new ConcelierApplicationFactory(
|
|
_runner.ConnectionString,
|
|
authorityConfigure: null,
|
|
environmentOverrides: environment);
|
|
using var scope = factory.Services.CreateScope();
|
|
var provider = scope.ServiceProvider;
|
|
|
|
#pragma warning disable CS0618, CONCELIER0001, CONCELIER0002 // Checking deprecated service registration state.
|
|
Assert.NotNull(provider.GetService<AdvisoryMergeService>());
|
|
#pragma warning restore CS0618, CONCELIER0001, CONCELIER0002
|
|
|
|
var schedulerOptions = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
|
|
Assert.DoesNotContain("merge:reconcile", schedulerOptions.Definitions.Keys);
|
|
}
|
|
|
|
[Fact]
|
|
public void MergeJobRemainsWhenAllowlisted()
|
|
{
|
|
var environment = new Dictionary<string, string?>
|
|
{
|
|
["CONCELIER_FEATURES__NOMERGEENABLED"] = "false",
|
|
["CONCELIER_FEATURES__MERGEJOBALLOWLIST__0"] = "merge:reconcile"
|
|
};
|
|
|
|
using var factory = new ConcelierApplicationFactory(
|
|
_runner.ConnectionString,
|
|
authorityConfigure: null,
|
|
environmentOverrides: environment);
|
|
using var scope = factory.Services.CreateScope();
|
|
var provider = scope.ServiceProvider;
|
|
|
|
#pragma warning disable CS0618, CONCELIER0001, CONCELIER0002 // Checking deprecated service registration state.
|
|
Assert.NotNull(provider.GetService<AdvisoryMergeService>());
|
|
#pragma warning restore CS0618, CONCELIER0001, CONCELIER0002
|
|
|
|
var schedulerOptions = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
|
|
Assert.Contains("merge:reconcile", schedulerOptions.Definitions.Keys);
|
|
}
|
|
|
|
|
|
[Fact]
|
|
public async Task JobsEndpointsAllowBypassWhenAuthorityEnabled()
|
|
{
|
|
var environment = new Dictionary<string, string?>
|
|
{
|
|
["CONCELIER_AUTHORITY__ENABLED"] = "true",
|
|
["CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK"] = "false",
|
|
["CONCELIER_AUTHORITY__ISSUER"] = "https://authority.example",
|
|
["CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA"] = "false",
|
|
["CONCELIER_AUTHORITY__AUDIENCES__0"] = "api://concelier",
|
|
["CONCELIER_AUTHORITY__REQUIREDSCOPES__0"] = StellaOpsScopes.ConcelierJobsTrigger,
|
|
["CONCELIER_AUTHORITY__BYPASSNETWORKS__0"] = "127.0.0.1/32",
|
|
["CONCELIER_AUTHORITY__BYPASSNETWORKS__1"] = "::1/128",
|
|
["CONCELIER_AUTHORITY__CLIENTID"] = "concelier-jobs",
|
|
["CONCELIER_AUTHORITY__CLIENTSECRET"] = "test-secret",
|
|
["CONCELIER_AUTHORITY__CLIENTSCOPES__0"] = StellaOpsScopes.ConcelierJobsTrigger,
|
|
};
|
|
|
|
using var factory = new ConcelierApplicationFactory(
|
|
_runner.ConnectionString,
|
|
authority =>
|
|
{
|
|
authority.Enabled = true;
|
|
authority.AllowAnonymousFallback = false;
|
|
authority.Issuer = "https://authority.example";
|
|
authority.RequireHttpsMetadata = false;
|
|
authority.Audiences.Clear();
|
|
authority.Audiences.Add("api://concelier");
|
|
authority.RequiredScopes.Clear();
|
|
authority.RequiredScopes.Add(StellaOpsScopes.ConcelierJobsTrigger);
|
|
authority.BypassNetworks.Clear();
|
|
authority.BypassNetworks.Add("127.0.0.1/32");
|
|
authority.BypassNetworks.Add("::1/128");
|
|
authority.ClientId = "concelier-jobs";
|
|
authority.ClientSecret = "test-secret";
|
|
},
|
|
environment);
|
|
|
|
var handler = factory.Services.GetRequiredService<StubJobCoordinator>();
|
|
handler.Definitions = new[] { new JobDefinition("demo", typeof(DemoJob), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(1), null, true) };
|
|
|
|
using var client = factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Test-RemoteAddr", "127.0.0.1");
|
|
var response = await client.GetAsync("/jobs/definitions");
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
|
|
var auditLogs = factory.LoggerProvider.Snapshot("Concelier.Authorization.Audit");
|
|
var bypassLog = Assert.Single(auditLogs, entry => entry.TryGetState("Bypass", out var state) && state is bool flag && flag);
|
|
Assert.True(bypassLog.TryGetState("RemoteAddress", out var remoteObj) && string.Equals(remoteObj?.ToString(), "127.0.0.1", StringComparison.Ordinal));
|
|
Assert.True(bypassLog.TryGetState("StatusCode", out var statusObj) && Convert.ToInt32(statusObj) == (int)HttpStatusCode.OK);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task JobsEndpointsRequireAuthWhenFallbackDisabled()
|
|
{
|
|
var enforcementEnvironment = new Dictionary<string, string?>
|
|
{
|
|
["CONCELIER_AUTHORITY__ENABLED"] = "true",
|
|
["CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK"] = "false",
|
|
["CONCELIER_AUTHORITY__ISSUER"] = "https://authority.example",
|
|
["CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA"] = "false",
|
|
["CONCELIER_AUTHORITY__AUDIENCES__0"] = "api://concelier",
|
|
["CONCELIER_AUTHORITY__REQUIREDSCOPES__0"] = StellaOpsScopes.ConcelierJobsTrigger,
|
|
["CONCELIER_AUTHORITY__CLIENTID"] = "concelier-jobs",
|
|
["CONCELIER_AUTHORITY__CLIENTSECRET"] = "test-secret",
|
|
["CONCELIER_AUTHORITY__CLIENTSCOPES__0"] = StellaOpsScopes.ConcelierJobsTrigger,
|
|
};
|
|
|
|
using var factory = new ConcelierApplicationFactory(
|
|
_runner.ConnectionString,
|
|
authority =>
|
|
{
|
|
authority.Enabled = true;
|
|
authority.AllowAnonymousFallback = false;
|
|
authority.Issuer = "https://authority.example";
|
|
authority.RequireHttpsMetadata = false;
|
|
authority.Audiences.Clear();
|
|
authority.Audiences.Add("api://concelier");
|
|
authority.RequiredScopes.Clear();
|
|
authority.RequiredScopes.Add(StellaOpsScopes.ConcelierJobsTrigger);
|
|
authority.BypassNetworks.Clear();
|
|
authority.ClientId = "concelier-jobs";
|
|
authority.ClientSecret = "test-secret";
|
|
},
|
|
enforcementEnvironment);
|
|
|
|
var resolved = factory.Services.GetRequiredService<IOptions<ConcelierOptions>>().Value;
|
|
Assert.False(resolved.Authority.AllowAnonymousFallback);
|
|
|
|
using var client = factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Test-RemoteAddr", "127.0.0.1");
|
|
var response = await client.GetAsync("/jobs/definitions");
|
|
|
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
|
|
|
var auditLogs = factory.LoggerProvider.Snapshot("Concelier.Authorization.Audit");
|
|
var enforcementLog = Assert.Single(auditLogs);
|
|
Assert.True(enforcementLog.TryGetState("BypassAllowed", out var bypassAllowedObj) && bypassAllowedObj is bool bypassAllowed && bypassAllowed == false);
|
|
Assert.True(enforcementLog.TryGetState("HasPrincipal", out var principalObj) && principalObj is bool hasPrincipal && hasPrincipal == false);
|
|
}
|
|
|
|
[Fact]
|
|
public void AuthorityClientResilienceOptionsAreBound()
|
|
{
|
|
var environment = new Dictionary<string, string?>
|
|
{
|
|
["CONCELIER_AUTHORITY__ENABLED"] = "true",
|
|
["CONCELIER_AUTHORITY__ISSUER"] = "https://authority.example",
|
|
["CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA"] = "false",
|
|
["CONCELIER_AUTHORITY__AUDIENCES__0"] = "api://concelier",
|
|
["CONCELIER_AUTHORITY__REQUIREDSCOPES__0"] = StellaOpsScopes.ConcelierJobsTrigger,
|
|
["CONCELIER_AUTHORITY__CLIENTSCOPES__0"] = StellaOpsScopes.ConcelierJobsTrigger,
|
|
["CONCELIER_AUTHORITY__BACKCHANNELTIMEOUTSECONDS"] = "45",
|
|
["CONCELIER_AUTHORITY__RESILIENCE__ENABLERETRIES"] = "true",
|
|
["CONCELIER_AUTHORITY__RESILIENCE__RETRYDELAYS__0"] = "00:00:02",
|
|
["CONCELIER_AUTHORITY__RESILIENCE__RETRYDELAYS__1"] = "00:00:04",
|
|
["CONCELIER_AUTHORITY__RESILIENCE__ALLOWOFFLINECACHEFALLBACK"] = "false",
|
|
["CONCELIER_AUTHORITY__RESILIENCE__OFFLINECACHETOLERANCE"] = "00:02:30"
|
|
};
|
|
|
|
using var factory = new ConcelierApplicationFactory(
|
|
_runner.ConnectionString,
|
|
authority =>
|
|
{
|
|
authority.Enabled = true;
|
|
authority.Issuer = "https://authority.example";
|
|
authority.RequireHttpsMetadata = false;
|
|
authority.Audiences.Clear();
|
|
authority.Audiences.Add("api://concelier");
|
|
authority.RequiredScopes.Clear();
|
|
authority.RequiredScopes.Add(StellaOpsScopes.ConcelierJobsTrigger);
|
|
authority.ClientScopes.Clear();
|
|
authority.ClientScopes.Add(StellaOpsScopes.ConcelierJobsTrigger);
|
|
authority.BackchannelTimeoutSeconds = 45;
|
|
},
|
|
environment);
|
|
|
|
var monitor = factory.Services.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>();
|
|
var options = monitor.CurrentValue;
|
|
|
|
Assert.Equal("https://authority.example", options.Authority);
|
|
Assert.Equal(TimeSpan.FromSeconds(45), options.HttpTimeout);
|
|
Assert.Equal(new[] { StellaOpsScopes.ConcelierJobsTrigger }, options.NormalizedScopes);
|
|
Assert.Equal(new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4) }, options.NormalizedRetryDelays);
|
|
Assert.False(options.AllowOfflineCacheFallback);
|
|
Assert.Equal(TimeSpan.FromSeconds(150), options.OfflineCacheTolerance);
|
|
}
|
|
|
|
private async Task SeedObservationDocumentsAsync(IEnumerable<AdvisoryObservationDocument> documents)
|
|
{
|
|
var client = new InMemoryClient(_runner.ConnectionString);
|
|
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
|
|
|
|
try
|
|
{
|
|
await database.DropCollectionAsync(StorageDefaults.Collections.AdvisoryObservations);
|
|
}
|
|
catch (StorageCommandException ex) when (ex.CodeName == "NamespaceNotFound" || ex.Message.Contains("ns not found", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// Collection does not exist yet; ignore.
|
|
}
|
|
|
|
// Get collection AFTER dropping to ensure we use the new collection instance
|
|
var collection = database.GetCollection<AdvisoryObservationDocument>(StorageDefaults.Collections.AdvisoryObservations);
|
|
|
|
var snapshot = documents?.ToArray() ?? Array.Empty<AdvisoryObservationDocument>();
|
|
if (snapshot.Length == 0)
|
|
{
|
|
await collection.InsertManyAsync(snapshot);
|
|
return;
|
|
}
|
|
|
|
await collection.InsertManyAsync(snapshot);
|
|
|
|
var rawDocuments = snapshot
|
|
.Select(doc => CreateAdvisoryRawDocument(
|
|
doc.Tenant,
|
|
doc.Source.Vendor,
|
|
doc.Id,
|
|
doc.Upstream.ContentHash,
|
|
doc.Content.Raw.DeepClone().AsDocumentObject))
|
|
.ToArray();
|
|
|
|
await SeedAdvisoryRawDocumentsAsync(rawDocuments);
|
|
}
|
|
|
|
private async Task SeedLinksetDocumentsAsync(IEnumerable<AdvisoryLinksetDocument> documents)
|
|
{
|
|
var client = new InMemoryClient(_runner.ConnectionString);
|
|
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
|
|
|
|
try
|
|
{
|
|
await database.DropCollectionAsync(StorageDefaults.Collections.AdvisoryLinksets);
|
|
}
|
|
catch (StorageCommandException ex) when (ex.CodeName == "NamespaceNotFound" || ex.Message.Contains("ns not found", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// Collection not created yet; safe to ignore.
|
|
}
|
|
|
|
// Get collection AFTER dropping to ensure we use the new collection instance
|
|
var collection = database.GetCollection<AdvisoryLinksetDocument>(StorageDefaults.Collections.AdvisoryLinksets);
|
|
|
|
var snapshot = documents?.ToArray() ?? Array.Empty<AdvisoryLinksetDocument>();
|
|
if (snapshot.Length > 0)
|
|
{
|
|
await collection.InsertManyAsync(snapshot);
|
|
}
|
|
}
|
|
|
|
private static AdvisoryLinksetDocument CreateLinksetDocument(
|
|
string tenant,
|
|
string source,
|
|
string advisoryId,
|
|
IEnumerable<string> observationIds,
|
|
IEnumerable<string> purls,
|
|
IEnumerable<string> versions,
|
|
DateTime createdAtUtc)
|
|
{
|
|
return new AdvisoryLinksetDocument
|
|
{
|
|
TenantId = tenant,
|
|
Source = source,
|
|
AdvisoryId = advisoryId,
|
|
Observations = observationIds.ToList(),
|
|
CreatedAt = DateTime.SpecifyKind(createdAtUtc, DateTimeKind.Utc),
|
|
Normalized = new AdvisoryLinksetNormalizedDocument
|
|
{
|
|
Purls = purls.ToList(),
|
|
Versions = versions.ToList()
|
|
}
|
|
};
|
|
}
|
|
|
|
private static AdvisoryObservationDocument[] BuildSampleObservationDocuments()
|
|
{
|
|
return new[]
|
|
{
|
|
CreateObservationDocument(
|
|
id: "tenant-a:nvd:alpha:1",
|
|
tenant: "tenant-a",
|
|
createdAt: new DateTime(2025, 1, 5, 0, 0, 0, DateTimeKind.Utc),
|
|
aliases: new[] { "cve-2025-0001" },
|
|
purls: new[] { "pkg:npm/demo@1.0.0" },
|
|
cpes: new[] { "cpe:/a:vendor:product:1.0" },
|
|
references: new[] { ("advisory", "https://example.test/advisory-1") }),
|
|
CreateObservationDocument(
|
|
id: "tenant-a:ghsa:beta:1",
|
|
tenant: "tenant-a",
|
|
createdAt: new DateTime(2025, 1, 6, 0, 0, 0, DateTimeKind.Utc),
|
|
aliases: new[] { "ghsa-2025-xyz", "cve-2025-0001" },
|
|
purls: new[] { "pkg:npm/demo@1.1.0" },
|
|
cpes: new[] { "cpe:/a:vendor:product:1.1" },
|
|
references: new[] { ("patch", "https://example.test/patch-1") }),
|
|
CreateObservationDocument(
|
|
id: "tenant-b:nvd:alpha:1",
|
|
tenant: "tenant-b",
|
|
createdAt: new DateTime(2025, 1, 7, 0, 0, 0, DateTimeKind.Utc),
|
|
aliases: new[] { "cve-2025-0001" },
|
|
purls: new[] { "pkg:npm/demo@2.0.0" },
|
|
cpes: new[] { "cpe:/a:vendor:product:2.0" },
|
|
references: new[] { ("advisory", "https://example.test/advisory-2") })
|
|
};
|
|
}
|
|
|
|
private static AdvisoryObservationDocument CreateObservationDocument(
|
|
string id,
|
|
string tenant,
|
|
DateTime createdAt,
|
|
IEnumerable<string>? aliases = null,
|
|
IEnumerable<string>? purls = null,
|
|
IEnumerable<string>? cpes = null,
|
|
IEnumerable<(string Type, string Url)>? references = null)
|
|
{
|
|
return new AdvisoryObservationDocument
|
|
{
|
|
Id = id,
|
|
Tenant = tenant.ToLowerInvariant(),
|
|
CreatedAt = createdAt,
|
|
Source = new AdvisoryObservationSourceDocument
|
|
{
|
|
Vendor = "nvd",
|
|
Stream = "feed",
|
|
Api = "https://example.test/api"
|
|
},
|
|
Upstream = new AdvisoryObservationUpstreamDocument
|
|
{
|
|
UpstreamId = id,
|
|
DocumentVersion = null,
|
|
FetchedAt = createdAt,
|
|
ReceivedAt = createdAt,
|
|
ContentHash = $"sha256:{id}",
|
|
Signature = new AdvisoryObservationSignatureDocument
|
|
{
|
|
Present = false
|
|
},
|
|
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
|
},
|
|
Content = new AdvisoryObservationContentDocument
|
|
{
|
|
Format = "csaf",
|
|
SpecVersion = "2.0",
|
|
Raw = DocumentObject.Parse("""{"observation":"%ID%"}""".Replace("%ID%", id)),
|
|
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
|
},
|
|
Linkset = new AdvisoryObservationLinksetDocument
|
|
{
|
|
Aliases = aliases?.Where(value => value is not null).ToList(),
|
|
Purls = purls?.Where(value => value is not null).ToList(),
|
|
Cpes = cpes?.Where(value => value is not null).ToList(),
|
|
References = references is null
|
|
? new List<AdvisoryObservationReferenceDocument>()
|
|
: references
|
|
.Select(reference => new AdvisoryObservationReferenceDocument
|
|
{
|
|
Type = reference.Type,
|
|
Url = reference.Url
|
|
})
|
|
.ToList()
|
|
},
|
|
Attributes = new Dictionary<string, string>(StringComparer.Ordinal)
|
|
};
|
|
}
|
|
|
|
private static AdvisoryObservationDocument CreateChunkObservationDocument(
|
|
string id,
|
|
string tenant,
|
|
DateTime createdAt,
|
|
string alias,
|
|
DocumentObject rawDocument)
|
|
{
|
|
var document = CreateObservationDocument(
|
|
id,
|
|
tenant,
|
|
createdAt,
|
|
aliases: new[] { alias });
|
|
var clone = rawDocument.DeepClone().AsDocumentObject;
|
|
document.Content.Raw = clone;
|
|
document.Upstream.ContentHash = ComputeContentHash(clone);
|
|
return document;
|
|
}
|
|
|
|
private static readonly DateTimeOffset DefaultIngestTimestamp = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
private static readonly ICryptoHash Hash = CryptoHashFactory.CreateDefault();
|
|
|
|
private static string ComputeContentHash(DocumentObject rawDocument)
|
|
{
|
|
var canonical = rawDocument.ToJson(new JsonWriterSettings
|
|
{
|
|
OutputMode = JsonOutputMode.RelaxedExtendedJson
|
|
});
|
|
var digest = Hash.ComputeHashHex(Encoding.UTF8.GetBytes(canonical), HashAlgorithms.Sha256);
|
|
return $"sha256:{digest}";
|
|
}
|
|
|
|
private static string ComputeDeterministicContentHash(string upstreamId)
|
|
{
|
|
var raw = CreateJsonElement($@"{{""id"":""{upstreamId}"",""modified"":""{DefaultIngestTimestamp:O}""}}");
|
|
return NormalizeContentHash(null, raw, enforceContentHash: true);
|
|
}
|
|
|
|
private static string NormalizeContentHash(string? value, JsonElement raw, bool enforceContentHash)
|
|
{
|
|
if (!enforceContentHash)
|
|
{
|
|
return value ?? string.Empty;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return value.Trim();
|
|
}
|
|
|
|
var digest = Hash.ComputeHashHex(Encoding.UTF8.GetBytes(raw.GetRawText()), HashAlgorithms.Sha256);
|
|
return $"sha256:{digest}";
|
|
}
|
|
|
|
private sealed record ReplayResponse(
|
|
string VulnerabilityKey,
|
|
DateTimeOffset? AsOf,
|
|
List<ReplayStatement> Statements,
|
|
List<ReplayConflict>? Conflicts);
|
|
|
|
private sealed record ReplayStatement(
|
|
Guid StatementId,
|
|
string VulnerabilityKey,
|
|
string AdvisoryKey,
|
|
Advisory Advisory,
|
|
string StatementHash,
|
|
DateTimeOffset AsOf,
|
|
DateTimeOffset RecordedAt,
|
|
IReadOnlyList<Guid> InputDocumentIds);
|
|
|
|
private sealed record ReplayConflict(
|
|
Guid ConflictId,
|
|
string VulnerabilityKey,
|
|
IReadOnlyList<Guid> StatementIds,
|
|
string ConflictHash,
|
|
DateTimeOffset AsOf,
|
|
DateTimeOffset RecordedAt,
|
|
string Details,
|
|
MergeConflictExplainerPayload Explainer);
|
|
|
|
private sealed class ConcelierApplicationFactory : WebApplicationFactory<Program>
|
|
{
|
|
private readonly string _connectionString;
|
|
private readonly string? _previousPgDsn;
|
|
private readonly string? _previousPgEnabled;
|
|
private readonly string? _previousPgTimeout;
|
|
private readonly string? _previousPgSchema;
|
|
private readonly string? _previousTelemetryEnabled;
|
|
private readonly string? _previousTelemetryLogging;
|
|
private readonly string? _previousTelemetryTracing;
|
|
private readonly string? _previousTelemetryMetrics;
|
|
private readonly Action<ConcelierOptions.AuthorityOptions>? _authorityConfigure;
|
|
private readonly IDictionary<string, string?> _additionalPreviousEnvironment = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
|
public CollectingLoggerProvider LoggerProvider { get; } = new();
|
|
|
|
public ConcelierApplicationFactory(
|
|
string connectionString,
|
|
Action<ConcelierOptions.AuthorityOptions>? authorityConfigure = null,
|
|
IDictionary<string, string?>? environmentOverrides = null)
|
|
{
|
|
var defaultPostgresDsn = "Host=localhost;Port=5432;Database=concelier_test;Username=postgres;Password=postgres";
|
|
_connectionString = string.IsNullOrWhiteSpace(connectionString)
|
|
? defaultPostgresDsn
|
|
: connectionString;
|
|
_authorityConfigure = authorityConfigure;
|
|
_previousPgDsn = Environment.GetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__CONNECTIONSTRING");
|
|
_previousPgEnabled = Environment.GetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__ENABLED");
|
|
_previousPgTimeout = Environment.GetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__COMMANDTIMEOUTSECONDS");
|
|
_previousPgSchema = Environment.GetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__SCHEMANAME");
|
|
_previousTelemetryEnabled = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLED");
|
|
_previousTelemetryLogging = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING");
|
|
_previousTelemetryTracing = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING");
|
|
_previousTelemetryMetrics = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS");
|
|
|
|
var opensslPath = ResolveOpenSsl11Path();
|
|
if (!string.IsNullOrEmpty(opensslPath))
|
|
{
|
|
var currentLd = Environment.GetEnvironmentVariable("LD_LIBRARY_PATH");
|
|
var merged = string.IsNullOrWhiteSpace(currentLd)
|
|
? opensslPath
|
|
: string.Join(':', opensslPath, currentLd);
|
|
Environment.SetEnvironmentVariable("LD_LIBRARY_PATH", merged);
|
|
}
|
|
|
|
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__CONNECTIONSTRING", _connectionString);
|
|
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__ENABLED", "true");
|
|
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__COMMANDTIMEOUTSECONDS", "30");
|
|
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__SCHEMANAME", "vuln");
|
|
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLED", "false");
|
|
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING", "false");
|
|
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING", "false");
|
|
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS", "false");
|
|
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
|
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
|
|
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
|
|
const string EvidenceRootKey = "CONCELIER_EVIDENCE__ROOT";
|
|
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
|
|
_additionalPreviousEnvironment[EvidenceRootKey] = Environment.GetEnvironmentVariable(EvidenceRootKey);
|
|
Environment.SetEnvironmentVariable(EvidenceRootKey, repoRoot);
|
|
const string TestSecretKey = "CONCELIER_AUTHORITY__TESTSIGNINGSECRET";
|
|
if (environmentOverrides is null || !environmentOverrides.ContainsKey(TestSecretKey))
|
|
{
|
|
var previousSecret = Environment.GetEnvironmentVariable(TestSecretKey);
|
|
_additionalPreviousEnvironment[TestSecretKey] = previousSecret;
|
|
Environment.SetEnvironmentVariable(TestSecretKey, TestSigningSecret);
|
|
}
|
|
if (environmentOverrides is not null)
|
|
{
|
|
foreach (var kvp in environmentOverrides)
|
|
{
|
|
var previous = Environment.GetEnvironmentVariable(kvp.Key);
|
|
_additionalPreviousEnvironment[kvp.Key] = previous;
|
|
Environment.SetEnvironmentVariable(kvp.Key, kvp.Value);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string? ResolveOpenSsl11Path()
|
|
{
|
|
var current = AppContext.BaseDirectory;
|
|
for (var i = 0; i < 8; i++)
|
|
{
|
|
var candidate = Path.GetFullPath(Path.Combine(current, "tests", "native", "openssl-1.1", "linux-x64"));
|
|
if (Directory.Exists(candidate))
|
|
{
|
|
return candidate;
|
|
}
|
|
|
|
current = Path.GetFullPath(Path.Combine(current, ".."));
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
|
{
|
|
builder.UseEnvironment("Testing");
|
|
|
|
builder.ConfigureAppConfiguration((context, configurationBuilder) =>
|
|
{
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
["Plugins:Directory"] = Path.GetFullPath(Path.Combine(context.HostingEnvironment.ContentRootPath, "..", "..", "..", "plugins", "concelier")),
|
|
};
|
|
|
|
configurationBuilder.AddInMemoryCollection(settings!);
|
|
});
|
|
|
|
builder.ConfigureLogging(logging =>
|
|
{
|
|
logging.AddProvider(LoggerProvider);
|
|
});
|
|
|
|
builder.ConfigureServices(services =>
|
|
{
|
|
// Remove ConcelierDataSource to skip Postgres initialization during tests
|
|
// This allows tests to run without a real database connection
|
|
services.RemoveAll<ConcelierDataSource>();
|
|
|
|
services.AddSingleton<ILeaseStore, Fixtures.TestLeaseStore>();
|
|
services.AddSingleton<StubJobCoordinator>();
|
|
services.AddSingleton<IJobCoordinator>(sp => sp.GetRequiredService<StubJobCoordinator>());
|
|
|
|
// Register in-memory lookups that query the shared in-memory database
|
|
services.RemoveAll<IAdvisoryRawService>();
|
|
services.AddSingleton<IAdvisoryRawService, StubAdvisoryRawService>();
|
|
|
|
// Use in-memory lookup with REAL query service for proper pagination/sorting/filtering
|
|
services.RemoveAll<IAdvisoryObservationLookup>();
|
|
services.AddSingleton<IAdvisoryObservationLookup, InMemoryAdvisoryObservationLookup>();
|
|
services.RemoveAll<IAdvisoryObservationQueryService>();
|
|
services.AddSingleton<IAdvisoryObservationQueryService, AdvisoryObservationQueryService>();
|
|
|
|
// Register stubs for storage and event log services
|
|
services.RemoveAll<IStorageDatabase>();
|
|
services.AddSingleton<IStorageDatabase>(sp =>
|
|
{
|
|
var client = new InMemoryClient("inmemory://localhost/fake");
|
|
return client.GetDatabase(StorageDefaults.DefaultDatabaseName);
|
|
});
|
|
services.RemoveAll<IAdvisoryStore>();
|
|
services.AddSingleton<IAdvisoryStore, StubAdvisoryStore>();
|
|
services.RemoveAll<IAdvisoryEventLog>();
|
|
services.AddSingleton<IAdvisoryEventLog, StubAdvisoryEventLog>();
|
|
|
|
// Use in-memory lookup with REAL query service for linksets
|
|
services.RemoveAll<IAdvisoryLinksetLookup>();
|
|
services.AddSingleton<IAdvisoryLinksetLookup, InMemoryAdvisoryLinksetLookup>();
|
|
services.RemoveAll<IAdvisoryLinksetQueryService>();
|
|
services.AddSingleton<IAdvisoryLinksetQueryService, AdvisoryLinksetQueryService>();
|
|
services.RemoveAll<IAdvisoryLinksetStore>();
|
|
services.AddSingleton<IAdvisoryLinksetStore, InMemoryAdvisoryLinksetStore>();
|
|
|
|
services.PostConfigure<ConcelierOptions>(options =>
|
|
{
|
|
options.PostgresStorage ??= new ConcelierOptions.PostgresStorageOptions();
|
|
options.PostgresStorage.ConnectionString = _connectionString;
|
|
options.PostgresStorage.CommandTimeoutSeconds = 30;
|
|
options.Plugins.Directory ??= Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "..", "plugins", "concelier"));
|
|
options.Telemetry.Enabled = false;
|
|
options.Telemetry.EnableLogging = false;
|
|
options.Telemetry.EnableTracing = false;
|
|
options.Telemetry.EnableMetrics = false;
|
|
options.Authority ??= new ConcelierOptions.AuthorityOptions();
|
|
_authorityConfigure?.Invoke(options.Authority);
|
|
|
|
// Point evidence root at the repo so sample bundles under docs/samples/evidence-bundle resolve without 400.
|
|
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
|
|
options.Evidence.Root = repoRoot;
|
|
options.Evidence.RootAbsolute = repoRoot;
|
|
});
|
|
|
|
// Ensure content root + wwwroot exist so host startup does not throw when WebService bin output isn't present.
|
|
var contentRoot = AppContext.BaseDirectory;
|
|
var wwwroot = Path.Combine(contentRoot, "wwwroot");
|
|
Directory.CreateDirectory(wwwroot);
|
|
});
|
|
|
|
builder.ConfigureTestServices(services =>
|
|
{
|
|
services.AddSingleton<IStartupFilter, RemoteIpStartupFilter>();
|
|
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_POSTGRESSTORAGE__CONNECTIONSTRING", _previousPgDsn);
|
|
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__ENABLED", _previousPgEnabled);
|
|
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__COMMANDTIMEOUTSECONDS", _previousPgTimeout);
|
|
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__SCHEMANAME", _previousPgSchema);
|
|
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", null);
|
|
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLED", _previousTelemetryEnabled);
|
|
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING", _previousTelemetryLogging);
|
|
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING", _previousTelemetryTracing);
|
|
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS", _previousTelemetryMetrics);
|
|
foreach (var kvp in _additionalPreviousEnvironment)
|
|
{
|
|
Environment.SetEnvironmentVariable(kvp.Key, kvp.Value);
|
|
}
|
|
|
|
LoggerProvider.Dispose();
|
|
}
|
|
|
|
private sealed class RemoteIpStartupFilter : IStartupFilter
|
|
{
|
|
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
|
|
{
|
|
return app =>
|
|
{
|
|
app.Use(async (context, nextMiddleware) =>
|
|
{
|
|
if (context.Request.Headers.TryGetValue("X-Test-RemoteAddr", out var values)
|
|
&& values.Count > 0
|
|
&& IPAddress.TryParse(values[0], out var remote))
|
|
{
|
|
context.Connection.RemoteIpAddress = remote;
|
|
}
|
|
|
|
await nextMiddleware();
|
|
});
|
|
|
|
next(app);
|
|
};
|
|
}
|
|
}
|
|
|
|
public sealed record LogEntry(
|
|
string LoggerName,
|
|
LogLevel Level,
|
|
EventId EventId,
|
|
string? Message,
|
|
Exception? Exception,
|
|
IReadOnlyList<KeyValuePair<string, object?>> State)
|
|
{
|
|
public bool TryGetState(string name, out object? value)
|
|
{
|
|
foreach (var kvp in State)
|
|
{
|
|
if (string.Equals(kvp.Key, name, StringComparison.Ordinal))
|
|
{
|
|
value = kvp.Value;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
value = null;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public sealed class CollectingLoggerProvider : ILoggerProvider
|
|
{
|
|
private readonly object syncRoot = new();
|
|
private readonly List<LogEntry> entries = new();
|
|
private bool disposed;
|
|
|
|
public ILogger CreateLogger(string categoryName) => new CollectingLogger(categoryName, this);
|
|
|
|
public IReadOnlyList<LogEntry> Snapshot(string loggerName)
|
|
{
|
|
lock (syncRoot)
|
|
{
|
|
return entries
|
|
.Where(entry => string.Equals(entry.LoggerName, loggerName, StringComparison.Ordinal))
|
|
.ToArray();
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
disposed = true;
|
|
lock (syncRoot)
|
|
{
|
|
entries.Clear();
|
|
}
|
|
}
|
|
|
|
private void Append(LogEntry entry)
|
|
{
|
|
if (disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (syncRoot)
|
|
{
|
|
entries.Add(entry);
|
|
}
|
|
}
|
|
|
|
private sealed class CollectingLogger : ILogger
|
|
{
|
|
private readonly string categoryName;
|
|
private readonly CollectingLoggerProvider provider;
|
|
|
|
public CollectingLogger(string categoryName, CollectingLoggerProvider provider)
|
|
{
|
|
this.categoryName = categoryName;
|
|
this.provider = provider;
|
|
}
|
|
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
|
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
|
{
|
|
if (formatter is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(formatter));
|
|
}
|
|
|
|
var message = formatter(state, exception);
|
|
var kvps = ExtractState(state);
|
|
var entry = new LogEntry(categoryName, logLevel, eventId, message, exception, kvps);
|
|
provider.Append(entry);
|
|
}
|
|
|
|
private static IReadOnlyList<KeyValuePair<string, object?>> ExtractState<TState>(TState state)
|
|
{
|
|
if (state is IReadOnlyList<KeyValuePair<string, object?>> list)
|
|
{
|
|
return list;
|
|
}
|
|
|
|
if (state is IEnumerable<KeyValuePair<string, object?>> enumerable)
|
|
{
|
|
return enumerable.ToArray();
|
|
}
|
|
|
|
if (state is null)
|
|
{
|
|
return Array.Empty<KeyValuePair<string, object?>>();
|
|
}
|
|
|
|
return new[] { new KeyValuePair<string, object?>("State", state) };
|
|
}
|
|
}
|
|
|
|
private sealed class NullScope : IDisposable
|
|
{
|
|
public static readonly NullScope Instance = new();
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class StubAdvisoryRawService : IAdvisoryRawService
|
|
{
|
|
public Task<AdvisoryRawUpsertResult> IngestAsync(AdvisoryRawDocument document, CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var record = new AdvisoryRawRecord(Guid.NewGuid().ToString("D"), document, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch);
|
|
return Task.FromResult(new AdvisoryRawUpsertResult(true, record));
|
|
}
|
|
|
|
public Task<AdvisoryRawRecord?> FindByIdAsync(string tenant, string id, CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
return Task.FromResult<AdvisoryRawRecord?>(null);
|
|
}
|
|
|
|
public Task<AdvisoryRawQueryResult> QueryAsync(AdvisoryRawQueryOptions options, CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
return Task.FromResult(new AdvisoryRawQueryResult(Array.Empty<AdvisoryRawRecord>(), null, false));
|
|
}
|
|
|
|
public Task<IReadOnlyList<AdvisoryRawRecord>> FindByAdvisoryKeyAsync(
|
|
string tenant,
|
|
string advisoryKey,
|
|
IReadOnlyCollection<string> sourceVendors,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
return Task.FromResult<IReadOnlyList<AdvisoryRawRecord>>(Array.Empty<AdvisoryRawRecord>());
|
|
}
|
|
|
|
public Task<AdvisoryRawVerificationResult> VerifyAsync(AdvisoryRawVerificationRequest request, CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
return Task.FromResult(new AdvisoryRawVerificationResult(
|
|
request.Tenant,
|
|
request.Since,
|
|
request.Until,
|
|
0,
|
|
Array.Empty<AdvisoryRawVerificationViolation>(),
|
|
false));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// In-memory implementation of IAdvisoryObservationLookup that queries the shared in-memory database.
|
|
/// Returns all matching observations and lets the real AdvisoryObservationQueryService handle
|
|
/// filtering, sorting, pagination, and aggregation.
|
|
/// </summary>
|
|
private sealed class InMemoryAdvisoryObservationLookup : IAdvisoryObservationLookup
|
|
{
|
|
public async ValueTask<IReadOnlyList<AdvisoryObservation>> ListByTenantAsync(
|
|
string tenant,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var docs = await GetAllDocumentsAsync(cancellationToken);
|
|
|
|
return docs
|
|
.Where(d => string.Equals(d.Tenant, tenant, StringComparison.OrdinalIgnoreCase))
|
|
.Select(MapToObservation)
|
|
.ToList();
|
|
}
|
|
|
|
public async ValueTask<IReadOnlyList<AdvisoryObservation>> FindByFiltersAsync(
|
|
string tenant,
|
|
IReadOnlyCollection<string> observationIds,
|
|
IReadOnlyCollection<string> aliases,
|
|
IReadOnlyCollection<string> purls,
|
|
IReadOnlyCollection<string> cpes,
|
|
AdvisoryObservationCursor? cursor,
|
|
int limit,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var docs = await GetAllDocumentsAsync(cancellationToken);
|
|
|
|
// Filter by tenant
|
|
var observations = docs
|
|
.Where(d => string.Equals(d.Tenant, tenant, StringComparison.OrdinalIgnoreCase))
|
|
.Select(MapToObservation)
|
|
.ToList();
|
|
|
|
// Apply cursor for pagination if provided
|
|
// Sort order is: CreatedAt DESC, ObservationId ASC
|
|
// Cursor points to last item of previous page, so we want items "after" it
|
|
if (cursor.HasValue)
|
|
{
|
|
var cursorCreatedAt = cursor.Value.CreatedAt;
|
|
var cursorObsId = cursor.Value.ObservationId;
|
|
observations = observations
|
|
.Where(obs => IsBeyondCursor(obs, cursorCreatedAt, cursorObsId))
|
|
.ToList();
|
|
}
|
|
|
|
return observations;
|
|
}
|
|
|
|
private static bool IsBeyondCursor(AdvisoryObservation obs, DateTimeOffset cursorCreatedAt, string cursorObsId)
|
|
{
|
|
// For DESC CreatedAt, ASC ObservationId sorting:
|
|
// Return true if this observation should appear AFTER the cursor position
|
|
// "After" means: older (smaller CreatedAt), or same time but later in alpha order
|
|
if (obs.CreatedAt < cursorCreatedAt)
|
|
{
|
|
return true;
|
|
}
|
|
if (obs.CreatedAt == cursorCreatedAt &&
|
|
string.Compare(obs.ObservationId, cursorObsId, StringComparison.Ordinal) > 0)
|
|
{
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static async Task<List<AdvisoryObservationDocument>> GetAllDocumentsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var client = new InMemoryClient("inmemory://localhost/fake");
|
|
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
|
|
var collection = database.GetCollection<AdvisoryObservationDocument>(StorageDefaults.Collections.AdvisoryObservations);
|
|
|
|
var cursor = await collection.FindAsync(FilterDefinition<AdvisoryObservationDocument>.Empty, null, cancellationToken);
|
|
var docs = new List<AdvisoryObservationDocument>();
|
|
while (await cursor.MoveNextAsync(cancellationToken))
|
|
{
|
|
docs.AddRange(cursor.Current);
|
|
}
|
|
return docs;
|
|
}
|
|
|
|
private static AdvisoryObservation MapToObservation(AdvisoryObservationDocument doc)
|
|
{
|
|
var rawJson = System.Text.Json.JsonSerializer.SerializeToNode(doc.Content.Raw) ?? System.Text.Json.Nodes.JsonNode.Parse("{}")!;
|
|
|
|
var linkset = new AdvisoryObservationLinkset(
|
|
doc.Linkset.Aliases,
|
|
doc.Linkset.Purls,
|
|
doc.Linkset.Cpes,
|
|
doc.Linkset.References?.Select(r => new AdvisoryObservationReference(r.Type, r.Url)));
|
|
|
|
var rawLinkset = new RawLinkset
|
|
{
|
|
Aliases = doc.Linkset.Aliases?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
|
|
PackageUrls = doc.Linkset.Purls?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
|
|
Cpes = doc.Linkset.Cpes?.ToImmutableArray() ?? ImmutableArray<string>.Empty
|
|
};
|
|
|
|
return new AdvisoryObservation(
|
|
doc.Id,
|
|
doc.Tenant,
|
|
new AdvisoryObservationSource(doc.Source.Vendor, doc.Source.Stream, doc.Source.Api),
|
|
new AdvisoryObservationUpstream(
|
|
doc.Upstream.UpstreamId,
|
|
doc.Upstream.DocumentVersion,
|
|
new DateTimeOffset(doc.Upstream.FetchedAt, TimeSpan.Zero),
|
|
new DateTimeOffset(doc.Upstream.ReceivedAt, TimeSpan.Zero),
|
|
doc.Upstream.ContentHash,
|
|
new AdvisoryObservationSignature(
|
|
doc.Upstream.Signature.Present,
|
|
doc.Upstream.Signature.Format,
|
|
doc.Upstream.Signature.KeyId,
|
|
doc.Upstream.Signature.Signature),
|
|
doc.Upstream.Metadata.ToImmutableDictionary()),
|
|
new AdvisoryObservationContent(
|
|
doc.Content.Format,
|
|
doc.Content.SpecVersion,
|
|
rawJson,
|
|
doc.Content.Metadata.ToImmutableDictionary()),
|
|
linkset,
|
|
rawLinkset,
|
|
new DateTimeOffset(doc.CreatedAt, TimeSpan.Zero),
|
|
doc.Attributes.ToImmutableDictionary());
|
|
}
|
|
}
|
|
|
|
private sealed class StubAdvisoryEventLog : IAdvisoryEventLog
|
|
{
|
|
private readonly ConcurrentDictionary<string, List<AdvisoryStatementInput>> _statements = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public ValueTask AppendAsync(AdvisoryEventAppendRequest request, CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
foreach (var statement in request.Statements)
|
|
{
|
|
var list = _statements.GetOrAdd(statement.VulnerabilityKey, _ => new List<AdvisoryStatementInput>());
|
|
lock (list)
|
|
{
|
|
list.Add(statement);
|
|
}
|
|
}
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
public ValueTask<AdvisoryReplay> ReplayAsync(string vulnerabilityKey, DateTimeOffset? asOf, CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (_statements.TryGetValue(vulnerabilityKey, out var statements) && statements.Count > 0)
|
|
{
|
|
var snapshots = statements
|
|
.Select(s => new AdvisoryStatementSnapshot(
|
|
s.StatementId ?? Guid.NewGuid(),
|
|
s.VulnerabilityKey,
|
|
s.AdvisoryKey ?? s.Advisory.AdvisoryKey,
|
|
s.Advisory,
|
|
System.Collections.Immutable.ImmutableArray<byte>.Empty,
|
|
s.AsOf,
|
|
DateTimeOffset.UtcNow,
|
|
System.Collections.Immutable.ImmutableArray<Guid>.Empty))
|
|
.ToImmutableArray();
|
|
|
|
return ValueTask.FromResult(new AdvisoryReplay(
|
|
vulnerabilityKey,
|
|
asOf,
|
|
snapshots,
|
|
System.Collections.Immutable.ImmutableArray<AdvisoryConflictSnapshot>.Empty));
|
|
}
|
|
|
|
return ValueTask.FromResult(new AdvisoryReplay(
|
|
vulnerabilityKey,
|
|
asOf,
|
|
System.Collections.Immutable.ImmutableArray<AdvisoryStatementSnapshot>.Empty,
|
|
System.Collections.Immutable.ImmutableArray<AdvisoryConflictSnapshot>.Empty));
|
|
}
|
|
|
|
public ValueTask AttachStatementProvenanceAsync(Guid statementId, DsseProvenance provenance, TrustInfo trust, CancellationToken cancellationToken)
|
|
=> ValueTask.CompletedTask;
|
|
}
|
|
|
|
private sealed class StubAdvisoryStore : IAdvisoryStore
|
|
{
|
|
private readonly ConcurrentDictionary<string, Advisory> _advisories = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public Task UpsertAsync(Advisory advisory, CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
_advisories[advisory.AdvisoryKey] = advisory;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<Advisory?> FindAsync(string advisoryKey, CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
_advisories.TryGetValue(advisoryKey, out var advisory);
|
|
return Task.FromResult<Advisory?>(advisory);
|
|
}
|
|
|
|
public Task<IReadOnlyList<Advisory>> GetRecentAsync(int limit, CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var result = _advisories.Values
|
|
.OrderByDescending(a => a.Modified ?? a.Published ?? DateTimeOffset.MinValue)
|
|
.Take(limit)
|
|
.ToArray();
|
|
return Task.FromResult<IReadOnlyList<Advisory>>(result);
|
|
}
|
|
|
|
public async IAsyncEnumerable<Advisory> StreamAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
foreach (var advisory in _advisories.Values.OrderBy(a => a.AdvisoryKey, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
yield return advisory;
|
|
await Task.Yield();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// In-memory implementation of IAdvisoryLinksetLookup that queries the shared in-memory database.
|
|
/// Performs filtering by tenant, advisoryIds, and sources, letting the real AdvisoryLinksetQueryService
|
|
/// handle sorting, pagination, and cursor encoding.
|
|
/// </summary>
|
|
private sealed class InMemoryAdvisoryLinksetLookup : IAdvisoryLinksetLookup
|
|
{
|
|
public async Task<IReadOnlyList<AdvisoryLinkset>> FindByTenantAsync(
|
|
string tenantId,
|
|
IEnumerable<string>? advisoryIds,
|
|
IEnumerable<string>? sources,
|
|
AdvisoryLinksetCursor? cursor,
|
|
int limit,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var client = new InMemoryClient("inmemory://localhost/fake");
|
|
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
|
|
var collection = database.GetCollection<AdvisoryLinksetDocument>(StorageDefaults.Collections.AdvisoryLinksets);
|
|
|
|
var dbCursor = await collection.FindAsync(FilterDefinition<AdvisoryLinksetDocument>.Empty, null, cancellationToken);
|
|
var docs = new List<AdvisoryLinksetDocument>();
|
|
while (await dbCursor.MoveNextAsync(cancellationToken))
|
|
{
|
|
docs.AddRange(dbCursor.Current);
|
|
}
|
|
|
|
var advisoryIdsList = advisoryIds?.ToList();
|
|
var sourcesList = sources?.ToList();
|
|
|
|
// Filter by tenant, advisoryIds, and sources
|
|
var filtered = docs
|
|
.Where(d => string.Equals(d.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
|
.Where(d => advisoryIdsList == null || !advisoryIdsList.Any() ||
|
|
advisoryIdsList.Any(id => string.Equals(d.AdvisoryId, id, StringComparison.OrdinalIgnoreCase)))
|
|
.Where(d => sourcesList == null || !sourcesList.Any() ||
|
|
sourcesList.Any(s => string.Equals(d.Source, s, StringComparison.OrdinalIgnoreCase)))
|
|
.Select(MapToLinkset)
|
|
.ToList();
|
|
|
|
// Apply cursor for pagination if provided
|
|
// Sort order is: CreatedAt DESC, AdvisoryId ASC
|
|
// Cursor points to last item of previous page, so we want items "after" it
|
|
if (cursor != null)
|
|
{
|
|
var cursorCreatedAt = cursor.CreatedAt;
|
|
var cursorAdvisoryId = cursor.AdvisoryId;
|
|
filtered = filtered
|
|
.Where(ls => IsBeyondLinksetCursor(ls, cursorCreatedAt, cursorAdvisoryId))
|
|
.ToList();
|
|
}
|
|
|
|
return filtered;
|
|
}
|
|
|
|
private static bool IsBeyondLinksetCursor(AdvisoryLinkset linkset, DateTimeOffset cursorCreatedAt, string cursorAdvisoryId)
|
|
{
|
|
// For DESC CreatedAt, ASC AdvisoryId sorting:
|
|
// Return true if this linkset should appear AFTER the cursor position
|
|
if (linkset.CreatedAt < cursorCreatedAt)
|
|
{
|
|
return true;
|
|
}
|
|
if (linkset.CreatedAt == cursorCreatedAt &&
|
|
string.Compare(linkset.AdvisoryId, cursorAdvisoryId, StringComparison.Ordinal) > 0)
|
|
{
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static AdvisoryLinkset MapToLinkset(AdvisoryLinksetDocument doc)
|
|
{
|
|
return new AdvisoryLinkset(
|
|
doc.TenantId,
|
|
doc.Source,
|
|
doc.AdvisoryId,
|
|
doc.Observations.ToImmutableArray(),
|
|
new AdvisoryLinksetNormalized(
|
|
doc.Normalized.Purls.ToList(),
|
|
null, // Cpes
|
|
doc.Normalized.Versions.ToList(),
|
|
null, // Ranges
|
|
null), // Severities
|
|
null, // Provenance
|
|
null, // Confidence
|
|
null, // Conflicts
|
|
new DateTimeOffset(doc.CreatedAt, TimeSpan.Zero),
|
|
null); // BuiltByJobId
|
|
}
|
|
}
|
|
|
|
private sealed class InMemoryAdvisoryLinksetStore : IAdvisoryLinksetStore
|
|
{
|
|
public async Task<IReadOnlyList<AdvisoryLinkset>> FindByTenantAsync(
|
|
string tenantId,
|
|
IEnumerable<string>? advisoryIds,
|
|
IEnumerable<string>? sources,
|
|
AdvisoryLinksetCursor? cursor,
|
|
int limit,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var client = new InMemoryClient("inmemory://localhost/fake");
|
|
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
|
|
var collection = database.GetCollection<AdvisoryLinksetDocument>(StorageDefaults.Collections.AdvisoryLinksets);
|
|
|
|
var dbCursor = await collection.FindAsync(FilterDefinition<AdvisoryLinksetDocument>.Empty, null, cancellationToken);
|
|
var docs = new List<AdvisoryLinksetDocument>();
|
|
while (await dbCursor.MoveNextAsync(cancellationToken))
|
|
{
|
|
docs.AddRange(dbCursor.Current);
|
|
}
|
|
|
|
var advisoryIdsList = advisoryIds?.ToList();
|
|
var sourcesList = sources?.ToList();
|
|
|
|
var filtered = docs
|
|
.Where(d => string.Equals(d.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
|
.Where(d => advisoryIdsList == null || !advisoryIdsList.Any() ||
|
|
advisoryIdsList.Any(id => string.Equals(d.AdvisoryId, id, StringComparison.OrdinalIgnoreCase)))
|
|
.Where(d => sourcesList == null || !sourcesList.Any() ||
|
|
sourcesList.Any(s => string.Equals(d.Source, s, StringComparison.OrdinalIgnoreCase)))
|
|
.OrderByDescending(d => d.CreatedAt)
|
|
.Take(limit)
|
|
.Select(MapToLinkset)
|
|
.ToList();
|
|
|
|
return filtered;
|
|
}
|
|
|
|
public Task UpsertAsync(AdvisoryLinkset linkset, CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private static AdvisoryLinkset MapToLinkset(AdvisoryLinksetDocument doc)
|
|
{
|
|
return new AdvisoryLinkset(
|
|
doc.TenantId,
|
|
doc.Source,
|
|
doc.AdvisoryId,
|
|
doc.Observations.ToImmutableArray(),
|
|
new AdvisoryLinksetNormalized(
|
|
doc.Normalized.Purls.ToList(),
|
|
null, // Cpes
|
|
doc.Normalized.Versions.ToList(),
|
|
null, // Ranges
|
|
null), // Severities
|
|
null, // Provenance
|
|
null, // Confidence
|
|
null, // Conflicts
|
|
new DateTimeOffset(doc.CreatedAt, TimeSpan.Zero),
|
|
null); // BuiltByJobId
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StatementProvenanceEndpointAttachesMetadata()
|
|
{
|
|
var tenant = "tenant-provenance";
|
|
var vulnerabilityKey = "CVE-2025-9200";
|
|
var statementId = Guid.NewGuid();
|
|
var recordedAt = DateTimeOffset.Parse("2025-03-01T00:00:00Z", CultureInfo.InvariantCulture);
|
|
|
|
using (var scope = _factory.Services.CreateScope())
|
|
{
|
|
var eventLog = scope.ServiceProvider.GetRequiredService<IAdvisoryEventLog>();
|
|
var advisory = new Advisory(
|
|
advisoryKey: vulnerabilityKey,
|
|
title: "Provenance seed",
|
|
summary: "Ready for DSSE metadata",
|
|
language: "en",
|
|
published: recordedAt.AddDays(-1),
|
|
modified: recordedAt,
|
|
severity: "high",
|
|
exploitKnown: false,
|
|
aliases: new[] { vulnerabilityKey },
|
|
references: Array.Empty<AdvisoryReference>(),
|
|
affectedPackages: Array.Empty<AffectedPackage>(),
|
|
cvssMetrics: Array.Empty<CvssMetric>(),
|
|
provenance: Array.Empty<AdvisoryProvenance>());
|
|
|
|
var statementInput = new AdvisoryStatementInput(
|
|
vulnerabilityKey,
|
|
advisory,
|
|
recordedAt,
|
|
InputDocumentIds: Array.Empty<Guid>(),
|
|
StatementId: statementId,
|
|
AdvisoryKey: advisory.AdvisoryKey);
|
|
|
|
await eventLog.AppendAsync(new AdvisoryEventAppendRequest(new[] { statementInput }), CancellationToken.None);
|
|
}
|
|
|
|
try
|
|
{
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Stella-Tenant", tenant);
|
|
|
|
var response = await client.PostAsync(
|
|
$"/events/statements/{statementId}/provenance?tenant={tenant}",
|
|
new StringContent(BuildProvenancePayload(), Encoding.UTF8, "application/json"));
|
|
|
|
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
|
|
|
using var validationScope = _factory.Services.CreateScope();
|
|
var database = validationScope.ServiceProvider.GetRequiredService<IStorageDatabase>();
|
|
var statements = database.GetCollection<DocumentObject>(StorageDefaults.Collections.AdvisoryStatements);
|
|
|
|
var stored = await statements
|
|
.Find(Builders<DocumentObject>.Filter.Eq("_id", statementId.ToString()))
|
|
.FirstOrDefaultAsync();
|
|
|
|
Assert.NotNull(stored);
|
|
var dsse = stored!["provenance"].AsDocumentObject["dsse"].AsDocumentObject;
|
|
Assert.Equal("sha256:feedface", dsse["envelopeDigest"].AsString);
|
|
var trustDoc = stored["trust"].AsDocumentObject;
|
|
Assert.True(trustDoc["verified"].AsBoolean);
|
|
Assert.Equal("Authority@stella", trustDoc["verifier"].AsString);
|
|
}
|
|
finally
|
|
{
|
|
using var cleanupScope = _factory.Services.CreateScope();
|
|
var database = cleanupScope.ServiceProvider.GetRequiredService<IStorageDatabase>();
|
|
var statements = database.GetCollection<DocumentObject>(StorageDefaults.Collections.AdvisoryStatements);
|
|
await statements.DeleteOneAsync(Builders<DocumentObject>.Filter.Eq("_id", statementId.ToString()));
|
|
}
|
|
}
|
|
|
|
private static string BuildProvenancePayload()
|
|
{
|
|
var payload = new
|
|
{
|
|
dsse = new
|
|
{
|
|
envelopeDigest = "sha256:feedface",
|
|
payloadType = "application/vnd.in-toto+json",
|
|
key = new
|
|
{
|
|
keyId = "cosign:SHA256-PKIX:fixture",
|
|
issuer = "Authority@stella",
|
|
algo = "Ed25519"
|
|
},
|
|
rekor = new
|
|
{
|
|
logIndex = 1337,
|
|
uuid = "11111111-2222-3333-4444-555555555555",
|
|
integratedTime = 1731081600
|
|
}
|
|
},
|
|
trust = new
|
|
{
|
|
verified = true,
|
|
verifier = "Authority@stella",
|
|
witnesses = 1,
|
|
policyScore = 1.0
|
|
}
|
|
};
|
|
|
|
return JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
|
}
|
|
|
|
private sealed class TempDirectory : IDisposable
|
|
{
|
|
public string Path { get; }
|
|
|
|
public TempDirectory()
|
|
{
|
|
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "concelier-mirror-" + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture));
|
|
Directory.CreateDirectory(Path);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try
|
|
{
|
|
if (Directory.Exists(Path))
|
|
{
|
|
Directory.Delete(Path, recursive: true);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// best effort cleanup
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed record HealthPayload(string Status, DateTimeOffset StartedAt, double UptimeSeconds, StoragePayload Storage, TelemetryPayload Telemetry);
|
|
|
|
private sealed record StoragePayload(string Backend, bool Ready, DateTimeOffset? CheckedAt, double? LatencyMs, string? Error);
|
|
|
|
private sealed record TelemetryPayload(bool Enabled, bool Tracing, bool Metrics, bool Logging);
|
|
|
|
private sealed record ReadyPayload(string Status, DateTimeOffset StartedAt, double UptimeSeconds, StoragePayload Storage);
|
|
|
|
private sealed record JobDefinitionPayload(string Kind, bool Enabled, string? CronExpression, TimeSpan Timeout, TimeSpan LeaseDuration, JobRunPayload? LastRun);
|
|
|
|
private sealed record JobRunPayload(Guid RunId, string Kind, string Status, string Trigger, DateTimeOffset CreatedAt, DateTimeOffset? StartedAt, DateTimeOffset? CompletedAt, string? Error, TimeSpan? Duration, Dictionary<string, object?> Parameters);
|
|
|
|
private sealed record ProblemDocument(string? Type, string? Title, int? Status, string? Detail, string? Instance);
|
|
|
|
private async Task SeedCanonicalAdvisoriesAsync(params Advisory[] advisories)
|
|
{
|
|
using var scope = _factory.Services.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<IStorageDatabase>();
|
|
|
|
await DropCollectionIfExistsAsync(database, StorageDefaults.Collections.Advisory);
|
|
await DropCollectionIfExistsAsync(database, StorageDefaults.Collections.Alias);
|
|
|
|
if (advisories.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var store = scope.ServiceProvider.GetRequiredService<IAdvisoryStore>();
|
|
foreach (var advisory in advisories)
|
|
{
|
|
await store.UpsertAsync(advisory, CancellationToken.None);
|
|
}
|
|
}
|
|
|
|
private static async Task DropCollectionIfExistsAsync(IStorageDatabase database, string collectionName)
|
|
{
|
|
try
|
|
{
|
|
await database.DropCollectionAsync(collectionName);
|
|
}
|
|
catch (StorageCommandException ex) when (ex.CodeName == "NamespaceNotFound" || ex.Message.Contains("ns not found", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
}
|
|
}
|
|
|
|
private static Advisory CreateStructuredAdvisory(
|
|
string advisoryKey,
|
|
string alias,
|
|
string observationId,
|
|
DateTimeOffset recordedAt)
|
|
{
|
|
const string WorkaroundTitle = "Vendor guidance";
|
|
const string WorkaroundSummary = "Apply configuration change immediately.";
|
|
const string WorkaroundUrl = "https://vendor.example/workaround";
|
|
|
|
var reference = new AdvisoryReference(
|
|
WorkaroundUrl,
|
|
kind: "workaround",
|
|
sourceTag: WorkaroundTitle,
|
|
summary: WorkaroundSummary,
|
|
new AdvisoryProvenance(
|
|
"nvd",
|
|
"workaround",
|
|
observationId,
|
|
recordedAt,
|
|
new[] { "/references/0" }));
|
|
|
|
var affectedRange = new AffectedVersionRange(
|
|
rangeKind: "semver",
|
|
introducedVersion: "1.0.0",
|
|
fixedVersion: "1.1.0",
|
|
lastAffectedVersion: null,
|
|
rangeExpression: ">=1.0.0,<1.1.0",
|
|
new AdvisoryProvenance(
|
|
"nvd",
|
|
"affected",
|
|
observationId,
|
|
recordedAt,
|
|
new[] { "/affectedPackages/0/versionRanges/0" }));
|
|
|
|
var affectedPackage = new AffectedPackage(
|
|
type: AffectedPackageTypes.SemVer,
|
|
identifier: "pkg:npm/demo",
|
|
versionRanges: new[] { affectedRange },
|
|
statuses: Array.Empty<AffectedPackageStatus>(),
|
|
provenance: new[]
|
|
{
|
|
new AdvisoryProvenance(
|
|
"nvd",
|
|
"affected",
|
|
observationId,
|
|
recordedAt,
|
|
new[] { "/affectedPackages/0" })
|
|
},
|
|
normalizedVersions: Array.Empty<NormalizedVersionRule>());
|
|
|
|
var cvss = new CvssMetric(
|
|
"3.1",
|
|
"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
|
9.8,
|
|
"critical",
|
|
new AdvisoryProvenance(
|
|
"nvd",
|
|
"cvss",
|
|
observationId,
|
|
recordedAt,
|
|
new[] { "/cvssMetrics/0" }));
|
|
|
|
var advisory = new Advisory(
|
|
advisoryKey,
|
|
title: "Fixture advisory",
|
|
summary: "Structured payload fixture",
|
|
language: "en",
|
|
published: recordedAt,
|
|
modified: recordedAt,
|
|
severity: "critical",
|
|
exploitKnown: false,
|
|
aliases: string.IsNullOrWhiteSpace(alias) ? new[] { advisoryKey } : new[] { advisoryKey, alias },
|
|
references: new[] { reference },
|
|
affectedPackages: new[] { affectedPackage },
|
|
cvssMetrics: new[] { cvss },
|
|
provenance: new[]
|
|
{
|
|
new AdvisoryProvenance("nvd", "advisory", observationId, recordedAt)
|
|
});
|
|
|
|
return advisory;
|
|
}
|
|
|
|
private async Task SeedAdvisoryRawDocumentsAsync(params DocumentObject[] documents)
|
|
{
|
|
var client = new InMemoryClient(_runner.ConnectionString);
|
|
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
|
|
var collection = database.GetCollection<DocumentObject>(StorageDefaults.Collections.AdvisoryRaw);
|
|
await collection.DeleteManyAsync(FilterDefinition<DocumentObject>.Empty);
|
|
if (documents.Length > 0)
|
|
{
|
|
await collection.InsertManyAsync(documents);
|
|
}
|
|
}
|
|
|
|
private static DocumentObject CreateAdvisoryRawDocument(
|
|
string tenant,
|
|
string vendor,
|
|
string upstreamId,
|
|
string contentHash,
|
|
DocumentObject? raw = null,
|
|
string? supersedes = null)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
return new DocumentObject
|
|
{
|
|
{ "_id", BuildRawDocumentId(vendor, upstreamId, contentHash) },
|
|
{ "tenant", tenant },
|
|
{
|
|
"source",
|
|
new DocumentObject
|
|
{
|
|
{ "vendor", vendor },
|
|
{ "connector", "test-connector" },
|
|
{ "version", "1.0.0" }
|
|
}
|
|
},
|
|
{
|
|
"upstream",
|
|
new DocumentObject
|
|
{
|
|
{ "upstream_id", upstreamId },
|
|
{ "document_version", "1" },
|
|
{ "retrieved_at", now },
|
|
{ "content_hash", contentHash },
|
|
{ "signature", new DocumentObject { { "present", false } } },
|
|
{ "provenance", new DocumentObject { { "api", "https://example.test" } } }
|
|
}
|
|
},
|
|
{
|
|
"content",
|
|
new DocumentObject
|
|
{
|
|
{ "format", "osv" },
|
|
{ "raw", raw ?? new DocumentObject("id", upstreamId) }
|
|
}
|
|
},
|
|
{
|
|
"identifiers",
|
|
new DocumentObject
|
|
{
|
|
{ "aliases", new DocumentArray(new[] { upstreamId }) },
|
|
{ "primary", upstreamId }
|
|
}
|
|
},
|
|
{
|
|
"linkset",
|
|
new DocumentObject
|
|
{
|
|
{ "aliases", new DocumentArray() },
|
|
{ "purls", new DocumentArray() },
|
|
{ "cpes", new DocumentArray() },
|
|
{ "references", new DocumentArray() },
|
|
{ "reconciled_from", new DocumentArray() },
|
|
{ "notes", new DocumentObject() }
|
|
}
|
|
},
|
|
{ "advisory_key", upstreamId.ToUpperInvariant() },
|
|
{
|
|
"links",
|
|
new DocumentArray
|
|
{
|
|
new DocumentObject
|
|
{
|
|
{ "scheme", "PRIMARY" },
|
|
{ "value", upstreamId.ToUpperInvariant() }
|
|
}
|
|
}
|
|
},
|
|
{ "supersedes", supersedes is null ? DocumentNull.Value : supersedes },
|
|
{ "ingested_at", now },
|
|
{ "created_at", now }
|
|
};
|
|
}
|
|
|
|
private static string BuildRawDocumentId(string vendor, string upstreamId, string contentHash)
|
|
{
|
|
static string Sanitize(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return "unknown";
|
|
}
|
|
|
|
var buffer = new char[value.Length];
|
|
var index = 0;
|
|
foreach (var ch in value.Trim().ToLowerInvariant())
|
|
{
|
|
buffer[index++] = char.IsLetterOrDigit(ch) ? ch : '-';
|
|
}
|
|
|
|
var sanitized = new string(buffer, 0, index).Trim('-');
|
|
return string.IsNullOrEmpty(sanitized) ? "unknown" : sanitized;
|
|
}
|
|
|
|
var vendorSegment = Sanitize(vendor);
|
|
var upstreamSegment = Sanitize(upstreamId);
|
|
var hashSegment = Sanitize(contentHash.Replace(":", "-"));
|
|
return $"advisory_raw:{vendorSegment}:{upstreamSegment}:{hashSegment}";
|
|
}
|
|
|
|
private void WriteProgramLogs()
|
|
{
|
|
var entries = _factory.LoggerProvider.Snapshot("StellaOps.Concelier.WebService.Program");
|
|
foreach (var entry in entries)
|
|
{
|
|
_output.WriteLine($"[PROGRAM LOG] {entry.Level}: {entry.Message}");
|
|
}
|
|
}
|
|
|
|
private static void WarmupFactory(WebApplicationFactory<Program> factory)
|
|
{
|
|
using var client = factory.CreateClient();
|
|
}
|
|
|
|
private static AdvisoryIngestRequest BuildAdvisoryIngestRequest(
|
|
string? contentHash,
|
|
string upstreamId,
|
|
bool enforceContentHash = true,
|
|
IReadOnlyList<string>? purls = null,
|
|
IReadOnlyList<string>? notes = null)
|
|
{
|
|
var raw = CreateJsonElement($@"{{""id"":""{upstreamId}"",""modified"":""{DefaultIngestTimestamp:O}""}}");
|
|
var normalizedContentHash = NormalizeContentHash(contentHash, raw, enforceContentHash);
|
|
var resolvedPurls = purls ?? new[] { "pkg:npm/demo@1.0.0" };
|
|
var resolvedNotes = notes ?? Array.Empty<string>();
|
|
var references = new[]
|
|
{
|
|
new AdvisoryLinksetReferenceRequest("advisory", $"https://example.test/advisories/{upstreamId}", null)
|
|
};
|
|
|
|
return new AdvisoryIngestRequest(
|
|
new AdvisorySourceRequest("osv", "osv-connector", "1.0.0", "feed"),
|
|
new AdvisoryUpstreamRequest(
|
|
upstreamId,
|
|
"2025-01-01T00:00:00Z",
|
|
DateTimeOffset.UtcNow,
|
|
normalizedContentHash,
|
|
new AdvisorySignatureRequest(false, null, null, null, null, null),
|
|
new Dictionary<string, string> { ["http.method"] = "GET" }),
|
|
new AdvisoryContentRequest("osv", "1.3.0", raw, null),
|
|
new AdvisoryIdentifiersRequest(
|
|
upstreamId,
|
|
new[] { upstreamId, $"{upstreamId}-ALIAS" }),
|
|
new AdvisoryLinksetRequest(
|
|
new[] { upstreamId },
|
|
resolvedPurls,
|
|
Array.Empty<AdvisoryLinksetRelationshipRequest>(),
|
|
Array.Empty<string>(),
|
|
Array.Empty<string>(),
|
|
references,
|
|
resolvedNotes,
|
|
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);
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|