Repair live canonical migrations and scanner cache bootstrap

This commit is contained in:
master
2026-03-09 21:56:41 +02:00
parent 00bf2fa99a
commit dfd22281ed
21 changed files with 1018 additions and 12 deletions

View File

@@ -0,0 +1,33 @@
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using StellaOps.Concelier.Persistence.Postgres;
using StellaOps.TestKit;
namespace StellaOps.Concelier.Persistence.Tests;
public sealed class ConcelierInfrastructureRegistrationTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddConcelierPostgresStorage_RegistersStartupMigrationHost()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Postgres:Concelier:ConnectionString"] = "Host=postgres;Database=stellaops;Username=postgres;Password=postgres"
})
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddConcelierPostgresStorage(configuration);
services
.Where(descriptor => descriptor.ServiceType == typeof(IHostedService))
.Should()
.ContainSingle("fresh installs need Concelier startup migrations to create the vuln schema before canonical advisory queries can execute");
}
}

View File

@@ -11,3 +11,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| TASK-015-011 | DONE | Added SbomRepository integration coverage. |
| TASK-015-007d | DONE | Added license query coverage for SbomRepository. |
| TASK-015-013 | DONE | Added SbomRepository integration coverage for model cards and policy fields. |
| TASK-014-003 | DONE | 2026-03-09: added startup-migration registration coverage so Concelier canonical tables bootstrap on fresh deploys and verified `/api/v1/canonical` live after redeploy. |

View File

@@ -0,0 +1,132 @@
using System.Net;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Concelier.Core.Canonical;
using StellaOps.Concelier.WebService.Tests.Fixtures;
namespace StellaOps.Concelier.WebService.Tests.Canonical;
public sealed class CanonicalProductionRegistrationTests : IAsyncLifetime
{
private WebApplicationFactory<Program> factory = null!;
private HttpClient client = null!;
private static readonly Guid CanonicalId = Guid.Parse("22222222-2222-2222-2222-222222222222");
public ValueTask InitializeAsync()
{
factory = new ConcelierApplicationFactory()
.WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
services.RemoveAll<ICanonicalAdvisoryStore>();
services.AddSingleton<ICanonicalAdvisoryStore>(new StubCanonicalAdvisoryStore());
});
});
client = factory.CreateClient();
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync()
{
client.Dispose();
factory.Dispose();
return ValueTask.CompletedTask;
}
[Fact]
public async Task QueryCanonical_UsesProductionRegistrationWithoutDiFailure()
{
var response = await client.GetAsync("/api/v1/canonical?cve=CVE-2026-1000");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain("CVE-2026-1000");
}
private sealed class StubCanonicalAdvisoryStore : ICanonicalAdvisoryStore
{
private static readonly CanonicalAdvisory Advisory = new()
{
Id = CanonicalId,
Cve = "CVE-2026-1000",
AffectsKey = "pkg:npm/example@1.0.0",
MergeHash = "sha256:canonical-production-registration",
Status = CanonicalStatus.Active,
Severity = "high",
CreatedAt = new DateTimeOffset(2026, 3, 9, 0, 0, 0, TimeSpan.Zero),
UpdatedAt = new DateTimeOffset(2026, 3, 9, 0, 0, 0, TimeSpan.Zero),
SourceEdges =
[
new SourceEdge
{
Id = Guid.Parse("33333333-3333-3333-3333-333333333333"),
CanonicalId = CanonicalId,
SourceName = "nvd",
SourceAdvisoryId = "NVD-2026-1000",
SourceDocHash = "sha256:edge",
PrecedenceRank = 40,
FetchedAt = new DateTimeOffset(2026, 3, 9, 0, 0, 0, TimeSpan.Zero),
CreatedAt = new DateTimeOffset(2026, 3, 9, 0, 0, 0, TimeSpan.Zero)
}
]
};
public Task<CanonicalAdvisory?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> Task.FromResult(id == CanonicalId ? Advisory : null);
public Task<CanonicalAdvisory?> GetByMergeHashAsync(string mergeHash, CancellationToken ct = default)
=> Task.FromResult(
string.Equals(mergeHash, Advisory.MergeHash, StringComparison.Ordinal) ? Advisory : null);
public Task<IReadOnlyList<CanonicalAdvisory>> GetByCveAsync(string cve, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<CanonicalAdvisory>>(
string.Equals(cve, Advisory.Cve, StringComparison.Ordinal) ? [Advisory] : []);
public Task<IReadOnlyList<CanonicalAdvisory>> GetByArtifactAsync(string artifactKey, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<CanonicalAdvisory>>(
string.Equals(artifactKey, Advisory.AffectsKey, StringComparison.Ordinal) ? [Advisory] : []);
public Task<PagedResult<CanonicalAdvisory>> QueryAsync(CanonicalQueryOptions options, CancellationToken ct = default)
=> Task.FromResult(new PagedResult<CanonicalAdvisory>
{
Items = [Advisory],
TotalCount = 1,
Offset = options.Offset,
Limit = options.Limit
});
public Task<Guid> UpsertCanonicalAsync(UpsertCanonicalRequest request, CancellationToken ct = default)
=> Task.FromResult(CanonicalId);
public Task UpdateStatusAsync(Guid id, CanonicalStatus status, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<long> CountAsync(CancellationToken ct = default)
=> Task.FromResult(1L);
public Task<SourceEdgeResult> AddSourceEdgeAsync(AddSourceEdgeRequest request, CancellationToken ct = default)
=> Task.FromResult(SourceEdgeResult.Created(Guid.Parse("44444444-4444-4444-4444-444444444444")));
public Task<IReadOnlyList<SourceEdge>> GetSourceEdgesAsync(Guid canonicalId, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SourceEdge>>(Advisory.SourceEdges);
public Task<bool> SourceEdgeExistsAsync(Guid canonicalId, Guid sourceId, string docHash, CancellationToken ct = default)
=> Task.FromResult(false);
public Task<IReadOnlyList<ProvenanceScopeDto>> GetProvenanceScopesAsync(Guid canonicalId, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ProvenanceScopeDto>>([]);
public Task<Guid> ResolveSourceIdAsync(string sourceKey, CancellationToken ct = default)
=> Task.FromResult(Guid.Parse("55555555-5555-5555-5555-555555555555"));
public Task<int> GetSourcePrecedenceAsync(string sourceKey, CancellationToken ct = default)
=> Task.FromResult(40);
}
}