Gaps fill up, fixes, ui restructuring

This commit is contained in:
master
2026-02-19 22:10:54 +02:00
parent b5829dce5c
commit 04cacdca8a
331 changed files with 42859 additions and 2174 deletions

View File

@@ -0,0 +1,235 @@
using HttpResults = Microsoft.AspNetCore.Http.Results;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Concelier.Persistence.Postgres.Repositories;
namespace StellaOps.Concelier.WebService.Extensions;
/// <summary>
/// Advisory-source freshness endpoints used by UI v2 shell.
/// </summary>
internal static class AdvisorySourceEndpointExtensions
{
private const string AdvisoryReadPolicy = "Concelier.Advisories.Read";
public static void MapAdvisorySourceEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1/advisory-sources")
.WithTags("Advisory Sources");
group.MapGet(string.Empty, async (
HttpContext httpContext,
[FromQuery] bool includeDisabled,
[FromServices] IAdvisorySourceReadRepository readRepository,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out _))
{
return HttpResults.BadRequest(new { error = "tenant_required", header = StellaOps.Concelier.WebService.Program.TenantHeaderName });
}
var records = await readRepository.ListAsync(includeDisabled, cancellationToken).ConfigureAwait(false);
var items = records.Select(MapListItem).ToList();
return HttpResults.Ok(new AdvisorySourceListResponse
{
Items = items,
TotalCount = items.Count,
DataAsOf = timeProvider.GetUtcNow()
});
})
.WithName("ListAdvisorySources")
.WithSummary("List advisory sources with freshness state")
.Produces<AdvisorySourceListResponse>(StatusCodes.Status200OK)
.RequireAuthorization(AdvisoryReadPolicy);
group.MapGet("/summary", async (
HttpContext httpContext,
[FromServices] IAdvisorySourceReadRepository readRepository,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out _))
{
return HttpResults.BadRequest(new { error = "tenant_required", header = StellaOps.Concelier.WebService.Program.TenantHeaderName });
}
var records = await readRepository.ListAsync(includeDisabled: true, cancellationToken).ConfigureAwait(false);
var response = new AdvisorySourceSummaryResponse
{
TotalSources = records.Count,
HealthySources = records.Count(r => string.Equals(r.FreshnessStatus, "healthy", StringComparison.OrdinalIgnoreCase)),
WarningSources = records.Count(r => string.Equals(r.FreshnessStatus, "warning", StringComparison.OrdinalIgnoreCase)),
StaleSources = records.Count(r => string.Equals(r.FreshnessStatus, "stale", StringComparison.OrdinalIgnoreCase)),
UnavailableSources = records.Count(r => string.Equals(r.FreshnessStatus, "unavailable", StringComparison.OrdinalIgnoreCase)),
DisabledSources = records.Count(r => !r.Enabled),
ConflictingSources = 0,
DataAsOf = timeProvider.GetUtcNow()
};
return HttpResults.Ok(response);
})
.WithName("GetAdvisorySourceSummary")
.WithSummary("Get advisory source summary cards")
.Produces<AdvisorySourceSummaryResponse>(StatusCodes.Status200OK)
.RequireAuthorization(AdvisoryReadPolicy);
group.MapGet("/{id}/freshness", async (
HttpContext httpContext,
string id,
[FromServices] IAdvisorySourceReadRepository readRepository,
[FromServices] ISourceRepository sourceRepository,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out _))
{
return HttpResults.BadRequest(new { error = "tenant_required", header = StellaOps.Concelier.WebService.Program.TenantHeaderName });
}
if (string.IsNullOrWhiteSpace(id))
{
return HttpResults.BadRequest(new { error = "source_id_required" });
}
id = id.Trim();
AdvisorySourceFreshnessRecord? record = null;
if (Guid.TryParse(id, out var sourceId))
{
record = await readRepository.GetBySourceIdAsync(sourceId, cancellationToken).ConfigureAwait(false);
}
else
{
var source = await sourceRepository.GetByKeyAsync(id, cancellationToken).ConfigureAwait(false);
if (source is not null)
{
record = await readRepository.GetBySourceIdAsync(source.Id, cancellationToken).ConfigureAwait(false);
}
}
if (record is null)
{
return HttpResults.NotFound(new { error = "advisory_source_not_found", id });
}
return HttpResults.Ok(new AdvisorySourceFreshnessResponse
{
Source = MapListItem(record),
LastSyncAt = record.LastSyncAt,
LastSuccessAt = record.LastSuccessAt,
LastError = record.LastError,
SyncCount = record.SyncCount,
ErrorCount = record.ErrorCount,
DataAsOf = timeProvider.GetUtcNow()
});
})
.WithName("GetAdvisorySourceFreshness")
.WithSummary("Get freshness details for one advisory source")
.Produces<AdvisorySourceFreshnessResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(AdvisoryReadPolicy);
}
private static bool TryGetTenant(HttpContext httpContext, out string tenant)
{
tenant = string.Empty;
var claimTenant = httpContext.User?.FindFirst("tenant_id")?.Value;
if (!string.IsNullOrWhiteSpace(claimTenant))
{
tenant = claimTenant.Trim();
return true;
}
var headerTenant = httpContext.Request.Headers[StellaOps.Concelier.WebService.Program.TenantHeaderName].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(headerTenant))
{
tenant = headerTenant.Trim();
return true;
}
return false;
}
private static AdvisorySourceListItem MapListItem(AdvisorySourceFreshnessRecord record)
{
return new AdvisorySourceListItem
{
SourceId = record.SourceId,
SourceKey = record.SourceKey,
SourceName = record.SourceName,
SourceFamily = record.SourceFamily,
SourceUrl = record.SourceUrl,
Priority = record.Priority,
Enabled = record.Enabled,
LastSyncAt = record.LastSyncAt,
LastSuccessAt = record.LastSuccessAt,
FreshnessAgeSeconds = record.FreshnessAgeSeconds,
FreshnessSlaSeconds = record.FreshnessSlaSeconds,
FreshnessStatus = record.FreshnessStatus,
SignatureStatus = record.SignatureStatus,
LastError = record.LastError,
SyncCount = record.SyncCount,
ErrorCount = record.ErrorCount,
TotalAdvisories = record.TotalAdvisories,
SignedAdvisories = record.SignedAdvisories,
UnsignedAdvisories = record.UnsignedAdvisories,
SignatureFailureCount = record.SignatureFailureCount
};
}
}
public sealed record AdvisorySourceListResponse
{
public IReadOnlyList<AdvisorySourceListItem> Items { get; init; } = [];
public int TotalCount { get; init; }
public DateTimeOffset DataAsOf { get; init; }
}
public sealed record AdvisorySourceListItem
{
public Guid SourceId { get; init; }
public string SourceKey { get; init; } = string.Empty;
public string SourceName { get; init; } = string.Empty;
public string SourceFamily { get; init; } = string.Empty;
public string? SourceUrl { get; init; }
public int Priority { get; init; }
public bool Enabled { get; init; }
public DateTimeOffset? LastSyncAt { get; init; }
public DateTimeOffset? LastSuccessAt { get; init; }
public long FreshnessAgeSeconds { get; init; }
public int FreshnessSlaSeconds { get; init; }
public string FreshnessStatus { get; init; } = "unknown";
public string SignatureStatus { get; init; } = "unsigned";
public string? LastError { get; init; }
public long SyncCount { get; init; }
public int ErrorCount { get; init; }
public long TotalAdvisories { get; init; }
public long SignedAdvisories { get; init; }
public long UnsignedAdvisories { get; init; }
public long SignatureFailureCount { get; init; }
}
public sealed record AdvisorySourceSummaryResponse
{
public int TotalSources { get; init; }
public int HealthySources { get; init; }
public int WarningSources { get; init; }
public int StaleSources { get; init; }
public int UnavailableSources { get; init; }
public int DisabledSources { get; init; }
public int ConflictingSources { get; init; }
public DateTimeOffset DataAsOf { get; init; }
}
public sealed record AdvisorySourceFreshnessResponse
{
public AdvisorySourceListItem Source { get; init; } = new();
public DateTimeOffset? LastSyncAt { get; init; }
public DateTimeOffset? LastSuccessAt { get; init; }
public string? LastError { get; init; }
public long SyncCount { get; init; }
public int ErrorCount { get; init; }
public DateTimeOffset DataAsOf { get; init; }
}

