Gaps fill up, fixes, ui restructuring
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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();
|
||||
@@ -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.';
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>();
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user