View File

@@ -909,6 +909,7 @@ app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority);
// Canonical advisory endpoints (Sprint 8200.0012.0003)
app.MapCanonicalAdvisoryEndpoints();
app.MapAdvisorySourceEndpoints();
app.MapInterestScoreEndpoints();
// Federation endpoints for site-to-site bundle sync

View File

@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0242-M | DONE | Revalidated 2026-01-07. |
| AUDIT-0242-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0242-A | DONE | Applied 2026-01-13; TimeProvider defaults, ASCII cleanup, federation tests. |
| BE8-07-API | DONE | Advisory-source freshness endpoint contract extended with advisory stats fields consumed by UI security diagnostics. |

View File

@@ -40,6 +40,7 @@ public static class ConcelierPersistenceExtensions
services.AddScoped<IAdvisoryRepository, AdvisoryRepository>();
services.AddScoped<IPostgresAdvisoryStore, PostgresAdvisoryStore>();
services.AddScoped<ISourceRepository, SourceRepository>();
services.AddScoped<IAdvisorySourceReadRepository, AdvisorySourceReadRepository>();
services.AddScoped<IAdvisoryAliasRepository, AdvisoryAliasRepository>();
services.AddScoped<IAdvisoryCvssRepository, AdvisoryCvssRepository>();
services.AddScoped<IAdvisoryAffectedRepository, AdvisoryAffectedRepository>();
@@ -87,6 +88,7 @@ public static class ConcelierPersistenceExtensions
services.AddScoped<IAdvisoryRepository, AdvisoryRepository>();
services.AddScoped<IPostgresAdvisoryStore, PostgresAdvisoryStore>();
services.AddScoped<ISourceRepository, SourceRepository>();
services.AddScoped<IAdvisorySourceReadRepository, AdvisorySourceReadRepository>();
services.AddScoped<IAdvisoryAliasRepository, AdvisoryAliasRepository>();
services.AddScoped<IAdvisoryCvssRepository, AdvisoryCvssRepository>();
services.AddScoped<IAdvisoryAffectedRepository, AdvisoryAffectedRepository>();

View File

@@ -0,0 +1,31 @@
-- Concelier migration 004: advisory source freshness projection support
-- Sprint: SPRINT_20260219_008 (BE8-04)
CREATE TABLE IF NOT EXISTS vuln.source_freshness_sla (
source_id UUID PRIMARY KEY REFERENCES vuln.sources(id) ON DELETE CASCADE,
sla_seconds INT NOT NULL DEFAULT 21600 CHECK (sla_seconds > 0),
warning_ratio NUMERIC(4,2) NOT NULL DEFAULT 0.80 CHECK (warning_ratio > 0 AND warning_ratio < 1),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT NOT NULL DEFAULT 'system'
);
COMMENT ON TABLE vuln.source_freshness_sla IS
'Freshness SLA thresholds per advisory source for advisory-sources UI contracts.';
INSERT INTO vuln.source_freshness_sla (source_id)
SELECT s.id
FROM vuln.sources s
ON CONFLICT (source_id) DO NOTHING;
CREATE INDEX IF NOT EXISTS idx_source_states_last_success_at
ON vuln.source_states (last_success_at DESC)
WHERE last_success_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_source_states_last_sync_at
ON vuln.source_states (last_sync_at DESC)
WHERE last_sync_at IS NOT NULL;
DROP TRIGGER IF EXISTS trg_source_freshness_sla_updated_at ON vuln.source_freshness_sla;
CREATE TRIGGER trg_source_freshness_sla_updated_at
BEFORE UPDATE ON vuln.source_freshness_sla
FOR EACH ROW EXECUTE FUNCTION vuln.update_updated_at();

View File

@@ -0,0 +1,73 @@
-- Concelier migration 005: advisory-source signature projection support
-- Sprint: SPRINT_20260219_008 (BE8-07)
CREATE INDEX IF NOT EXISTS idx_advisories_source_key
ON vuln.advisories (source_id, advisory_key)
WHERE source_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_source_edge_source_advisory
ON vuln.advisory_source_edge (source_id, source_advisory_id);
CREATE OR REPLACE VIEW vuln.advisory_source_signature_projection AS
WITH advisory_totals AS (
SELECT
a.source_id,
COUNT(*)::BIGINT AS total_advisories
FROM vuln.advisories a
WHERE a.source_id IS NOT NULL
GROUP BY a.source_id
),
signed_totals AS (
SELECT
a.source_id,
COUNT(*)::BIGINT AS signed_advisories
FROM vuln.advisories a
WHERE a.source_id IS NOT NULL
AND EXISTS (
SELECT 1
FROM vuln.advisory_source_edge e
WHERE e.source_id = a.source_id
AND e.source_advisory_id = a.advisory_key
AND e.dsse_envelope IS NOT NULL
AND CASE
WHEN jsonb_typeof(e.dsse_envelope->'signatures') = 'array'
THEN jsonb_array_length(e.dsse_envelope->'signatures') > 0
ELSE FALSE
END
)
GROUP BY a.source_id
),
failure_totals AS (
SELECT
ss.source_id,
CASE
WHEN ss.metadata ? 'signature_failure_count'
AND (ss.metadata->>'signature_failure_count') ~ '^[0-9]+$'
THEN (ss.metadata->>'signature_failure_count')::BIGINT
ELSE 0::BIGINT
END AS signature_failure_count
FROM vuln.source_states ss
)
SELECT
s.id AS source_id,
COALESCE(t.total_advisories, 0)::BIGINT AS total_advisories,
LEAST(
COALESCE(t.total_advisories, 0)::BIGINT,
COALESCE(st.signed_advisories, 0)::BIGINT
) AS signed_advisories,
GREATEST(
COALESCE(t.total_advisories, 0)::BIGINT
- LEAST(
COALESCE(t.total_advisories, 0)::BIGINT,
COALESCE(st.signed_advisories, 0)::BIGINT
),
0::BIGINT
) AS unsigned_advisories,
COALESCE(f.signature_failure_count, 0)::BIGINT AS signature_failure_count
FROM vuln.sources s
LEFT JOIN advisory_totals t ON t.source_id = s.id
LEFT JOIN signed_totals st ON st.source_id = s.id
LEFT JOIN failure_totals f ON f.source_id = s.id;
COMMENT ON VIEW vuln.advisory_source_signature_projection IS
'Per-source advisory totals and signature rollups for advisory-source detail diagnostics.';

View File

@@ -0,0 +1,193 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL-backed read model for advisory source freshness contracts.
/// </summary>
public sealed class AdvisorySourceReadRepository : RepositoryBase<ConcelierDataSource>, IAdvisorySourceReadRepository
{
private const string SystemTenantId = "_system";
public AdvisorySourceReadRepository(
ConcelierDataSource dataSource,
ILogger<AdvisorySourceReadRepository> logger)
: base(dataSource, logger)
{
}
public Task<IReadOnlyList<AdvisorySourceFreshnessRecord>> ListAsync(
bool includeDisabled = false,
CancellationToken cancellationToken = default)
{
const string sql = """
WITH source_projection AS (
SELECT
s.id,
s.key,
s.name,
s.source_type,
s.url,
s.priority,
s.enabled,
st.last_sync_at,
st.last_success_at,
st.last_error,
COALESCE(st.sync_count, 0) AS sync_count,
COALESCE(st.error_count, 0) AS error_count,
COALESCE(sla.sla_seconds, 21600) AS freshness_sla_seconds,
COALESCE(sla.warning_ratio, 0.80) AS warning_ratio,
COALESCE(s.metadata->>'signature_status', 'unsigned') AS signature_status,
COALESCE(sig.total_advisories, 0) AS total_advisories,
COALESCE(sig.signed_advisories, 0) AS signed_advisories,
COALESCE(sig.unsigned_advisories, 0) AS unsigned_advisories,
COALESCE(sig.signature_failure_count, 0) AS signature_failure_count
FROM vuln.sources s
LEFT JOIN vuln.source_states st ON st.source_id = s.id
LEFT JOIN vuln.source_freshness_sla sla ON sla.source_id = s.id
LEFT JOIN vuln.advisory_source_signature_projection sig ON sig.source_id = s.id
WHERE (@include_disabled OR s.enabled = TRUE)
)
SELECT
id,
key,
name,
source_type,
url,
priority,
enabled,
last_sync_at,
last_success_at,
last_error,
sync_count,
error_count,
freshness_sla_seconds,
warning_ratio,
CAST(
EXTRACT(EPOCH FROM (
NOW() - COALESCE(last_success_at, last_sync_at, NOW())
)) AS BIGINT) AS freshness_age_seconds,
CASE
WHEN last_success_at IS NULL THEN 'unavailable'
WHEN EXTRACT(EPOCH FROM (NOW() - last_success_at)) > freshness_sla_seconds THEN 'stale'
WHEN EXTRACT(EPOCH FROM (NOW() - last_success_at)) > (freshness_sla_seconds * warning_ratio) THEN 'warning'
ELSE 'healthy'
END AS freshness_status,
signature_status,
total_advisories,
signed_advisories,
unsigned_advisories,
signature_failure_count
FROM source_projection
ORDER BY enabled DESC, priority DESC, key
""";
return QueryAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "include_disabled", includeDisabled),
MapRecord,
cancellationToken);
}
public Task<AdvisorySourceFreshnessRecord?> GetBySourceIdAsync(
Guid sourceId,
CancellationToken cancellationToken = default)
{
const string sql = """
WITH source_projection AS (
SELECT
s.id,
s.key,
s.name,
s.source_type,
s.url,
s.priority,
s.enabled,
st.last_sync_at,
st.last_success_at,
st.last_error,
COALESCE(st.sync_count, 0) AS sync_count,
COALESCE(st.error_count, 0) AS error_count,
COALESCE(sla.sla_seconds, 21600) AS freshness_sla_seconds,
COALESCE(sla.warning_ratio, 0.80) AS warning_ratio,
COALESCE(s.metadata->>'signature_status', 'unsigned') AS signature_status,
COALESCE(sig.total_advisories, 0) AS total_advisories,
COALESCE(sig.signed_advisories, 0) AS signed_advisories,
COALESCE(sig.unsigned_advisories, 0) AS unsigned_advisories,
COALESCE(sig.signature_failure_count, 0) AS signature_failure_count
FROM vuln.sources s
LEFT JOIN vuln.source_states st ON st.source_id = s.id
LEFT JOIN vuln.source_freshness_sla sla ON sla.source_id = s.id
LEFT JOIN vuln.advisory_source_signature_projection sig ON sig.source_id = s.id
WHERE s.id = @source_id
)
SELECT
id,
key,
name,
source_type,
url,
priority,
enabled,
last_sync_at,
last_success_at,
last_error,
sync_count,
error_count,
freshness_sla_seconds,
warning_ratio,
CAST(
EXTRACT(EPOCH FROM (
NOW() - COALESCE(last_success_at, last_sync_at, NOW())
)) AS BIGINT) AS freshness_age_seconds,
CASE
WHEN last_success_at IS NULL THEN 'unavailable'
WHEN EXTRACT(EPOCH FROM (NOW() - last_success_at)) > freshness_sla_seconds THEN 'stale'
WHEN EXTRACT(EPOCH FROM (NOW() - last_success_at)) > (freshness_sla_seconds * warning_ratio) THEN 'warning'
ELSE 'healthy'
END AS freshness_status,
signature_status,
total_advisories,
signed_advisories,
unsigned_advisories,
signature_failure_count
FROM source_projection
""";
return QuerySingleOrDefaultAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "source_id", sourceId),
MapRecord,
cancellationToken);
}
private static AdvisorySourceFreshnessRecord MapRecord(NpgsqlDataReader reader)
{
return new AdvisorySourceFreshnessRecord(
SourceId: reader.GetGuid(0),
SourceKey: reader.GetString(1),
SourceName: reader.GetString(2),
SourceFamily: reader.GetString(3),
SourceUrl: GetNullableString(reader, 4),
Priority: reader.GetInt32(5),
Enabled: reader.GetBoolean(6),
LastSyncAt: GetNullableDateTimeOffset(reader, 7),
LastSuccessAt: GetNullableDateTimeOffset(reader, 8),
LastError: GetNullableString(reader, 9),
SyncCount: reader.GetInt64(10),
ErrorCount: reader.GetInt32(11),
FreshnessSlaSeconds: reader.GetInt32(12),
WarningRatio: reader.GetDecimal(13),
FreshnessAgeSeconds: reader.GetInt64(14),
FreshnessStatus: reader.GetString(15),
SignatureStatus: reader.GetString(16),
TotalAdvisories: reader.GetInt64(17),
SignedAdvisories: reader.GetInt64(18),
UnsignedAdvisories: reader.GetInt64(19),
SignatureFailureCount: reader.GetInt64(20));
}
}

View File

@@ -0,0 +1,38 @@
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
/// <summary>
/// Read-model repository for advisory source freshness surfaces.
/// </summary>
public interface IAdvisorySourceReadRepository
{
Task<IReadOnlyList<AdvisorySourceFreshnessRecord>> ListAsync(
bool includeDisabled = false,
CancellationToken cancellationToken = default);
Task<AdvisorySourceFreshnessRecord?> GetBySourceIdAsync(
Guid sourceId,
CancellationToken cancellationToken = default);
}
public sealed record AdvisorySourceFreshnessRecord(
Guid SourceId,
string SourceKey,
string SourceName,
string SourceFamily,
string? SourceUrl,
int Priority,
bool Enabled,
DateTimeOffset? LastSyncAt,
DateTimeOffset? LastSuccessAt,
string? LastError,
long SyncCount,
int ErrorCount,
int FreshnessSlaSeconds,
decimal WarningRatio,
long FreshnessAgeSeconds,
string FreshnessStatus,
string SignatureStatus,
long TotalAdvisories,
long SignedAdvisories,
long UnsignedAdvisories,
long SignatureFailureCount);

View File

@@ -41,6 +41,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IAdvisoryRepository, AdvisoryRepository>();
services.AddScoped<IPostgresAdvisoryStore, PostgresAdvisoryStore>();
services.AddScoped<ISourceRepository, SourceRepository>();
services.AddScoped<IAdvisorySourceReadRepository, AdvisorySourceReadRepository>();
services.AddScoped<IAdvisoryAliasRepository, AdvisoryAliasRepository>();
services.AddScoped<IAdvisoryCvssRepository, AdvisoryCvssRepository>();
services.AddScoped<IAdvisoryAffectedRepository, AdvisoryAffectedRepository>();
@@ -90,6 +91,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IAdvisoryRepository, AdvisoryRepository>();
services.AddScoped<IPostgresAdvisoryStore, PostgresAdvisoryStore>();
services.AddScoped<ISourceRepository, SourceRepository>();
services.AddScoped<IAdvisorySourceReadRepository, AdvisorySourceReadRepository>();
services.AddScoped<IAdvisoryAliasRepository, AdvisoryAliasRepository>();
services.AddScoped<IAdvisoryCvssRepository, AdvisoryCvssRepository>();
services.AddScoped<IAdvisoryAffectedRepository, AdvisoryAffectedRepository>();

View File

@@ -11,3 +11,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| CICD-VAL-SMOKE-001 | DONE | Smoke validation: restore reference summaries from raw payload. |
| TASK-015-011 | DONE | Added enriched SBOM storage table + Postgres repository. |
| TASK-015-007d | DONE | Added license indexes and repository queries for license inventory. |
| BE8-07 | DONE | Added migration `005_add_advisory_source_signature_projection.sql` and advisory-source signature stats projection fields for UI detail diagnostics. |

View File

@@ -0,0 +1,324 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Persistence.Postgres.Models;
using StellaOps.Concelier.Persistence.Postgres.Repositories;
using StellaOps.Concelier.WebService.Extensions;
using StellaOps.Concelier.WebService.Options;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Concelier.WebService.Tests;
public sealed class AdvisorySourceWebAppFactory : WebApplicationFactory<Program>
{
public AdvisorySourceWebAppFactory()
{
Environment.SetEnvironmentVariable("CONCELIER__POSTGRESSTORAGE__CONNECTIONSTRING", "Host=localhost;Port=5432;Database=test-advisory-sources");
Environment.SetEnvironmentVariable("CONCELIER__POSTGRESSTORAGE__COMMANDTIMEOUTSECONDS", "30");
Environment.SetEnvironmentVariable("CONCELIER__TELEMETRY__ENABLED", "false");
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "Host=localhost;Port=5432;Database=test-advisory-sources");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((_, config) =>
{
var overrides = new Dictionary<string, string?>
{
{ "PostgresStorage:ConnectionString", "Host=localhost;Port=5432;Database=test-advisory-sources" },
{ "PostgresStorage:CommandTimeoutSeconds", "30" },
{ "Telemetry:Enabled", "false" }
};
config.AddInMemoryCollection(overrides);
});
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, static _ => { });
services.AddAuthorization(options =>
{
// Endpoint behavior in this test suite focuses on tenant/header/repository behavior.
// Authorization policy is exercised in dedicated auth coverage tests.
options.AddPolicy("Concelier.Advisories.Read", policy => policy.RequireAssertion(static _ => true));
});
services.RemoveAll<ILeaseStore>();
services.AddSingleton<ILeaseStore, Fixtures.TestLeaseStore>();
services.RemoveAll<IAdvisorySourceReadRepository>();
services.AddSingleton<IAdvisorySourceReadRepository, StubAdvisorySourceReadRepository>();
services.RemoveAll<ISourceRepository>();
services.AddSingleton<ISourceRepository, StubSourceRepository>();
services.AddSingleton<ConcelierOptions>(new ConcelierOptions
{
PostgresStorage = new ConcelierOptions.PostgresStorageOptions
{
ConnectionString = "Host=localhost;Port=5432;Database=test-advisory-sources",
CommandTimeoutSeconds = 30
},
Telemetry = new ConcelierOptions.TelemetryOptions
{
Enabled = false
}
});
services.AddSingleton<IConfigureOptions<ConcelierOptions>>(_ => new ConfigureOptions<ConcelierOptions>(opts =>
{
opts.PostgresStorage ??= new ConcelierOptions.PostgresStorageOptions();
opts.PostgresStorage.ConnectionString = "Host=localhost;Port=5432;Database=test-advisory-sources";
opts.PostgresStorage.CommandTimeoutSeconds = 30;
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
opts.Telemetry.Enabled = false;
}));
});
}
private sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "AdvisorySourceTests";
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "advisory-source-tests")
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
private sealed class StubAdvisorySourceReadRepository : IAdvisorySourceReadRepository
{
private static readonly AdvisorySourceFreshnessRecord[] Records =
[
new(
SourceId: Guid.Parse("0e94e643-4045-4af8-b0c4-f4f04f769279"),
SourceKey: "nvd",
SourceName: "NVD",
SourceFamily: "nvd",
SourceUrl: "https://nvd.nist.gov",
Priority: 100,
Enabled: true,
LastSyncAt: DateTimeOffset.Parse("2026-02-19T08:11:00Z"),
LastSuccessAt: DateTimeOffset.Parse("2026-02-19T08:10:00Z"),
LastError: null,
SyncCount: 220,
ErrorCount: 1,
FreshnessSlaSeconds: 14400,
WarningRatio: 0.8m,
FreshnessAgeSeconds: 3600,
FreshnessStatus: "healthy",
SignatureStatus: "signed",
TotalAdvisories: 220,
SignedAdvisories: 215,
UnsignedAdvisories: 5,
SignatureFailureCount: 1),
new(
SourceId: Guid.Parse("fc9d6356-01d8-4012-8ce7-31e0f983f8c3"),
SourceKey: "ghsa",
SourceName: "GHSA",
SourceFamily: "ghsa",
SourceUrl: "https://github.com/advisories",
Priority: 80,
Enabled: false,
LastSyncAt: DateTimeOffset.Parse("2026-02-19T01:00:00Z"),
LastSuccessAt: DateTimeOffset.Parse("2026-02-18T20:30:00Z"),
LastError: "timeout",
SyncCount: 200,
ErrorCount: 8,
FreshnessSlaSeconds: 14400,
WarningRatio: 0.8m,
FreshnessAgeSeconds: 43200,
FreshnessStatus: "stale",
SignatureStatus: "unsigned",
TotalAdvisories: 200,
SignedAdvisories: 0,
UnsignedAdvisories: 200,
SignatureFailureCount: 0)
];
public Task<IReadOnlyList<AdvisorySourceFreshnessRecord>> ListAsync(
bool includeDisabled = false,
CancellationToken cancellationToken = default)
{
IReadOnlyList<AdvisorySourceFreshnessRecord> items = includeDisabled
? Records
: Records.Where(static record => record.Enabled).ToList();
return Task.FromResult(items);
}
public Task<AdvisorySourceFreshnessRecord?> GetBySourceIdAsync(
Guid sourceId,
CancellationToken cancellationToken = default)
{
return Task.FromResult(Records.FirstOrDefault(record => record.SourceId == sourceId));
}
}
private sealed class StubSourceRepository : ISourceRepository
{
private static readonly IReadOnlyList<SourceEntity> Sources =
[
new SourceEntity
{
Id = Guid.Parse("0e94e643-4045-4af8-b0c4-f4f04f769279"),
Key = "nvd",
Name = "NVD",
SourceType = "nvd",
Url = "https://nvd.nist.gov",
Priority = 100,
Enabled = true,
CreatedAt = DateTimeOffset.Parse("2026-02-18T00:00:00Z"),
UpdatedAt = DateTimeOffset.Parse("2026-02-19T00:00:00Z")
}
];
public Task<SourceEntity> UpsertAsync(SourceEntity source, CancellationToken cancellationToken = default)
=> Task.FromResult(source);
public Task<SourceEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
=> Task.FromResult(Sources.FirstOrDefault(source => source.Id == id));
public Task<SourceEntity?> GetByKeyAsync(string key, CancellationToken cancellationToken = default)
=> Task.FromResult(Sources.FirstOrDefault(source => string.Equals(source.Key, key, StringComparison.OrdinalIgnoreCase)));
public Task<IReadOnlyList<SourceEntity>> ListAsync(bool? enabled = null, CancellationToken cancellationToken = default)
{
var items = Sources
.Where(source => enabled is null || source.Enabled == enabled.Value)
.ToList();
return Task.FromResult<IReadOnlyList<SourceEntity>>(items);
}
}
}
public sealed class AdvisorySourceEndpointsTests : IClassFixture<AdvisorySourceWebAppFactory>
{
private readonly AdvisorySourceWebAppFactory _factory;
public AdvisorySourceEndpointsTests(AdvisorySourceWebAppFactory factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ListEndpoints_WithoutTenantHeader_ReturnsBadRequest()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v1/advisory-sources", CancellationToken.None);
Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ListEndpoints_WithTenantHeader_ReturnsRecords()
{
using var client = CreateTenantClient();
var response = await client.GetAsync("/api/v1/advisory-sources?includeDisabled=true", CancellationToken.None);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<AdvisorySourceListResponse>(cancellationToken: CancellationToken.None);
Assert.NotNull(payload);
Assert.Equal(2, payload!.TotalCount);
Assert.Contains(payload.Items, static item => item.SourceKey == "nvd");
Assert.Contains(payload.Items, static item => item.SourceKey == "ghsa");
var nvd = payload.Items.Single(static item => item.SourceKey == "nvd");
Assert.Equal(220, nvd.TotalAdvisories);
Assert.Equal(215, nvd.SignedAdvisories);
Assert.Equal(5, nvd.UnsignedAdvisories);
Assert.Equal(1, nvd.SignatureFailureCount);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SummaryEndpoint_ReturnsExpectedCounts()
{
using var client = CreateTenantClient();
var response = await client.GetAsync("/api/v1/advisory-sources/summary", CancellationToken.None);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<AdvisorySourceSummaryResponse>(cancellationToken: CancellationToken.None);
Assert.NotNull(payload);
Assert.Equal(2, payload!.TotalSources);
Assert.Equal(1, payload.HealthySources);
Assert.Equal(1, payload.StaleSources);
Assert.Equal(1, payload.DisabledSources);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FreshnessEndpoint_ByKey_ReturnsRecord()
{
using var client = CreateTenantClient();
var response = await client.GetAsync("/api/v1/advisory-sources/nvd/freshness", CancellationToken.None);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<AdvisorySourceFreshnessResponse>(cancellationToken: CancellationToken.None);
Assert.NotNull(payload);
Assert.Equal("nvd", payload!.Source.SourceKey);
Assert.Equal("healthy", payload.Source.FreshnessStatus);
Assert.Equal(220, payload.Source.TotalAdvisories);
Assert.Equal(215, payload.Source.SignedAdvisories);
Assert.Equal(5, payload.Source.UnsignedAdvisories);
Assert.Equal(1, payload.Source.SignatureFailureCount);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FreshnessEndpoint_UnknownSource_ReturnsNotFound()
{
using var client = CreateTenantClient();
var response = await client.GetAsync("/api/v1/advisory-sources/unknown-source/freshness", CancellationToken.None);
Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode);
}
private HttpClient CreateTenantClient()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-a");
return client;
}
}

View File

@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0243-M | DONE | Revalidated 2026-01-07. |
| AUDIT-0243-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0243-A | DONE | Waived (test project; revalidated 2026-01-07). |
| BE8-07-TEST | DONE | Extended advisory-source endpoint coverage for total/signed/unsigned/signature-failure contract fields. |