Close scratch iteration 009 grouped policy and VEX audit repairs
This commit is contained in:
@@ -10,6 +10,7 @@ public static class GovernanceCompatibilityEndpoints
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, TrustWeightConfigState> TrustWeightStates = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly ConcurrentDictionary<string, StalenessConfigState> StalenessStates = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly ConcurrentDictionary<string, PolicyConflictState> ConflictStates = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static void MapGovernanceCompatibilityEndpoints(this WebApplication app)
|
||||
{
|
||||
@@ -116,6 +117,56 @@ public static class GovernanceCompatibilityEndpoints
|
||||
var state = StalenessStates.GetOrAdd(scope.Key, _ => CreateDefaultStalenessState(scope.TenantId, scope.ProjectId, timeProvider));
|
||||
return Results.Ok(state.BuildStatus());
|
||||
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
|
||||
|
||||
governance.MapGet("/conflicts/dashboard", (
|
||||
HttpContext context,
|
||||
[FromQuery] string? projectId,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var scope = ResolveScope(context, projectId);
|
||||
var state = ConflictStates.GetOrAdd(scope.Key, _ => CreateDefaultConflictState(scope.TenantId, scope.ProjectId, timeProvider));
|
||||
return Results.Ok(state.ToDashboard());
|
||||
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
|
||||
|
||||
governance.MapGet("/conflicts", (
|
||||
HttpContext context,
|
||||
[FromQuery] string? projectId,
|
||||
[FromQuery] string? type,
|
||||
[FromQuery] string? severity,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var scope = ResolveScope(context, projectId);
|
||||
var state = ConflictStates.GetOrAdd(scope.Key, _ => CreateDefaultConflictState(scope.TenantId, scope.ProjectId, timeProvider));
|
||||
return Results.Ok(state.List(type, severity));
|
||||
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
|
||||
|
||||
governance.MapPost("/conflicts/{conflictId}/resolve", (
|
||||
HttpContext context,
|
||||
string conflictId,
|
||||
[FromBody] ConflictResolutionWriteModel request,
|
||||
[FromQuery] string? projectId,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var scope = ResolveScope(context, projectId);
|
||||
var state = ConflictStates.GetOrAdd(scope.Key, _ => CreateDefaultConflictState(scope.TenantId, scope.ProjectId, timeProvider));
|
||||
var actor = StellaOpsTenantResolver.ResolveActor(context);
|
||||
var updated = state.Resolve(conflictId, request.Resolution, actor, timeProvider);
|
||||
return updated is null ? Results.NotFound() : Results.Ok(updated);
|
||||
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
|
||||
|
||||
governance.MapPost("/conflicts/{conflictId}/ignore", (
|
||||
HttpContext context,
|
||||
string conflictId,
|
||||
[FromBody] ConflictIgnoreWriteModel request,
|
||||
[FromQuery] string? projectId,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var scope = ResolveScope(context, projectId);
|
||||
var state = ConflictStates.GetOrAdd(scope.Key, _ => CreateDefaultConflictState(scope.TenantId, scope.ProjectId, timeProvider));
|
||||
var actor = StellaOpsTenantResolver.ResolveActor(context);
|
||||
var updated = state.Ignore(conflictId, request.Reason, actor, timeProvider);
|
||||
return updated is null ? Results.NotFound() : Results.Ok(updated);
|
||||
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
|
||||
}
|
||||
|
||||
private static GovernanceScope ResolveScope(HttpContext context, string? projectId)
|
||||
@@ -168,6 +219,50 @@ public static class GovernanceCompatibilityEndpoints
|
||||
]);
|
||||
}
|
||||
|
||||
private static PolicyConflictState CreateDefaultConflictState(string tenantId, string? projectId, TimeProvider timeProvider)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return new PolicyConflictState(
|
||||
tenantId,
|
||||
projectId,
|
||||
now.ToString("O"),
|
||||
new List<PolicyConflictRecord>
|
||||
{
|
||||
new(
|
||||
"conflict-001",
|
||||
"rule_overlap",
|
||||
"warning",
|
||||
"Overlapping severity rules in profiles",
|
||||
"The strict and default profiles both escalate exploitable high CVSS findings, creating ambiguous severity outcomes for shared scopes.",
|
||||
new PolicyConflictSourceRecord("profile-default", "profile", "Default Risk Profile", "1.0.0", "severityOverrides[0]"),
|
||||
new PolicyConflictSourceRecord("profile-strict", "profile", "Strict Security Profile", "1.1.0", "severityOverrides[0]"),
|
||||
new[] { "production", "staging" },
|
||||
"High reachability findings may oscillate between warn and block decisions across identical releases.",
|
||||
"Consolidate the overlapping rules or assign a strict precedence order.",
|
||||
now.AddHours(-8).ToString("O"),
|
||||
"open",
|
||||
null,
|
||||
null,
|
||||
null),
|
||||
new(
|
||||
"conflict-002",
|
||||
"precedence_ambiguity",
|
||||
"info",
|
||||
"Ambiguous rule precedence",
|
||||
"Two release gate rules with the same priority can evaluate the same evidence set in a non-deterministic order.",
|
||||
new PolicyConflictSourceRecord("gate-cvss-high", "rule", "CVSS High Escalation", null, "rules[2]"),
|
||||
new PolicyConflictSourceRecord("gate-exploit-available", "rule", "Exploit Available Escalation", null, "rules[5]"),
|
||||
new[] { "all" },
|
||||
"Operators may see inconsistent explain traces between runs with identical inputs.",
|
||||
"Assign distinct priorities so replay and live evaluation remain identical.",
|
||||
now.AddDays(-1).ToString("O"),
|
||||
"acknowledged",
|
||||
now.AddHours(-4).ToString("O"),
|
||||
"policy-reviewer",
|
||||
"Captured during route-action scratch verification.")
|
||||
});
|
||||
}
|
||||
|
||||
private static List<StalenessThresholdRecord> BuildThresholds(int fresh, int aging, int stale, int expired) =>
|
||||
[
|
||||
new("fresh", fresh, "low", [new StalenessActionRecord("warn", "Still within freshness SLA.")]),
|
||||
@@ -308,6 +403,130 @@ public static class GovernanceCompatibilityEndpoints
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private sealed class PolicyConflictState(
|
||||
string tenantId,
|
||||
string? projectId,
|
||||
string lastAnalyzedAt,
|
||||
List<PolicyConflictRecord> conflicts)
|
||||
{
|
||||
public string TenantId { get; private set; } = tenantId;
|
||||
public string? ProjectId { get; private set; } = projectId;
|
||||
public string LastAnalyzedAt { get; private set; } = lastAnalyzedAt;
|
||||
public List<PolicyConflictRecord> Conflicts { get; } = conflicts;
|
||||
|
||||
public object ToDashboard()
|
||||
{
|
||||
var byType = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["rule_overlap"] = 0,
|
||||
["precedence_ambiguity"] = 0,
|
||||
["circular_dependency"] = 0,
|
||||
["incompatible_actions"] = 0,
|
||||
["scope_collision"] = 0
|
||||
};
|
||||
var bySeverity = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["info"] = 0,
|
||||
["warning"] = 0,
|
||||
["error"] = 0,
|
||||
["critical"] = 0
|
||||
};
|
||||
|
||||
foreach (var conflict in Conflicts)
|
||||
{
|
||||
byType[conflict.Type] = byType.GetValueOrDefault(conflict.Type) + 1;
|
||||
bySeverity[conflict.Severity] = bySeverity.GetValueOrDefault(conflict.Severity) + 1;
|
||||
}
|
||||
|
||||
var trend = Enumerable.Range(0, 7)
|
||||
.Select(offset => DateTimeOffset.Parse(LastAnalyzedAt).UtcDateTime.Date.AddDays(offset - 6))
|
||||
.Select(day => new
|
||||
{
|
||||
date = day.ToString("yyyy-MM-dd"),
|
||||
count = Conflicts.Count(conflict =>
|
||||
DateTimeOffset.Parse(conflict.DetectedAt).UtcDateTime.Date == day)
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
return new
|
||||
{
|
||||
totalConflicts = Conflicts.Count,
|
||||
openConflicts = Conflicts.Count(conflict => string.Equals(conflict.Status, "open", StringComparison.OrdinalIgnoreCase)),
|
||||
byType,
|
||||
bySeverity,
|
||||
recentConflicts = Conflicts
|
||||
.OrderByDescending(conflict => conflict.DetectedAt, StringComparer.Ordinal)
|
||||
.Take(5)
|
||||
.ToArray(),
|
||||
trend,
|
||||
lastAnalyzedAt = LastAnalyzedAt
|
||||
};
|
||||
}
|
||||
|
||||
public object[] List(string? type, string? severity)
|
||||
{
|
||||
IEnumerable<PolicyConflictRecord> query = Conflicts;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(type))
|
||||
{
|
||||
query = query.Where(conflict => string.Equals(conflict.Type, type.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(severity))
|
||||
{
|
||||
query = query.Where(conflict => string.Equals(conflict.Severity, severity.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return query
|
||||
.OrderByDescending(conflict => conflict.DetectedAt, StringComparer.Ordinal)
|
||||
.ToArray<object>();
|
||||
}
|
||||
|
||||
public object? Resolve(string conflictId, string? resolution, string actor, TimeProvider timeProvider)
|
||||
{
|
||||
var index = Conflicts.FindIndex(conflict => string.Equals(conflict.Id, conflictId, StringComparison.OrdinalIgnoreCase));
|
||||
if (index < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow().ToString("O");
|
||||
var current = Conflicts[index];
|
||||
var updated = current with
|
||||
{
|
||||
Status = "resolved",
|
||||
ResolvedAt = now,
|
||||
ResolvedBy = actor,
|
||||
ResolutionNotes = string.IsNullOrWhiteSpace(resolution) ? current.SuggestedResolution : resolution.Trim()
|
||||
};
|
||||
Conflicts[index] = updated;
|
||||
LastAnalyzedAt = now;
|
||||
return updated;
|
||||
}
|
||||
|
||||
public object? Ignore(string conflictId, string? reason, string actor, TimeProvider timeProvider)
|
||||
{
|
||||
var index = Conflicts.FindIndex(conflict => string.Equals(conflict.Id, conflictId, StringComparison.OrdinalIgnoreCase));
|
||||
if (index < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow().ToString("O");
|
||||
var current = Conflicts[index];
|
||||
var updated = current with
|
||||
{
|
||||
Status = "ignored",
|
||||
ResolvedAt = now,
|
||||
ResolvedBy = actor,
|
||||
ResolutionNotes = string.IsNullOrWhiteSpace(reason) ? "Ignored by operator." : reason.Trim()
|
||||
};
|
||||
Conflicts[index] = updated;
|
||||
LastAnalyzedAt = now;
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeSource(string? source) =>
|
||||
string.IsNullOrWhiteSpace(source) ? "custom" : source.Trim().ToLowerInvariant();
|
||||
|
||||
@@ -350,6 +569,34 @@ public static class GovernanceCompatibilityEndpoints
|
||||
string[]? Channels = null);
|
||||
}
|
||||
|
||||
public sealed record ConflictResolutionWriteModel(string? Resolution);
|
||||
|
||||
public sealed record ConflictIgnoreWriteModel(string? Reason);
|
||||
|
||||
public sealed record PolicyConflictSourceRecord(
|
||||
string Id,
|
||||
string Type,
|
||||
string Name,
|
||||
string? Version,
|
||||
string? Path);
|
||||
|
||||
public sealed record PolicyConflictRecord(
|
||||
string Id,
|
||||
string Type,
|
||||
string Severity,
|
||||
string Summary,
|
||||
string Description,
|
||||
PolicyConflictSourceRecord SourceA,
|
||||
PolicyConflictSourceRecord SourceB,
|
||||
IReadOnlyList<string> AffectedScope,
|
||||
string ImpactAssessment,
|
||||
string? SuggestedResolution,
|
||||
string DetectedAt,
|
||||
string Status,
|
||||
string? ResolvedAt,
|
||||
string? ResolvedBy,
|
||||
string? ResolutionNotes);
|
||||
|
||||
public sealed record TrustWeightWriteModel
|
||||
{
|
||||
public string? Id { get; init; }
|
||||
|
||||
@@ -109,4 +109,49 @@ public sealed class GovernanceCompatibilityEndpointsTests : IClassFixture<TestPo
|
||||
Assert.True(status.ValueKind == JsonValueKind.Array);
|
||||
Assert.True(status.GetArrayLength() > 0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ConflictsEndpoints_ReturnDashboardAndFilteredList()
|
||||
{
|
||||
var dashboardResponse = await _client.GetAsync("/api/v1/governance/conflicts/dashboard", TestContext.Current.CancellationToken);
|
||||
dashboardResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var dashboard = await dashboardResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||
Assert.True(dashboard.GetProperty("totalConflicts").GetInt32() >= 2);
|
||||
Assert.True(dashboard.GetProperty("byType").TryGetProperty("rule_overlap", out _));
|
||||
Assert.True(dashboard.GetProperty("trend").GetArrayLength() == 7);
|
||||
|
||||
var listResponse = await _client.GetAsync("/api/v1/governance/conflicts?severity=warning", TestContext.Current.CancellationToken);
|
||||
listResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var conflicts = await listResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||
Assert.True(conflicts.ValueKind == JsonValueKind.Array);
|
||||
Assert.All(conflicts.EnumerateArray(), item => Assert.Equal("warning", item.GetProperty("severity").GetString()));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ConflictResolutionEndpoints_PersistUpdatedStatus()
|
||||
{
|
||||
var resolveResponse = await _client.PostAsJsonAsync(
|
||||
"/api/v1/governance/conflicts/conflict-001/resolve",
|
||||
new { resolution = "Consolidated precedence ordering" },
|
||||
TestContext.Current.CancellationToken);
|
||||
resolveResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var resolved = await resolveResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||
Assert.Equal("resolved", resolved.GetProperty("status").GetString());
|
||||
Assert.Equal("Consolidated precedence ordering", resolved.GetProperty("resolutionNotes").GetString());
|
||||
|
||||
var ignoreResponse = await _client.PostAsJsonAsync(
|
||||
"/api/v1/governance/conflicts/conflict-002/ignore",
|
||||
new { reason = "Accepted for lab-only profile" },
|
||||
TestContext.Current.CancellationToken);
|
||||
ignoreResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var ignored = await ignoreResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||
Assert.Equal("ignored", ignored.GetProperty("status").GetString());
|
||||
Assert.Equal("Accepted for lab-only profile", ignored.GetProperty("resolutionNotes").GetString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using StellaOps.VexHub.Core.Export;
|
||||
using StellaOps.VexHub.Core.Models;
|
||||
using StellaOps.VexHub.WebService.Models;
|
||||
using StellaOps.VexHub.WebService.Security;
|
||||
using StellaOps.VexLens.Models;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -86,7 +87,7 @@ public static class VexHubEndpointExtensions
|
||||
string cveId,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
IVexStatementRepository repository,
|
||||
[FromServices] IVexStatementRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statements = await repository.GetByCveAsync(cveId, limit ?? 100, offset ?? 0, cancellationToken);
|
||||
@@ -107,7 +108,7 @@ public static class VexHubEndpointExtensions
|
||||
string purl,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
IVexStatementRepository repository,
|
||||
[FromServices] IVexStatementRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// URL decode the PURL
|
||||
@@ -130,7 +131,7 @@ public static class VexHubEndpointExtensions
|
||||
string sourceId,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
IVexStatementRepository repository,
|
||||
[FromServices] IVexStatementRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statements = await repository.GetBySourceAsync(sourceId, limit ?? 100, offset ?? 0, cancellationToken);
|
||||
@@ -149,7 +150,7 @@ public static class VexHubEndpointExtensions
|
||||
|
||||
private static async Task<IResult> GetById(
|
||||
Guid id,
|
||||
IVexStatementRepository repository,
|
||||
[FromServices] IVexStatementRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statement = await repository.GetByIdAsync(id, cancellationToken);
|
||||
@@ -168,7 +169,7 @@ public static class VexHubEndpointExtensions
|
||||
[FromQuery] bool? isFlagged,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
IVexStatementRepository repository,
|
||||
[FromServices] IVexStatementRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = new VexStatementFilter
|
||||
@@ -192,7 +193,9 @@ public static class VexHubEndpointExtensions
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetStats(
|
||||
IVexStatementRepository repository,
|
||||
[FromServices] IVexSourceRepository sourceRepository,
|
||||
[FromServices] IVexStatementRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var totalCount = await repository.GetCountAsync(cancellationToken: cancellationToken);
|
||||
@@ -202,22 +205,123 @@ public static class VexHubEndpointExtensions
|
||||
var flaggedCount = await repository.GetCountAsync(
|
||||
new VexStatementFilter { IsFlagged = true },
|
||||
cancellationToken);
|
||||
var allSources = await sourceRepository.GetAllAsync(cancellationToken);
|
||||
var recentStatements = await repository.SearchAsync(
|
||||
new VexStatementFilter(),
|
||||
limit: 10_000,
|
||||
offset: null,
|
||||
cancellationToken);
|
||||
|
||||
var byStatus = recentStatements
|
||||
.GroupBy(statement => FormatStatusKey(statement.Status), StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => (long)group.Count(), StringComparer.OrdinalIgnoreCase);
|
||||
EnsureStatusBuckets(byStatus);
|
||||
|
||||
var bySource = recentStatements
|
||||
.GroupBy(statement => ResolveSourceBucket(statement.SourceId, allSources), StringComparer.OrdinalIgnoreCase)
|
||||
.OrderByDescending(group => group.Count())
|
||||
.ThenBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => (long)group.Count(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var recentActivity = recentStatements
|
||||
.OrderByDescending(statement => statement.SourceUpdatedAt ?? statement.UpdatedAt ?? statement.IngestedAt)
|
||||
.ThenBy(statement => statement.SourceId, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(20)
|
||||
.Select(statement => new VexHubActivityItem
|
||||
{
|
||||
StatementId = statement.Id,
|
||||
CveId = statement.VulnerabilityId,
|
||||
Action = ResolveActivityAction(statement),
|
||||
Timestamp = statement.SourceUpdatedAt ?? statement.UpdatedAt ?? statement.IngestedAt
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var trendWindowStart = timeProvider.GetUtcNow().UtcDateTime.Date.AddDays(-6);
|
||||
var trends = Enumerable.Range(0, 7)
|
||||
.Select(offset => trendWindowStart.AddDays(offset))
|
||||
.Select(day =>
|
||||
{
|
||||
var dayStatements = recentStatements
|
||||
.Where(statement => (statement.SourceUpdatedAt ?? statement.UpdatedAt ?? statement.IngestedAt).UtcDateTime.Date == day)
|
||||
.ToArray();
|
||||
|
||||
return new VexHubTrendPoint
|
||||
{
|
||||
Date = DateOnly.FromDateTime(day),
|
||||
Affected = dayStatements.Count(statement => statement.Status == VexStatus.Affected),
|
||||
NotAffected = dayStatements.Count(statement => statement.Status == VexStatus.NotAffected),
|
||||
Fixed = dayStatements.Count(statement => statement.Status == VexStatus.Fixed),
|
||||
Investigating = dayStatements.Count(statement => statement.Status == VexStatus.UnderInvestigation)
|
||||
};
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
return Results.Ok(new VexHubStats
|
||||
{
|
||||
TotalStatements = totalCount,
|
||||
VerifiedStatements = verifiedCount,
|
||||
FlaggedStatements = flaggedCount,
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
ByStatus = byStatus,
|
||||
BySource = bySource,
|
||||
RecentActivity = recentActivity,
|
||||
Trends = trends,
|
||||
GeneratedAt = timeProvider.GetUtcNow()
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetIndex()
|
||||
private static void EnsureStatusBuckets(IDictionary<string, long> byStatus)
|
||||
{
|
||||
foreach (var key in new[] { "affected", "not_affected", "fixed", "under_investigation" })
|
||||
{
|
||||
if (!byStatus.ContainsKey(key))
|
||||
{
|
||||
byStatus[key] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveSourceBucket(string sourceId, IReadOnlyList<VexSource> allSources)
|
||||
{
|
||||
var source = allSources.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.SourceId, sourceId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return source?.IssuerCategory switch
|
||||
{
|
||||
IssuerCategory.Vendor => "vendor",
|
||||
IssuerCategory.Community => "community",
|
||||
IssuerCategory.Distributor => "distributor",
|
||||
IssuerCategory.Internal => "internal",
|
||||
IssuerCategory.Aggregator => "aggregator",
|
||||
_ => string.IsNullOrWhiteSpace(sourceId) ? "unknown" : sourceId
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveActivityAction(AggregatedVexStatement statement)
|
||||
{
|
||||
var updatedAt = statement.SourceUpdatedAt ?? statement.UpdatedAt;
|
||||
if (updatedAt.HasValue && updatedAt.Value > statement.IngestedAt.AddMinutes(1))
|
||||
{
|
||||
return "updated";
|
||||
}
|
||||
|
||||
return statement.IsFlagged ? "superseded" : "created";
|
||||
}
|
||||
|
||||
private static string FormatStatusKey(VexStatus status) => status switch
|
||||
{
|
||||
VexStatus.NotAffected => "not_affected",
|
||||
VexStatus.Affected => "affected",
|
||||
VexStatus.Fixed => "fixed",
|
||||
VexStatus.UnderInvestigation => "under_investigation",
|
||||
_ => status.ToString().ToLowerInvariant()
|
||||
};
|
||||
|
||||
private static IResult GetIndex([FromServices] TimeProvider timeProvider)
|
||||
{
|
||||
return Results.Ok(new VexIndexManifest
|
||||
{
|
||||
Version = "1.0",
|
||||
LastUpdated = DateTimeOffset.UtcNow,
|
||||
LastUpdated = timeProvider.GetUtcNow(),
|
||||
Endpoints = new VexIndexEndpoints
|
||||
{
|
||||
ByCve = "/api/v1/vex/cve/{cve}",
|
||||
|
||||
@@ -32,9 +32,30 @@ public sealed class VexHubStats
|
||||
public required long TotalStatements { get; init; }
|
||||
public required long VerifiedStatements { get; init; }
|
||||
public required long FlaggedStatements { get; init; }
|
||||
public required IReadOnlyDictionary<string, long> ByStatus { get; init; }
|
||||
public required IReadOnlyDictionary<string, long> BySource { get; init; }
|
||||
public required IReadOnlyList<VexHubActivityItem> RecentActivity { get; init; }
|
||||
public required IReadOnlyList<VexHubTrendPoint> Trends { get; init; }
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class VexHubActivityItem
|
||||
{
|
||||
public required Guid StatementId { get; init; }
|
||||
public required string CveId { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
public sealed class VexHubTrendPoint
|
||||
{
|
||||
public required DateOnly Date { get; init; }
|
||||
public required int Affected { get; init; }
|
||||
public required int NotAffected { get; init; }
|
||||
public required int Fixed { get; init; }
|
||||
public required int Investigating { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX Hub index manifest for tool integration.
|
||||
/// </summary>
|
||||
|
||||
@@ -121,10 +121,9 @@ public partial class VexHubDbContext : DbContext
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
entity.Property(e => e.ContentDigest).HasColumnName("content_digest");
|
||||
|
||||
// search_vector is maintained by a DB trigger; EF should not write to it
|
||||
entity.Property(e => e.SearchVector)
|
||||
.HasColumnType("tsvector")
|
||||
.HasColumnName("search_vector");
|
||||
// search_vector is maintained by a DB trigger and is not used by the EF repositories.
|
||||
// Keeping it as a mapped string property breaks runtime model validation for Npgsql.
|
||||
entity.Ignore(e => e.SearchVector);
|
||||
});
|
||||
|
||||
// ── conflicts ────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.VexHub.Core;
|
||||
using StellaOps.VexHub.Persistence.Postgres;
|
||||
@@ -22,6 +23,13 @@ public static class VexHubPersistenceExtensions
|
||||
services.Configure<PostgresOptions>(configuration.GetSection("Postgres"));
|
||||
|
||||
services.AddSingleton<VexHubDataSource>();
|
||||
services.AddStartupMigrations(
|
||||
VexHubDataSource.DefaultSchemaName,
|
||||
"VexHub.Persistence",
|
||||
typeof(VexHubDataSource).Assembly);
|
||||
services.AddScoped<IVexSourceRepository, PostgresVexSourceRepository>();
|
||||
services.AddScoped<IVexConflictRepository, PostgresVexConflictRepository>();
|
||||
services.AddScoped<IVexIngestionJobRepository, PostgresVexIngestionJobRepository>();
|
||||
services.AddScoped<IVexStatementRepository, PostgresVexStatementRepository>();
|
||||
services.AddScoped<IVexProvenanceRepository, PostgresVexProvenanceRepository>();
|
||||
|
||||
@@ -38,6 +46,13 @@ public static class VexHubPersistenceExtensions
|
||||
services.Configure(configureOptions);
|
||||
|
||||
services.AddSingleton<VexHubDataSource>();
|
||||
services.AddStartupMigrations(
|
||||
VexHubDataSource.DefaultSchemaName,
|
||||
"VexHub.Persistence",
|
||||
typeof(VexHubDataSource).Assembly);
|
||||
services.AddScoped<IVexSourceRepository, PostgresVexSourceRepository>();
|
||||
services.AddScoped<IVexConflictRepository, PostgresVexConflictRepository>();
|
||||
services.AddScoped<IVexIngestionJobRepository, PostgresVexIngestionJobRepository>();
|
||||
services.AddScoped<IVexStatementRepository, PostgresVexStatementRepository>();
|
||||
services.AddScoped<IVexProvenanceRepository, PostgresVexProvenanceRepository>();
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.VexHub.Core;
|
||||
using StellaOps.VexHub.Core.Models;
|
||||
|
||||
namespace StellaOps.VexHub.Persistence.Postgres.Repositories;
|
||||
|
||||
using EfModels = StellaOps.VexHub.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL (EF Core) implementation of the VEX conflict repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresVexConflictRepository : IVexConflictRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly VexHubDataSource _dataSource;
|
||||
|
||||
public PostgresVexConflictRepository(VexHubDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
}
|
||||
|
||||
public async Task<VexConflict> AddAsync(VexConflict conflict, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = ToEntity(conflict);
|
||||
dbContext.Conflicts.Add(entity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<VexConflict?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.Conflicts
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(conflict => conflict.Id == id, cancellationToken);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<VexConflict>> GetByVulnerabilityProductAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entities = await dbContext.Conflicts
|
||||
.AsNoTracking()
|
||||
.Where(conflict => conflict.VulnerabilityId == vulnerabilityId && conflict.ProductKey == productKey)
|
||||
.OrderByDescending(conflict => conflict.DetectedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<VexConflict>> GetOpenConflictsAsync(
|
||||
int? limit = null,
|
||||
int? offset = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
IQueryable<EfModels.VexConflict> query = dbContext.Conflicts
|
||||
.AsNoTracking()
|
||||
.Where(conflict => conflict.ResolutionStatus == "open")
|
||||
.OrderByDescending(conflict => conflict.DetectedAt);
|
||||
|
||||
if (offset.HasValue)
|
||||
{
|
||||
query = query.Skip(offset.Value);
|
||||
}
|
||||
|
||||
if (limit.HasValue)
|
||||
{
|
||||
query = query.Take(limit.Value);
|
||||
}
|
||||
|
||||
var entities = await query.ToListAsync(cancellationToken);
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<VexConflict>> GetBySeverityAsync(
|
||||
ConflictSeverity severity,
|
||||
ConflictResolutionStatus? status = null,
|
||||
int? limit = null,
|
||||
int? offset = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
IQueryable<EfModels.VexConflict> query = dbContext.Conflicts
|
||||
.AsNoTracking()
|
||||
.Where(conflict => conflict.Severity == FormatSeverity(severity))
|
||||
.OrderByDescending(conflict => conflict.DetectedAt);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
var resolutionStatus = FormatResolutionStatus(status.Value);
|
||||
query = query.Where(conflict => conflict.ResolutionStatus == resolutionStatus);
|
||||
}
|
||||
|
||||
if (offset.HasValue)
|
||||
{
|
||||
query = query.Skip(offset.Value);
|
||||
}
|
||||
|
||||
if (limit.HasValue)
|
||||
{
|
||||
query = query.Take(limit.Value);
|
||||
}
|
||||
|
||||
var entities = await query.ToListAsync(cancellationToken);
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task ResolveAsync(
|
||||
Guid id,
|
||||
ConflictResolutionStatus status,
|
||||
string? resolutionMethod,
|
||||
Guid? winningStatementId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.Conflicts.FirstOrDefaultAsync(conflict => conflict.Id == id, cancellationToken);
|
||||
if (entity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entity.ResolutionStatus = FormatResolutionStatus(status);
|
||||
entity.ResolutionMethod = resolutionMethod;
|
||||
entity.WinningStatementId = winningStatementId;
|
||||
entity.ResolvedAt = DateTime.UtcNow;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<long> GetOpenConflictCountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await dbContext.Conflicts
|
||||
.AsNoTracking()
|
||||
.LongCountAsync(conflict => conflict.ResolutionStatus == "open", cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<ConflictSeverity, long>> GetConflictCountsBySeverityAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var grouped = await dbContext.Conflicts
|
||||
.AsNoTracking()
|
||||
.GroupBy(conflict => conflict.Severity)
|
||||
.Select(group => new { Severity = group.Key, Count = group.LongCount() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return grouped.ToDictionary(item => ParseSeverity(item.Severity), item => item.Count);
|
||||
}
|
||||
|
||||
private static EfModels.VexConflict ToEntity(VexConflict model) => new()
|
||||
{
|
||||
Id = model.Id,
|
||||
VulnerabilityId = model.VulnerabilityId,
|
||||
ProductKey = model.ProductKey,
|
||||
ConflictingStatementIds = model.ConflictingStatementIds.ToArray(),
|
||||
Severity = FormatSeverity(model.Severity),
|
||||
Description = model.Description,
|
||||
ResolutionStatus = FormatResolutionStatus(model.ResolutionStatus),
|
||||
ResolutionMethod = model.ResolutionMethod,
|
||||
WinningStatementId = model.WinningStatementId,
|
||||
DetectedAt = model.DetectedAt.UtcDateTime,
|
||||
ResolvedAt = model.ResolvedAt?.UtcDateTime
|
||||
};
|
||||
|
||||
private static VexConflict ToModel(EfModels.VexConflict entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
VulnerabilityId = entity.VulnerabilityId,
|
||||
ProductKey = entity.ProductKey,
|
||||
ConflictingStatementIds = entity.ConflictingStatementIds,
|
||||
Severity = ParseSeverity(entity.Severity),
|
||||
Description = entity.Description,
|
||||
ResolutionStatus = ParseResolutionStatus(entity.ResolutionStatus),
|
||||
ResolutionMethod = entity.ResolutionMethod,
|
||||
WinningStatementId = entity.WinningStatementId,
|
||||
DetectedAt = new DateTimeOffset(entity.DetectedAt, TimeSpan.Zero),
|
||||
ResolvedAt = entity.ResolvedAt.HasValue ? new DateTimeOffset(entity.ResolvedAt.Value, TimeSpan.Zero) : null
|
||||
};
|
||||
|
||||
private static string FormatSeverity(ConflictSeverity severity) => severity switch
|
||||
{
|
||||
ConflictSeverity.Low => "low",
|
||||
ConflictSeverity.Medium => "medium",
|
||||
ConflictSeverity.High => "high",
|
||||
_ => "critical"
|
||||
};
|
||||
|
||||
private static ConflictSeverity ParseSeverity(string severity) => severity.ToLowerInvariant() switch
|
||||
{
|
||||
"low" => ConflictSeverity.Low,
|
||||
"medium" => ConflictSeverity.Medium,
|
||||
"high" => ConflictSeverity.High,
|
||||
_ => ConflictSeverity.Critical
|
||||
};
|
||||
|
||||
private static string FormatResolutionStatus(ConflictResolutionStatus status) => status switch
|
||||
{
|
||||
ConflictResolutionStatus.Open => "open",
|
||||
ConflictResolutionStatus.AutoResolved => "auto_resolved",
|
||||
ConflictResolutionStatus.ManuallyResolved => "manually_resolved",
|
||||
_ => "suppressed"
|
||||
};
|
||||
|
||||
private static ConflictResolutionStatus ParseResolutionStatus(string status) => status.ToLowerInvariant() switch
|
||||
{
|
||||
"open" => ConflictResolutionStatus.Open,
|
||||
"auto_resolved" => ConflictResolutionStatus.AutoResolved,
|
||||
"manually_resolved" => ConflictResolutionStatus.ManuallyResolved,
|
||||
_ => ConflictResolutionStatus.Suppressed
|
||||
};
|
||||
|
||||
private static string GetSchemaName() => VexHubDataSource.DefaultSchemaName;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.VexHub.Core;
|
||||
using StellaOps.VexHub.Core.Models;
|
||||
|
||||
namespace StellaOps.VexHub.Persistence.Postgres.Repositories;
|
||||
|
||||
using EfModels = StellaOps.VexHub.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL (EF Core) implementation of the VEX ingestion job repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresVexIngestionJobRepository : IVexIngestionJobRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly VexHubDataSource _dataSource;
|
||||
|
||||
public PostgresVexIngestionJobRepository(VexHubDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
}
|
||||
|
||||
public async Task<VexIngestionJob> CreateAsync(VexIngestionJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = ToEntity(job);
|
||||
dbContext.IngestionJobs.Add(entity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<VexIngestionJob> UpdateAsync(VexIngestionJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.IngestionJobs.FirstOrDefaultAsync(existing => existing.JobId == job.JobId, cancellationToken);
|
||||
if (entity is null)
|
||||
{
|
||||
entity = ToEntity(job);
|
||||
dbContext.IngestionJobs.Add(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
Apply(job, entity);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<VexIngestionJob?> GetByIdAsync(Guid jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.IngestionJobs
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(job => job.JobId == jobId, cancellationToken);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<VexIngestionJob?> GetLatestBySourceAsync(string sourceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.IngestionJobs
|
||||
.AsNoTracking()
|
||||
.Where(job => job.SourceId == sourceId)
|
||||
.OrderByDescending(job => job.StartedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<VexIngestionJob>> GetByStatusAsync(
|
||||
IngestionJobStatus status,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
IQueryable<EfModels.VexIngestionJob> query = dbContext.IngestionJobs
|
||||
.AsNoTracking()
|
||||
.Where(job => job.Status == FormatStatus(status))
|
||||
.OrderByDescending(job => job.StartedAt);
|
||||
|
||||
if (limit.HasValue)
|
||||
{
|
||||
query = query.Take(limit.Value);
|
||||
}
|
||||
|
||||
var entities = await query.ToListAsync(cancellationToken);
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<VexIngestionJob>> GetRunningJobsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return GetByStatusAsync(IngestionJobStatus.Running, null, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task UpdateProgressAsync(
|
||||
Guid jobId,
|
||||
int documentsProcessed,
|
||||
int statementsIngested,
|
||||
int statementsDeduplicated,
|
||||
int conflictsDetected,
|
||||
string? checkpoint = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.IngestionJobs.FirstOrDefaultAsync(job => job.JobId == jobId, cancellationToken);
|
||||
if (entity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entity.DocumentsProcessed = documentsProcessed;
|
||||
entity.StatementsIngested = statementsIngested;
|
||||
entity.StatementsDeduplicated = statementsDeduplicated;
|
||||
entity.ConflictsDetected = conflictsDetected;
|
||||
entity.Checkpoint = checkpoint;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task CompleteAsync(
|
||||
Guid jobId,
|
||||
int documentsProcessed,
|
||||
int statementsIngested,
|
||||
int statementsDeduplicated,
|
||||
int conflictsDetected,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.IngestionJobs.FirstOrDefaultAsync(job => job.JobId == jobId, cancellationToken);
|
||||
if (entity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entity.Status = FormatStatus(IngestionJobStatus.Completed);
|
||||
entity.DocumentsProcessed = documentsProcessed;
|
||||
entity.StatementsIngested = statementsIngested;
|
||||
entity.StatementsDeduplicated = statementsDeduplicated;
|
||||
entity.ConflictsDetected = conflictsDetected;
|
||||
entity.CompletedAt = DateTime.UtcNow;
|
||||
entity.ErrorMessage = null;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task FailAsync(Guid jobId, string errorMessage, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.IngestionJobs.FirstOrDefaultAsync(job => job.JobId == jobId, cancellationToken);
|
||||
if (entity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entity.Status = FormatStatus(IngestionJobStatus.Failed);
|
||||
entity.ErrorMessage = errorMessage;
|
||||
entity.ErrorCount += 1;
|
||||
entity.CompletedAt = DateTime.UtcNow;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static EfModels.VexIngestionJob ToEntity(VexIngestionJob model) => new()
|
||||
{
|
||||
JobId = model.JobId,
|
||||
SourceId = model.SourceId,
|
||||
Status = FormatStatus(model.Status),
|
||||
StartedAt = model.StartedAt.UtcDateTime,
|
||||
CompletedAt = model.CompletedAt?.UtcDateTime,
|
||||
DocumentsProcessed = model.DocumentsProcessed,
|
||||
StatementsIngested = model.StatementsIngested,
|
||||
StatementsDeduplicated = model.StatementsDeduplicated,
|
||||
ConflictsDetected = model.ConflictsDetected,
|
||||
ErrorCount = model.ErrorCount,
|
||||
ErrorMessage = model.ErrorMessage,
|
||||
Checkpoint = model.Checkpoint
|
||||
};
|
||||
|
||||
private static void Apply(VexIngestionJob model, EfModels.VexIngestionJob entity)
|
||||
{
|
||||
entity.SourceId = model.SourceId;
|
||||
entity.Status = FormatStatus(model.Status);
|
||||
entity.StartedAt = model.StartedAt.UtcDateTime;
|
||||
entity.CompletedAt = model.CompletedAt?.UtcDateTime;
|
||||
entity.DocumentsProcessed = model.DocumentsProcessed;
|
||||
entity.StatementsIngested = model.StatementsIngested;
|
||||
entity.StatementsDeduplicated = model.StatementsDeduplicated;
|
||||
entity.ConflictsDetected = model.ConflictsDetected;
|
||||
entity.ErrorCount = model.ErrorCount;
|
||||
entity.ErrorMessage = model.ErrorMessage;
|
||||
entity.Checkpoint = model.Checkpoint;
|
||||
}
|
||||
|
||||
private static VexIngestionJob ToModel(EfModels.VexIngestionJob entity) => new()
|
||||
{
|
||||
JobId = entity.JobId,
|
||||
SourceId = entity.SourceId,
|
||||
Status = ParseStatus(entity.Status),
|
||||
StartedAt = new DateTimeOffset(entity.StartedAt, TimeSpan.Zero),
|
||||
CompletedAt = entity.CompletedAt.HasValue ? new DateTimeOffset(entity.CompletedAt.Value, TimeSpan.Zero) : null,
|
||||
DocumentsProcessed = entity.DocumentsProcessed,
|
||||
StatementsIngested = entity.StatementsIngested,
|
||||
StatementsDeduplicated = entity.StatementsDeduplicated,
|
||||
ConflictsDetected = entity.ConflictsDetected,
|
||||
ErrorCount = entity.ErrorCount,
|
||||
ErrorMessage = entity.ErrorMessage,
|
||||
Checkpoint = entity.Checkpoint
|
||||
};
|
||||
|
||||
private static string FormatStatus(IngestionJobStatus status) => status switch
|
||||
{
|
||||
IngestionJobStatus.Queued => "queued",
|
||||
IngestionJobStatus.Running => "running",
|
||||
IngestionJobStatus.Completed => "completed",
|
||||
IngestionJobStatus.Failed => "failed",
|
||||
IngestionJobStatus.Cancelled => "cancelled",
|
||||
_ => "paused"
|
||||
};
|
||||
|
||||
private static IngestionJobStatus ParseStatus(string status) => status.ToLowerInvariant() switch
|
||||
{
|
||||
"queued" => IngestionJobStatus.Queued,
|
||||
"running" => IngestionJobStatus.Running,
|
||||
"completed" => IngestionJobStatus.Completed,
|
||||
"failed" => IngestionJobStatus.Failed,
|
||||
"cancelled" => IngestionJobStatus.Cancelled,
|
||||
_ => IngestionJobStatus.Paused
|
||||
};
|
||||
|
||||
private static string GetSchemaName() => VexHubDataSource.DefaultSchemaName;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.VexHub.Core;
|
||||
using StellaOps.VexHub.Core.Models;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexHub.Persistence.Postgres.Repositories;
|
||||
|
||||
using EfModels = StellaOps.VexHub.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL (EF Core) implementation of the VEX source repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresVexSourceRepository : IVexSourceRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly VexHubDataSource _dataSource;
|
||||
|
||||
public PostgresVexSourceRepository(VexHubDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
}
|
||||
|
||||
public async Task<VexSource> AddAsync(VexSource source, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = ToEntity(source);
|
||||
dbContext.Sources.Add(entity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<VexSource> UpdateAsync(VexSource source, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.Sources.FirstOrDefaultAsync(s => s.SourceId == source.SourceId, cancellationToken);
|
||||
if (entity is null)
|
||||
{
|
||||
entity = ToEntity(source);
|
||||
dbContext.Sources.Add(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
Apply(source, entity);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<VexSource?> GetByIdAsync(string sourceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.Sources
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.SourceId == sourceId, cancellationToken);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<VexSource>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entities = await dbContext.Sources
|
||||
.AsNoTracking()
|
||||
.OrderBy(s => s.Name)
|
||||
.ThenBy(s => s.SourceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<VexSource>> GetDueForPollingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var entities = await dbContext.Sources
|
||||
.AsNoTracking()
|
||||
.Where(s => s.IsEnabled && s.PollingIntervalSeconds.HasValue)
|
||||
.OrderBy(s => s.LastPolledAt)
|
||||
.ThenBy(s => s.SourceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return entities
|
||||
.Where(entity => entity.LastPolledAt is null
|
||||
|| entity.LastPolledAt.Value.AddSeconds(entity.PollingIntervalSeconds ?? 0) <= nowUtc)
|
||||
.Select(ToModel)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task UpdateLastPolledAsync(
|
||||
string sourceId,
|
||||
DateTimeOffset timestamp,
|
||||
string? errorMessage = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.Sources.FirstOrDefaultAsync(s => s.SourceId == sourceId, cancellationToken);
|
||||
if (entity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entity.LastPolledAt = timestamp.UtcDateTime;
|
||||
entity.LastErrorMessage = errorMessage;
|
||||
entity.UpdatedAt = timestamp.UtcDateTime;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string sourceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var deleted = await dbContext.Sources
|
||||
.Where(s => s.SourceId == sourceId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
private static EfModels.VexSource ToEntity(VexSource model) => new()
|
||||
{
|
||||
SourceId = model.SourceId,
|
||||
Name = model.Name,
|
||||
SourceUri = model.SourceUri,
|
||||
SourceFormat = FormatSourceFormat(model.SourceFormat),
|
||||
IssuerCategory = model.IssuerCategory.HasValue ? FormatIssuerCategory(model.IssuerCategory.Value) : null,
|
||||
TrustTier = FormatTrustTier(model.TrustTier),
|
||||
IsEnabled = model.IsEnabled,
|
||||
PollingIntervalSeconds = model.PollingIntervalSeconds,
|
||||
LastPolledAt = model.LastPolledAt?.UtcDateTime,
|
||||
LastErrorMessage = model.LastErrorMessage,
|
||||
Config = "{}",
|
||||
CreatedAt = model.CreatedAt.UtcDateTime,
|
||||
UpdatedAt = model.UpdatedAt?.UtcDateTime
|
||||
};
|
||||
|
||||
private static void Apply(VexSource model, EfModels.VexSource entity)
|
||||
{
|
||||
entity.Name = model.Name;
|
||||
entity.SourceUri = model.SourceUri;
|
||||
entity.SourceFormat = FormatSourceFormat(model.SourceFormat);
|
||||
entity.IssuerCategory = model.IssuerCategory.HasValue ? FormatIssuerCategory(model.IssuerCategory.Value) : null;
|
||||
entity.TrustTier = FormatTrustTier(model.TrustTier);
|
||||
entity.IsEnabled = model.IsEnabled;
|
||||
entity.PollingIntervalSeconds = model.PollingIntervalSeconds;
|
||||
entity.LastPolledAt = model.LastPolledAt?.UtcDateTime;
|
||||
entity.LastErrorMessage = model.LastErrorMessage;
|
||||
entity.UpdatedAt = model.UpdatedAt?.UtcDateTime ?? DateTime.UtcNow;
|
||||
entity.Config ??= "{}";
|
||||
}
|
||||
|
||||
private static VexSource ToModel(EfModels.VexSource entity) => new()
|
||||
{
|
||||
SourceId = entity.SourceId,
|
||||
Name = entity.Name,
|
||||
SourceUri = entity.SourceUri,
|
||||
SourceFormat = ParseSourceFormat(entity.SourceFormat),
|
||||
IssuerCategory = string.IsNullOrWhiteSpace(entity.IssuerCategory) ? null : ParseIssuerCategory(entity.IssuerCategory),
|
||||
TrustTier = ParseTrustTier(entity.TrustTier),
|
||||
IsEnabled = entity.IsEnabled,
|
||||
PollingIntervalSeconds = entity.PollingIntervalSeconds,
|
||||
LastPolledAt = entity.LastPolledAt.HasValue ? new DateTimeOffset(entity.LastPolledAt.Value, TimeSpan.Zero) : null,
|
||||
LastErrorMessage = entity.LastErrorMessage,
|
||||
CreatedAt = new DateTimeOffset(entity.CreatedAt, TimeSpan.Zero),
|
||||
UpdatedAt = entity.UpdatedAt.HasValue ? new DateTimeOffset(entity.UpdatedAt.Value, TimeSpan.Zero) : null
|
||||
};
|
||||
|
||||
private static string FormatSourceFormat(VexSourceFormat sourceFormat) => sourceFormat switch
|
||||
{
|
||||
VexSourceFormat.OpenVex => "OPENVEX",
|
||||
VexSourceFormat.CsafVex => "CSAF_VEX",
|
||||
VexSourceFormat.CycloneDxVex => "CYCLONEDX_VEX",
|
||||
VexSourceFormat.SpdxVex => "SPDX_VEX",
|
||||
VexSourceFormat.StellaOps => "STELLAOPS",
|
||||
_ => "UNKNOWN"
|
||||
};
|
||||
|
||||
private static VexSourceFormat ParseSourceFormat(string sourceFormat) => sourceFormat.ToUpperInvariant() switch
|
||||
{
|
||||
"OPENVEX" => VexSourceFormat.OpenVex,
|
||||
"CSAF_VEX" => VexSourceFormat.CsafVex,
|
||||
"CYCLONEDX_VEX" => VexSourceFormat.CycloneDxVex,
|
||||
"SPDX_VEX" => VexSourceFormat.SpdxVex,
|
||||
"STELLAOPS" => VexSourceFormat.StellaOps,
|
||||
_ => VexSourceFormat.Unknown
|
||||
};
|
||||
|
||||
private static string FormatIssuerCategory(IssuerCategory issuerCategory) => issuerCategory switch
|
||||
{
|
||||
IssuerCategory.Vendor => "VENDOR",
|
||||
IssuerCategory.Distributor => "DISTRIBUTOR",
|
||||
IssuerCategory.Community => "COMMUNITY",
|
||||
IssuerCategory.Internal => "INTERNAL",
|
||||
IssuerCategory.Aggregator => "AGGREGATOR",
|
||||
_ => issuerCategory.ToString().ToUpperInvariant()
|
||||
};
|
||||
|
||||
private static IssuerCategory ParseIssuerCategory(string issuerCategory) => issuerCategory.ToUpperInvariant() switch
|
||||
{
|
||||
"VENDOR" => IssuerCategory.Vendor,
|
||||
"DISTRIBUTOR" => IssuerCategory.Distributor,
|
||||
"COMMUNITY" => IssuerCategory.Community,
|
||||
"INTERNAL" => IssuerCategory.Internal,
|
||||
"AGGREGATOR" => IssuerCategory.Aggregator,
|
||||
_ => throw new ArgumentException($"Unknown issuer category: {issuerCategory}")
|
||||
};
|
||||
|
||||
private static string FormatTrustTier(TrustTier trustTier) => trustTier switch
|
||||
{
|
||||
TrustTier.Authoritative => "AUTHORITATIVE",
|
||||
TrustTier.Trusted => "TRUSTED",
|
||||
TrustTier.Untrusted => "UNTRUSTED",
|
||||
_ => "UNKNOWN"
|
||||
};
|
||||
|
||||
private static TrustTier ParseTrustTier(string trustTier) => trustTier.ToUpperInvariant() switch
|
||||
{
|
||||
"AUTHORITATIVE" => TrustTier.Authoritative,
|
||||
"TRUSTED" => TrustTier.Trusted,
|
||||
"UNTRUSTED" => TrustTier.Untrusted,
|
||||
_ => TrustTier.Unknown
|
||||
};
|
||||
|
||||
private static string GetSchemaName() => VexHubDataSource.DefaultSchemaName;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.VexHub.Persistence.EfCore.CompiledModels;
|
||||
@@ -22,7 +23,8 @@ internal static class VexHubDbContextFactory
|
||||
var optionsBuilder = new DbContextOptionsBuilder<VexHubDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, VexHubDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
if (string.Equals(normalizedSchema, VexHubDataSource.DefaultSchemaName, StringComparison.Ordinal)
|
||||
&& VexHubDbContextModel.Instance.GetEntityTypes().Any())
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
optionsBuilder.UseModel(VexHubDbContextModel.Instance);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.VexHub.Persistence.EfCore.Context;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexHub.WebService.Tests.Integration;
|
||||
|
||||
public sealed class VexEfModelCompatibilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void VexHubDbContext_builds_runtime_model_without_tsvector_mapping_errors()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<VexHubDbContext>()
|
||||
.UseNpgsql("Host=localhost;Database=vexhub_test;Username=stella;Password=stella")
|
||||
.Options;
|
||||
|
||||
using var dbContext = new VexHubDbContext(options);
|
||||
|
||||
var act = () => _ = dbContext.Model;
|
||||
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ using System.Text.Json;
|
||||
using System.Collections.Concurrent;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.VexHub.Core;
|
||||
using StellaOps.VexHub.Core.Models;
|
||||
using StellaOps.VexLens.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexHub.WebService.Tests.Integration;
|
||||
@@ -16,12 +18,25 @@ namespace StellaOps.VexHub.WebService.Tests.Integration;
|
||||
/// </summary>
|
||||
public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFactory<StellaOps.VexHub.WebService.Program>>
|
||||
{
|
||||
private const string TestApiKey = "integration-test-key";
|
||||
private static readonly DateTimeOffset FixedNow = DateTimeOffset.Parse("2026-03-13T12:00:00Z");
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public VexExportCompatibilityTests(WebApplicationFactory<StellaOps.VexHub.WebService.Program> factory)
|
||||
{
|
||||
_client = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[$"VexHub:ApiKeys:{TestApiKey}:KeyId"] = "integration-test",
|
||||
[$"VexHub:ApiKeys:{TestApiKey}:ClientId"] = "integration-suite",
|
||||
[$"VexHub:ApiKeys:{TestApiKey}:ClientName"] = "Integration Suite",
|
||||
[$"VexHub:ApiKeys:{TestApiKey}:Scopes:0"] = "VexHub.Read",
|
||||
[$"VexHub:ApiKeys:{TestApiKey}:Scopes:1"] = "VexHub.Admin",
|
||||
});
|
||||
});
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<IVexSourceRepository, InMemoryVexSourceRepository>();
|
||||
@@ -30,6 +45,8 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
|
||||
services.AddSingleton<IVexStatementRepository, InMemoryVexStatementRepository>();
|
||||
});
|
||||
}).CreateClient();
|
||||
_client.DefaultRequestHeaders.Add("X-Api-Key", TestApiKey);
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -118,6 +135,21 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StatsEndpoint_ReturnsDashboardCompatibleShape()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/v1/vex/stats");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
payload.GetProperty("totalStatements").GetInt64().Should().BeGreaterThan(0);
|
||||
payload.GetProperty("byStatus").GetProperty("affected").GetInt64().Should().BeGreaterThan(0);
|
||||
payload.GetProperty("bySource").GetProperty("vendor").GetInt64().Should().BeGreaterThan(0);
|
||||
payload.GetProperty("recentActivity").GetArrayLength().Should().BeGreaterThan(0);
|
||||
payload.GetProperty("trends").GetArrayLength().Should().Be(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SourceEndpoint_ReturnsValidResponse()
|
||||
{
|
||||
@@ -207,6 +239,45 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, VexSource> _sources = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public InMemoryVexSourceRepository()
|
||||
{
|
||||
Seed(new VexSource
|
||||
{
|
||||
SourceId = "redhat-csaf",
|
||||
Name = "Red Hat CSAF",
|
||||
SourceFormat = VexSourceFormat.CsafVex,
|
||||
IssuerCategory = IssuerCategory.Vendor,
|
||||
TrustTier = TrustTier.Authoritative,
|
||||
CreatedAt = FixedNow.AddDays(-30),
|
||||
UpdatedAt = FixedNow.AddDays(-1),
|
||||
IsEnabled = true,
|
||||
});
|
||||
Seed(new VexSource
|
||||
{
|
||||
SourceId = "internal-vex",
|
||||
Name = "Internal VEX",
|
||||
SourceFormat = VexSourceFormat.OpenVex,
|
||||
IssuerCategory = IssuerCategory.Internal,
|
||||
TrustTier = TrustTier.Trusted,
|
||||
CreatedAt = FixedNow.AddDays(-14),
|
||||
UpdatedAt = FixedNow.AddHours(-6),
|
||||
IsEnabled = true,
|
||||
});
|
||||
Seed(new VexSource
|
||||
{
|
||||
SourceId = "osv-community",
|
||||
Name = "OSV Community",
|
||||
SourceFormat = VexSourceFormat.OpenVex,
|
||||
IssuerCategory = IssuerCategory.Community,
|
||||
TrustTier = TrustTier.Trusted,
|
||||
CreatedAt = FixedNow.AddDays(-21),
|
||||
UpdatedAt = FixedNow.AddHours(-12),
|
||||
IsEnabled = true,
|
||||
});
|
||||
}
|
||||
|
||||
private void Seed(VexSource source) => _sources[source.SourceId] = source;
|
||||
|
||||
public Task<VexSource> AddAsync(VexSource source, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_sources[source.SourceId] = source;
|
||||
@@ -248,7 +319,7 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
|
||||
{
|
||||
LastPolledAt = timestamp,
|
||||
LastErrorMessage = errorMessage,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
UpdatedAt = timestamp
|
||||
};
|
||||
}
|
||||
|
||||
@@ -331,7 +402,7 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
|
||||
ResolutionStatus = status,
|
||||
ResolutionMethod = resolutionMethod,
|
||||
WinningStatementId = winningStatementId,
|
||||
ResolvedAt = DateTimeOffset.UtcNow
|
||||
ResolvedAt = FixedNow
|
||||
};
|
||||
}
|
||||
|
||||
@@ -446,7 +517,7 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
|
||||
StatementsIngested = statementsIngested,
|
||||
StatementsDeduplicated = statementsDeduplicated,
|
||||
ConflictsDetected = conflictsDetected,
|
||||
CompletedAt = DateTimeOffset.UtcNow
|
||||
CompletedAt = FixedNow
|
||||
};
|
||||
}
|
||||
|
||||
@@ -461,7 +532,7 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
|
||||
{
|
||||
Status = IngestionJobStatus.Failed,
|
||||
ErrorMessage = errorMessage,
|
||||
CompletedAt = DateTimeOffset.UtcNow
|
||||
CompletedAt = FixedNow
|
||||
};
|
||||
}
|
||||
|
||||
@@ -473,6 +544,57 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, AggregatedVexStatement> _statements = new();
|
||||
|
||||
public InMemoryVexStatementRepository()
|
||||
{
|
||||
Seed(new AggregatedVexStatement
|
||||
{
|
||||
Id = Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
SourceStatementId = "stmt-redhat-001",
|
||||
SourceId = "redhat-csaf",
|
||||
SourceDocumentId = "doc-redhat-001",
|
||||
VulnerabilityId = "CVE-2026-1001",
|
||||
ProductKey = "pkg:oci/acme/api@sha256:1111",
|
||||
Status = VexStatus.Affected,
|
||||
VerificationStatus = VerificationStatus.Verified,
|
||||
IsFlagged = false,
|
||||
IngestedAt = FixedNow.AddDays(-1),
|
||||
SourceUpdatedAt = FixedNow.AddHours(-10),
|
||||
ContentDigest = "sha256:redhat-001",
|
||||
});
|
||||
Seed(new AggregatedVexStatement
|
||||
{
|
||||
Id = Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
||||
SourceStatementId = "stmt-internal-001",
|
||||
SourceId = "internal-vex",
|
||||
SourceDocumentId = "doc-internal-001",
|
||||
VulnerabilityId = "CVE-2026-1002",
|
||||
ProductKey = "pkg:oci/acme/web@sha256:2222",
|
||||
Status = VexStatus.NotAffected,
|
||||
VerificationStatus = VerificationStatus.Pending,
|
||||
IsFlagged = false,
|
||||
IngestedAt = FixedNow.AddDays(-2),
|
||||
SourceUpdatedAt = FixedNow.AddHours(-20),
|
||||
ContentDigest = "sha256:internal-001",
|
||||
});
|
||||
Seed(new AggregatedVexStatement
|
||||
{
|
||||
Id = Guid.Parse("33333333-3333-3333-3333-333333333333"),
|
||||
SourceStatementId = "stmt-community-001",
|
||||
SourceId = "osv-community",
|
||||
SourceDocumentId = "doc-community-001",
|
||||
VulnerabilityId = "CVE-2026-1003",
|
||||
ProductKey = "pkg:oci/acme/worker@sha256:3333",
|
||||
Status = VexStatus.Fixed,
|
||||
VerificationStatus = VerificationStatus.None,
|
||||
IsFlagged = true,
|
||||
IngestedAt = FixedNow.AddDays(-3),
|
||||
SourceUpdatedAt = FixedNow.AddDays(-1).AddHours(-2),
|
||||
ContentDigest = "sha256:community-001",
|
||||
});
|
||||
}
|
||||
|
||||
private void Seed(AggregatedVexStatement statement) => _statements[statement.Id] = statement;
|
||||
|
||||
public Task<AggregatedVexStatement> UpsertAsync(AggregatedVexStatement statement, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_statements[statement.Id] = statement;
|
||||
@@ -502,7 +624,8 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
|
||||
int? offset = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<AggregatedVexStatement>>(Array.Empty<AggregatedVexStatement>());
|
||||
var results = ApplyFilter(new VexStatementFilter { VulnerabilityId = cveId }, limit, offset);
|
||||
return Task.FromResult<IReadOnlyList<AggregatedVexStatement>>(results);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AggregatedVexStatement>> GetByPackageAsync(
|
||||
@@ -511,7 +634,8 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
|
||||
int? offset = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<AggregatedVexStatement>>(Array.Empty<AggregatedVexStatement>());
|
||||
var results = ApplyFilter(new VexStatementFilter { ProductKey = purl }, limit, offset);
|
||||
return Task.FromResult<IReadOnlyList<AggregatedVexStatement>>(results);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AggregatedVexStatement>> GetBySourceAsync(
|
||||
@@ -520,14 +644,15 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
|
||||
int? offset = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<AggregatedVexStatement>>(Array.Empty<AggregatedVexStatement>());
|
||||
var results = ApplyFilter(new VexStatementFilter { SourceId = sourceId }, limit, offset);
|
||||
return Task.FromResult<IReadOnlyList<AggregatedVexStatement>>(results);
|
||||
}
|
||||
|
||||
public Task<bool> ExistsByDigestAsync(string contentDigest, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(false);
|
||||
=> Task.FromResult(_statements.Values.Any(statement => string.Equals(statement.ContentDigest, contentDigest, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
public Task<long> GetCountAsync(VexStatementFilter? filter = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(0L);
|
||||
=> Task.FromResult((long)ApplyFilter(filter, null, null).Count);
|
||||
|
||||
public Task<IReadOnlyList<AggregatedVexStatement>> SearchAsync(
|
||||
VexStatementFilter filter,
|
||||
@@ -535,13 +660,85 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
|
||||
int? offset = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<AggregatedVexStatement>>(Array.Empty<AggregatedVexStatement>());
|
||||
var results = ApplyFilter(filter, limit, offset);
|
||||
return Task.FromResult<IReadOnlyList<AggregatedVexStatement>>(results);
|
||||
}
|
||||
|
||||
public Task FlagStatementAsync(Guid id, string reason, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
{
|
||||
if (_statements.TryGetValue(id, out var statement))
|
||||
{
|
||||
_statements[id] = statement with { IsFlagged = true, FlagReason = reason };
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<int> DeleteBySourceAsync(string sourceId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(0);
|
||||
{
|
||||
var removed = _statements.Values
|
||||
.Where(statement => string.Equals(statement.SourceId, sourceId, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(statement => statement.Id)
|
||||
.ToArray();
|
||||
|
||||
foreach (var id in removed)
|
||||
{
|
||||
_statements.TryRemove(id, out _);
|
||||
}
|
||||
|
||||
return Task.FromResult(removed.Length);
|
||||
}
|
||||
|
||||
private List<AggregatedVexStatement> ApplyFilter(VexStatementFilter? filter, int? limit, int? offset)
|
||||
{
|
||||
IEnumerable<AggregatedVexStatement> query = _statements.Values
|
||||
.OrderByDescending(statement => statement.IngestedAt)
|
||||
.ThenBy(statement => statement.SourceId, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (filter is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(filter.SourceId))
|
||||
{
|
||||
query = query.Where(statement => string.Equals(statement.SourceId, filter.SourceId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.VulnerabilityId))
|
||||
{
|
||||
query = query.Where(statement => string.Equals(statement.VulnerabilityId, filter.VulnerabilityId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.ProductKey))
|
||||
{
|
||||
query = query.Where(statement => string.Equals(statement.ProductKey, filter.ProductKey, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (filter.Status.HasValue)
|
||||
{
|
||||
query = query.Where(statement => statement.Status == filter.Status.Value);
|
||||
}
|
||||
|
||||
if (filter.VerificationStatus.HasValue)
|
||||
{
|
||||
query = query.Where(statement => statement.VerificationStatus == filter.VerificationStatus.Value);
|
||||
}
|
||||
|
||||
if (filter.IsFlagged.HasValue)
|
||||
{
|
||||
query = query.Where(statement => statement.IsFlagged == filter.IsFlagged.Value);
|
||||
}
|
||||
}
|
||||
|
||||
if (offset.HasValue)
|
||||
{
|
||||
query = query.Skip(offset.Value);
|
||||
}
|
||||
|
||||
if (limit.HasValue)
|
||||
{
|
||||
query = query.Take(limit.Value);
|
||||
}
|
||||
|
||||
return query.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.VexHub.Core;
|
||||
using StellaOps.VexHub.Persistence.Extensions;
|
||||
using StellaOps.VexHub.Persistence.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexHub.WebService.Tests.Integration;
|
||||
|
||||
public sealed class VexPersistenceRegistrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddVexHubPersistence_registers_all_core_repository_contracts()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection()
|
||||
.Build();
|
||||
|
||||
services.AddVexHubPersistence(configuration);
|
||||
|
||||
services.Should().ContainSingle(descriptor =>
|
||||
descriptor.ServiceType == typeof(IVexSourceRepository)
|
||||
&& descriptor.ImplementationType == typeof(PostgresVexSourceRepository)
|
||||
&& descriptor.Lifetime == ServiceLifetime.Scoped);
|
||||
services.Should().ContainSingle(descriptor =>
|
||||
descriptor.ServiceType == typeof(IVexConflictRepository)
|
||||
&& descriptor.ImplementationType == typeof(PostgresVexConflictRepository)
|
||||
&& descriptor.Lifetime == ServiceLifetime.Scoped);
|
||||
services.Should().ContainSingle(descriptor =>
|
||||
descriptor.ServiceType == typeof(IVexIngestionJobRepository)
|
||||
&& descriptor.ImplementationType == typeof(PostgresVexIngestionJobRepository)
|
||||
&& descriptor.Lifetime == ServiceLifetime.Scoped);
|
||||
services.Should().ContainSingle(descriptor =>
|
||||
descriptor.ServiceType == typeof(IVexStatementRepository)
|
||||
&& descriptor.ImplementationType == typeof(PostgresVexStatementRepository)
|
||||
&& descriptor.Lifetime == ServiceLifetime.Scoped);
|
||||
services.Should().ContainSingle(descriptor =>
|
||||
descriptor.ServiceType == typeof(IVexProvenanceRepository)
|
||||
&& descriptor.ImplementationType == typeof(PostgresVexProvenanceRepository)
|
||||
&& descriptor.Lifetime == ServiceLifetime.Scoped);
|
||||
services.Should().Contain(descriptor =>
|
||||
descriptor.ServiceType == typeof(IHostedService));
|
||||
}
|
||||
}
|
||||
@@ -174,7 +174,30 @@ async function waitForNotificationsPanel(page, timeoutMs = 12_000) {
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async function findNavigationTarget(page, name, index = 0) {
|
||||
async function findNavigationTarget(page, route, name, index = 0) {
|
||||
if (route === '/ops/policy/overview') {
|
||||
const locator = page.locator('#main-content [data-testid^="policy-overview-card-"]').getByRole('link', { name }).nth(index);
|
||||
if ((await locator.count()) > 0) {
|
||||
return {
|
||||
matchedRole: 'link',
|
||||
locator,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (route === '/ops/policy/governance') {
|
||||
const tabBar = page.locator('#main-content .governance__tabs').first();
|
||||
for (const role of ['tab', 'link']) {
|
||||
const locator = tabBar.getByRole(role, { name }).nth(index);
|
||||
if ((await locator.count()) > 0) {
|
||||
return {
|
||||
matchedRole: role,
|
||||
locator,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
{ role: 'link', locator: page.getByRole('link', { name }) },
|
||||
{ role: 'tab', locator: page.getByRole('tab', { name }) },
|
||||
@@ -196,7 +219,7 @@ async function findNavigationTarget(page, name, index = 0) {
|
||||
async function waitForNavigationTarget(page, name, index = 0, timeoutMs = ELEMENT_WAIT_MS) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const target = await findNavigationTarget(page, name, index);
|
||||
const target = await findNavigationTarget(page, page.url().replace(/^https:\/\/stella-ops\.local/, '').split('?')[0], name, index);
|
||||
if (target) {
|
||||
return target;
|
||||
}
|
||||
@@ -301,6 +324,113 @@ async function clickLink(context, page, route, name, index = 0) {
|
||||
};
|
||||
}
|
||||
|
||||
async function clickLinkExpectPath(context, page, route, name, expectedPath, index = 0) {
|
||||
const result = await clickLink(context, page, route, name, index);
|
||||
const targetUrl = result.mode === 'popup' ? result.targetUrl : result.targetUrl ?? result.snapshot?.url ?? '';
|
||||
return {
|
||||
...result,
|
||||
ok: Boolean(result.ok) && typeof targetUrl === 'string' && targetUrl.includes(expectedPath),
|
||||
expectedPath,
|
||||
targetUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyTextLoad(page, route, label, expectedTexts, timeoutMs = 12_000) {
|
||||
await navigate(page, route);
|
||||
await page.waitForFunction(
|
||||
(values) => {
|
||||
const text = (document.body?.innerText || '').toLowerCase();
|
||||
return values.every((value) => text.includes(value.toLowerCase()));
|
||||
},
|
||||
expectedTexts,
|
||||
{ timeout: timeoutMs },
|
||||
).catch(() => {});
|
||||
|
||||
const snapshot = await captureSnapshot(page, `${slugify(route)}:${slugify(label)}`);
|
||||
const bodyText = await page.locator('body').innerText().catch(() => '');
|
||||
const normalized = bodyText.toLowerCase();
|
||||
const missingTexts = expectedTexts.filter((value) => !normalized.includes(value.toLowerCase()));
|
||||
|
||||
return {
|
||||
action: label,
|
||||
ok: missingTexts.length === 0 && snapshot.alerts.length === 0,
|
||||
missingTexts,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyConflictDashboardLoad(page) {
|
||||
const route = '/ops/policy/governance/conflicts';
|
||||
await navigate(page, route);
|
||||
await page.waitForFunction(() => {
|
||||
const text = (document.body?.innerText || '').toLowerCase();
|
||||
return text.includes('policy conflicts') && (text.includes('resolve wizard') || text.includes('no conflicts found'));
|
||||
}, { timeout: 12_000 }).catch(() => {});
|
||||
|
||||
const snapshot = await captureSnapshot(page, 'policy-conflicts:load-dashboard');
|
||||
const bodyText = (await page.locator('body').innerText().catch(() => '')).toLowerCase();
|
||||
const hasHeading = bodyText.includes('policy conflicts');
|
||||
const hasActionableConflict = bodyText.includes('resolve wizard');
|
||||
const hasEmptyState = bodyText.includes('no conflicts found');
|
||||
|
||||
return {
|
||||
action: 'load:Conflict dashboard',
|
||||
ok: hasHeading && (hasActionableConflict || hasEmptyState) && snapshot.alerts.length === 0,
|
||||
snapshot,
|
||||
mode: hasActionableConflict ? 'actionable-conflicts' : hasEmptyState ? 'empty-state' : 'unknown',
|
||||
missingTexts: hasHeading ? [] : ['Policy Conflicts'],
|
||||
};
|
||||
}
|
||||
|
||||
async function openConflictResolutionWizard(page) {
|
||||
const route = '/ops/policy/governance/conflicts';
|
||||
await navigate(page, route);
|
||||
|
||||
await page
|
||||
.waitForFunction(() => {
|
||||
const text = (document.body?.innerText || '').toLowerCase();
|
||||
return text.includes('resolve wizard') || text.includes('no conflicts found');
|
||||
}, { timeout: 12_000 })
|
||||
.catch(() => {});
|
||||
|
||||
const currentBodyText = await page.locator('body').innerText().catch(() => '');
|
||||
if (currentBodyText.includes('No conflicts found')) {
|
||||
return {
|
||||
action: 'link:Resolve Wizard',
|
||||
ok: true,
|
||||
mode: 'no-open-conflicts',
|
||||
snapshot: await captureSnapshot(page, 'policy-conflicts:no-open-conflicts'),
|
||||
};
|
||||
}
|
||||
|
||||
const link = page.locator('.conflict-card').getByRole('link', { name: 'Resolve Wizard' }).first();
|
||||
if (!(await link.isVisible().catch(() => false))) {
|
||||
return {
|
||||
action: 'link:Resolve Wizard',
|
||||
ok: false,
|
||||
reason: 'missing-link',
|
||||
snapshot: await captureSnapshot(page, 'policy-conflicts:missing-resolve-wizard'),
|
||||
};
|
||||
}
|
||||
|
||||
await link.click({ timeout: 10_000 });
|
||||
await settle(page);
|
||||
|
||||
const snapshot = await captureSnapshot(page, 'policy-conflicts:resolve-wizard');
|
||||
const bodyText = await page.locator('body').innerText().catch(() => '');
|
||||
const ok =
|
||||
page.url().includes('/ops/policy/governance/conflicts/') &&
|
||||
bodyText.includes('Conflict Resolution Wizard') &&
|
||||
bodyText.includes('Review Conflict Details');
|
||||
|
||||
return {
|
||||
action: 'link:Resolve Wizard',
|
||||
ok,
|
||||
targetUrl: page.url(),
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
async function clickButton(page, route, name, index = 0) {
|
||||
await navigate(page, route);
|
||||
const locator = await waitForButton(page, name, index);
|
||||
@@ -812,6 +942,68 @@ async function main() {
|
||||
});
|
||||
|
||||
try {
|
||||
results.push({
|
||||
route: '/ops/policy/overview',
|
||||
actions: [
|
||||
await runAction(page, '/ops/policy/overview', 'link:Packs', () =>
|
||||
clickLinkExpectPath(context, page, '/ops/policy/overview', 'Packs', '/ops/policy/packs')),
|
||||
await runAction(page, '/ops/policy/overview', 'link:Governance', () =>
|
||||
clickLinkExpectPath(context, page, '/ops/policy/overview', 'Governance', '/ops/policy/governance')),
|
||||
await runAction(page, '/ops/policy/overview', 'link:Simulation', () =>
|
||||
clickLinkExpectPath(context, page, '/ops/policy/overview', 'Simulation', '/ops/policy/simulation')),
|
||||
await runAction(page, '/ops/policy/overview', 'link:VEX & Exceptions', () =>
|
||||
clickLinkExpectPath(context, page, '/ops/policy/overview', 'VEX & Exceptions', '/ops/policy/vex')),
|
||||
await runAction(page, '/ops/policy/overview', 'link:Release Gates', () =>
|
||||
clickLinkExpectPath(context, page, '/ops/policy/overview', 'Release Gates', '/ops/policy/gates')),
|
||||
await runAction(page, '/ops/policy/overview', 'link:Policy Audit', () =>
|
||||
clickLinkExpectPath(context, page, '/ops/policy/overview', 'Policy Audit', '/ops/policy/audit')),
|
||||
],
|
||||
});
|
||||
await persistSummary(summary);
|
||||
|
||||
results.push({
|
||||
route: '/ops/policy/governance',
|
||||
actions: [
|
||||
await runAction(page, '/ops/policy/governance', 'link:Trust Weights', () =>
|
||||
clickLinkExpectPath(context, page, '/ops/policy/governance', 'Trust Weights', '/ops/policy/governance/trust-weights')),
|
||||
await runAction(page, '/ops/policy/governance', 'link:Sealed Mode', () =>
|
||||
clickLinkExpectPath(context, page, '/ops/policy/governance', 'Sealed Mode', '/ops/policy/governance/sealed-mode')),
|
||||
await runAction(page, '/ops/policy/governance', 'link:Profiles', () =>
|
||||
clickLinkExpectPath(context, page, '/ops/policy/governance', 'Profiles', '/ops/policy/governance/profiles')),
|
||||
await runAction(page, '/ops/policy/governance', 'link:Validator', () =>
|
||||
clickLinkExpectPath(context, page, '/ops/policy/governance', 'Validator', '/ops/policy/governance/validator')),
|
||||
await runAction(page, '/ops/policy/governance', 'link:Audit Log', () =>
|
||||
clickLinkExpectPath(context, page, '/ops/policy/governance', 'Audit Log', '/ops/policy/governance/audit')),
|
||||
await runAction(page, '/ops/policy/governance', 'link:Conflicts', () =>
|
||||
clickLinkExpectPath(context, page, '/ops/policy/governance', 'Conflicts', '/ops/policy/governance/conflicts')),
|
||||
await runAction(page, '/ops/policy/governance', 'link:Playground', () =>
|
||||
clickLinkExpectPath(context, page, '/ops/policy/governance', 'Playground', '/ops/policy/governance/schema-playground')),
|
||||
await runAction(page, '/ops/policy/governance', 'link:Docs', () =>
|
||||
clickLinkExpectPath(context, page, '/ops/policy/governance', 'Docs', '/ops/policy/governance/schema-docs')),
|
||||
],
|
||||
});
|
||||
await persistSummary(summary);
|
||||
|
||||
results.push({
|
||||
route: '/ops/policy/vex',
|
||||
actions: [
|
||||
await runAction(page, '/ops/policy/vex', 'load:Dashboard data', () =>
|
||||
verifyTextLoad(page, '/ops/policy/vex', 'load:Dashboard data', ['VEX Statement Dashboard', 'Statement Sources'])),
|
||||
],
|
||||
});
|
||||
await persistSummary(summary);
|
||||
|
||||
results.push({
|
||||
route: '/ops/policy/governance/conflicts',
|
||||
actions: [
|
||||
await runAction(page, '/ops/policy/governance/conflicts', 'load:Conflict dashboard', () =>
|
||||
verifyConflictDashboardLoad(page)),
|
||||
await runAction(page, '/ops/policy/governance/conflicts', 'link:Resolve Wizard', () =>
|
||||
openConflictResolutionWizard(page)),
|
||||
],
|
||||
});
|
||||
await persistSummary(summary);
|
||||
|
||||
results.push({
|
||||
route: '/ops/operations/quotas',
|
||||
actions: [
|
||||
|
||||
@@ -191,6 +191,8 @@ async function findLinkForRoute(page, route, name, timeoutMs = 10_000) {
|
||||
|
||||
if (route === '/releases/environments' && name === 'Open Environment') {
|
||||
locator = page.locator('.actions').getByRole('link', { name }).first();
|
||||
} else if (route === '/security/posture' && name === 'Configure sources') {
|
||||
locator = page.locator('#main-content .panel').getByRole('link', { name }).first();
|
||||
} else if (route === '/ops/operations/jobengine' && name instanceof RegExp && String(name) === String(/Execution Quotas/i)) {
|
||||
locator = await findScopedLink(page, '[data-testid="jobengine-quotas-card"]', name);
|
||||
} else if (route === '/ops/operations/offline-kit' && name === 'Bundles') {
|
||||
@@ -209,7 +211,44 @@ async function findLinkForRoute(page, route, name, timeoutMs = 10_000) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findButton(page, name, timeoutMs = 10_000) {
|
||||
async function findButton(page, route, name, timeoutMs = 10_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const scopes = [];
|
||||
if (route === '/releases/versions' && name === 'Create Hotfix Run') {
|
||||
scopes.push(page.locator('#main-content .header-actions').first());
|
||||
}
|
||||
scopes.push(page.locator('#main-content').first(), page.locator('body'));
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
for (const scope of scopes) {
|
||||
for (const role of ['button', 'tab']) {
|
||||
const button = scope.getByRole(role, { name }).first();
|
||||
if (await button.count()) {
|
||||
return button;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function waitForPath(page, expectedPath, timeoutMs = 10_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (normalizeUrl(page.url()).includes(expectedPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function findButtonLegacy(page, name, timeoutMs = 10_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
for (const role of ['button', 'tab']) {
|
||||
@@ -269,6 +308,9 @@ async function runLinkCheck(page, route, name, expectedPath) {
|
||||
}
|
||||
|
||||
await link.click({ timeout: 10_000 });
|
||||
if (expectedPath) {
|
||||
await waitForPath(page, expectedPath, 10_000);
|
||||
}
|
||||
await settle(page);
|
||||
const snapshot = await captureSnapshot(page, action);
|
||||
const finalUrl = normalizeUrl(snapshot.url);
|
||||
@@ -294,7 +336,7 @@ async function runButtonCheck(page, route, name, expectedPath = null) {
|
||||
const action = `${route} -> button:${name}`;
|
||||
try {
|
||||
await navigate(page, route);
|
||||
const button = await findButton(page, name);
|
||||
const button = await findButton(page, route, name).catch(() => null) ?? await findButtonLegacy(page, name);
|
||||
if (!button) {
|
||||
return { action, ok: false, reason: 'missing-button', snapshot: await captureSnapshot(page, action) };
|
||||
}
|
||||
@@ -313,6 +355,9 @@ async function runButtonCheck(page, route, name, expectedPath = null) {
|
||||
}
|
||||
|
||||
await button.click({ timeout: 10_000 });
|
||||
if (expectedPath) {
|
||||
await waitForPath(page, expectedPath, 10_000);
|
||||
}
|
||||
await settle(page);
|
||||
const snapshot = await captureSnapshot(page, action);
|
||||
const finalUrl = normalizeUrl(snapshot.url);
|
||||
|
||||
@@ -171,6 +171,10 @@ async function collectReportsTabState(page, tab) {
|
||||
};
|
||||
}
|
||||
|
||||
function tabContainsText(values, expected) {
|
||||
return values.some((value) => value.toLowerCase().includes(expected.toLowerCase()));
|
||||
}
|
||||
|
||||
async function runSearchQueryCheck(page, query) {
|
||||
const searchInput = page.locator('input[aria-label="Global search"]').first();
|
||||
const responses = [];
|
||||
@@ -487,6 +491,33 @@ async function main() {
|
||||
if (!tabState.url.includes('/security/reports')) {
|
||||
failures.push(`Security Reports tab "${tabState.tab}" still navigates away instead of embedding its workspace.`);
|
||||
}
|
||||
|
||||
if (tabState.tab === 'Risk Report') {
|
||||
const riskEmbedded =
|
||||
tabContainsText(tabState.headings, 'Artifact triage') &&
|
||||
(tabContainsText(tabState.headings, 'Findings') || tabContainsText(tabState.primaryButtons, 'Open witness workspace'));
|
||||
if (!riskEmbedded) {
|
||||
failures.push('Security Reports risk tab did not render the embedded triage workspace.');
|
||||
}
|
||||
}
|
||||
|
||||
if (tabState.tab === 'VEX Ledger') {
|
||||
const vexEmbedded =
|
||||
tabContainsText(tabState.headings, 'Security / Advisories & VEX') &&
|
||||
(tabContainsText(tabState.headings, 'Providers') || tabContainsText(tabState.headings, 'VEX Library'));
|
||||
if (!vexEmbedded) {
|
||||
failures.push('Security Reports VEX tab did not render the embedded advisories and VEX workspace.');
|
||||
}
|
||||
}
|
||||
|
||||
if (tabState.tab === 'Evidence Export') {
|
||||
const evidenceEmbedded =
|
||||
tabContainsText(tabState.headings, 'Export Center') &&
|
||||
(tabContainsText(tabState.primaryButtons, 'Create Profile') || tabContainsText(tabState.headings, 'Export Runs'));
|
||||
if (!evidenceEmbedded) {
|
||||
failures.push('Security Reports evidence tab did not render the embedded export workspace.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (byAction.get('security-triage:raw-svg-visible')?.triageRawSvgTextVisible) {
|
||||
|
||||
@@ -90,7 +90,7 @@ export interface VexStatementSearchResponse {
|
||||
export interface VexHubStats {
|
||||
totalStatements: number;
|
||||
byStatus: Record<VexStatementStatus, number>;
|
||||
bySource: Record<VexIssuerType, number>;
|
||||
bySource: Record<string, number>;
|
||||
recentActivity: VexActivityItem[];
|
||||
trends?: VexTrendData[];
|
||||
}
|
||||
|
||||
@@ -1,65 +1,105 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||
import { ActivatedRoute, Router, provideRouter } from '@angular/router';
|
||||
import { convertToParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { POLICY_GOVERNANCE_API, PolicyGovernanceApi } from '../../core/api/policy-governance.client';
|
||||
import { PolicyConflict } from '../../core/api/policy-governance.models';
|
||||
import { AuthSessionStore } from '../../core/auth/auth-session.store';
|
||||
import { TenantActivationService } from '../../core/auth/tenant-activation.service';
|
||||
import { ConflictResolutionWizardComponent } from './conflict-resolution-wizard.component';
|
||||
|
||||
describe('ConflictResolutionWizardComponent', () => {
|
||||
let component: ConflictResolutionWizardComponent;
|
||||
let fixture: ComponentFixture<ConflictResolutionWizardComponent>;
|
||||
let api: jasmine.SpyObj<PolicyGovernanceApi>;
|
||||
let router: Router;
|
||||
let tenantActivation: { activeTenantId: jasmine.Spy; activeProjectId: jasmine.Spy };
|
||||
let authSession: { getActiveTenantId: jasmine.Spy };
|
||||
|
||||
const mockActivatedRoute = {
|
||||
paramMap: of({
|
||||
get: (key: string) => key === 'conflictId' ? 'conflict-123' : null,
|
||||
}),
|
||||
const conflict: PolicyConflict = {
|
||||
id: 'conflict-001',
|
||||
type: 'rule_overlap',
|
||||
severity: 'warning',
|
||||
summary: 'Overlapping severity rules in profiles',
|
||||
description: 'Two profiles can emit conflicting outcomes for the same release.',
|
||||
sourceA: { id: 'profile-default', type: 'profile', name: 'Default Risk Profile', version: '1.0.0', path: 'severityOverrides[0]' },
|
||||
sourceB: { id: 'profile-strict', type: 'profile', name: 'Strict Security Profile', version: '1.1.0', path: 'severityOverrides[0]' },
|
||||
affectedScope: ['production'],
|
||||
impactAssessment: 'Live releases may oscillate between warn and block.',
|
||||
suggestedResolution: 'Choose one precedence order.',
|
||||
detectedAt: '2026-03-13T10:00:00Z',
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
api = jasmine.createSpyObj<PolicyGovernanceApi>('PolicyGovernanceApi', [
|
||||
'getConflicts',
|
||||
'resolveConflict',
|
||||
]);
|
||||
tenantActivation = {
|
||||
activeTenantId: jasmine.createSpy('activeTenantId').and.returnValue(null),
|
||||
activeProjectId: jasmine.createSpy('activeProjectId').and.returnValue(null),
|
||||
};
|
||||
authSession = {
|
||||
getActiveTenantId: jasmine.createSpy('getActiveTenantId').and.returnValue('session-tenant'),
|
||||
};
|
||||
|
||||
api.getConflicts.and.returnValue(of([conflict]));
|
||||
api.resolveConflict.and.returnValue(of({ ...conflict, status: 'resolved', resolutionNotes: 'Consolidated precedence ordering' }));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ConflictResolutionWizardComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
|
||||
{ provide: POLICY_GOVERNANCE_API, useValue: api },
|
||||
{ provide: TenantActivationService, useValue: tenantActivation },
|
||||
{ provide: AuthSessionStore, useValue: authSession },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ conflictId: 'conflict-001' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
router = TestBed.inject(Router);
|
||||
spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||
|
||||
fixture = TestBed.createComponent(ConflictResolutionWizardComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('loads the requested conflict with the resolved tenant scope', () => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
expect(api.getConflicts).toHaveBeenCalledWith({ tenantId: 'session-tenant' });
|
||||
expect((component as any).conflict()).toEqual(conflict);
|
||||
|
||||
it('should render wizard header', () => {
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('.wizard__header')).toBeTruthy();
|
||||
expect(compiled.textContent).toContain('Conflict Resolution Wizard');
|
||||
expect(compiled.textContent).toContain('Review');
|
||||
});
|
||||
|
||||
it('should display step indicators', () => {
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const steps = compiled.querySelectorAll('.wizard__step');
|
||||
expect(steps.length).toBe(4);
|
||||
});
|
||||
it('applies the resolution through the resolved scope and returns to the conflicts page', async () => {
|
||||
fixture.detectChanges();
|
||||
(component as any).selectedWinner.set('A');
|
||||
(component as any).selectedStrategy.set('keep_higher_priority');
|
||||
(component as any).resolutionNotes = 'Consolidated precedence ordering';
|
||||
|
||||
it('should show step labels for all steps', () => {
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const content = compiled.textContent;
|
||||
expect(content).toContain('Review');
|
||||
expect(content).toContain('Compare');
|
||||
expect(content).toContain('Strategy');
|
||||
expect(content).toContain('Confirm');
|
||||
});
|
||||
(component as any).applyResolution();
|
||||
await fixture.whenStable();
|
||||
|
||||
it('should display navigation buttons', () => {
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('.wizard__actions')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have back to conflicts link', () => {
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const backLink = compiled.querySelector('a[routerLink="../.."]');
|
||||
expect(backLink).toBeTruthy();
|
||||
expect(api.resolveConflict).toHaveBeenCalledWith(
|
||||
'conflict-001',
|
||||
'Consolidated precedence ordering',
|
||||
{ tenantId: 'session-tenant' },
|
||||
);
|
||||
expect(router.navigate).toHaveBeenCalledWith(['../conflicts'], {
|
||||
relativeTo: TestBed.inject(ActivatedRoute),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
PolicyConflict,
|
||||
PolicyConflictSource,
|
||||
} from '../../core/api/policy-governance.models';
|
||||
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
|
||||
|
||||
/**
|
||||
* Conflict Resolution Wizard component.
|
||||
@@ -957,6 +958,7 @@ export class ConflictResolutionWizardComponent implements OnInit {
|
||||
private readonly api = inject(POLICY_GOVERNANCE_API);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
|
||||
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly applying = signal(false);
|
||||
@@ -1006,7 +1008,7 @@ export class ConflictResolutionWizardComponent implements OnInit {
|
||||
private loadConflict(conflictId: string): void {
|
||||
this.loading.set(true);
|
||||
this.api
|
||||
.getConflicts({ tenantId: 'acme-tenant' })
|
||||
.getConflicts(this.governanceScope())
|
||||
.pipe(finalize(() => this.loading.set(false)))
|
||||
.subscribe({
|
||||
next: (conflicts) => {
|
||||
@@ -1104,7 +1106,7 @@ export class ConflictResolutionWizardComponent implements OnInit {
|
||||
|
||||
this.applying.set(true);
|
||||
this.api
|
||||
.resolveConflict(c.id, this.resolutionNotes, { tenantId: 'acme-tenant' })
|
||||
.resolveConflict(c.id, this.resolutionNotes, this.governanceScope())
|
||||
.pipe(finalize(() => this.applying.set(false)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
AuditEventType,
|
||||
GovernanceAuditDiff,
|
||||
} from '../../core/api/policy-governance.models';
|
||||
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
|
||||
|
||||
/**
|
||||
* Governance Audit component.
|
||||
@@ -571,6 +572,7 @@ import {
|
||||
})
|
||||
export class GovernanceAuditComponent implements OnInit {
|
||||
private readonly api = inject(POLICY_GOVERNANCE_API);
|
||||
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
|
||||
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly events = signal<GovernanceAuditEvent[]>([]);
|
||||
@@ -606,7 +608,7 @@ export class GovernanceAuditComponent implements OnInit {
|
||||
this.loading.set(true);
|
||||
|
||||
const options: AuditQueryOptions = {
|
||||
tenantId: 'acme-tenant',
|
||||
...this.governanceScope(),
|
||||
page,
|
||||
pageSize: 20,
|
||||
sortOrder: 'desc',
|
||||
@@ -735,7 +737,7 @@ export class GovernanceAuditComponent implements OnInit {
|
||||
targetResourceType: this.toString(record?.['targetResourceType']) || 'unknown',
|
||||
summary,
|
||||
traceId: this.toString(record?.['traceId']) || undefined,
|
||||
tenantId: this.toString(record?.['tenantId']) || 'acme-tenant',
|
||||
tenantId: this.toString(record?.['tenantId']) || this.governanceScope().tenantId,
|
||||
projectId: this.toString(record?.['projectId']) || undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TrustWeightAffectedFinding,
|
||||
Severity,
|
||||
} from '../../core/api/policy-governance.models';
|
||||
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
|
||||
|
||||
/**
|
||||
* Impact Preview component.
|
||||
@@ -525,6 +526,7 @@ import {
|
||||
})
|
||||
export class ImpactPreviewComponent implements OnInit {
|
||||
private readonly api = inject(POLICY_GOVERNANCE_API);
|
||||
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
protected readonly loading = signal(true);
|
||||
@@ -541,7 +543,7 @@ export class ImpactPreviewComponent implements OnInit {
|
||||
this.loading.set(true);
|
||||
// In real implementation, get changes from query params or service
|
||||
this.api
|
||||
.previewTrustWeightImpact([], { tenantId: 'acme-tenant' })
|
||||
.previewTrustWeightImpact([], this.governanceScope())
|
||||
.pipe(finalize(() => this.loading.set(false)))
|
||||
.subscribe({
|
||||
next: (impact) => this.impact.set(impact),
|
||||
|
||||
@@ -1,55 +1,140 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { POLICY_GOVERNANCE_API, PolicyGovernanceApi } from '../../core/api/policy-governance.client';
|
||||
import { PolicyConflict, PolicyConflictDashboard } from '../../core/api/policy-governance.models';
|
||||
import { AuthSessionStore } from '../../core/auth/auth-session.store';
|
||||
import { TenantActivationService } from '../../core/auth/tenant-activation.service';
|
||||
import { PolicyConflictDashboardComponent } from './policy-conflict-dashboard.component';
|
||||
|
||||
describe('PolicyConflictDashboardComponent', () => {
|
||||
let component: PolicyConflictDashboardComponent;
|
||||
let fixture: ComponentFixture<PolicyConflictDashboardComponent>;
|
||||
let api: jasmine.SpyObj<PolicyGovernanceApi>;
|
||||
let tenantActivation: { activeTenantId: jasmine.Spy; activeProjectId: jasmine.Spy };
|
||||
let authSession: { getActiveTenantId: jasmine.Spy };
|
||||
|
||||
const dashboard: PolicyConflictDashboard = {
|
||||
totalConflicts: 2,
|
||||
openConflicts: 1,
|
||||
byType: {
|
||||
rule_overlap: 1,
|
||||
precedence_ambiguity: 1,
|
||||
circular_dependency: 0,
|
||||
incompatible_actions: 0,
|
||||
scope_collision: 0,
|
||||
},
|
||||
bySeverity: {
|
||||
info: 1,
|
||||
warning: 1,
|
||||
error: 0,
|
||||
critical: 0,
|
||||
},
|
||||
recentConflicts: [],
|
||||
trend: [
|
||||
{ date: '2026-03-07', count: 0 },
|
||||
{ date: '2026-03-08', count: 0 },
|
||||
{ date: '2026-03-09', count: 1 },
|
||||
{ date: '2026-03-10', count: 0 },
|
||||
{ date: '2026-03-11', count: 1 },
|
||||
{ date: '2026-03-12', count: 0 },
|
||||
{ date: '2026-03-13', count: 0 },
|
||||
],
|
||||
lastAnalyzedAt: '2026-03-13T12:00:00Z',
|
||||
};
|
||||
|
||||
const conflicts: PolicyConflict[] = [
|
||||
{
|
||||
id: 'conflict-001',
|
||||
type: 'rule_overlap',
|
||||
severity: 'warning',
|
||||
summary: 'Overlapping severity rules in profiles',
|
||||
description: 'Two profiles can emit conflicting outcomes for the same release.',
|
||||
sourceA: { id: 'profile-default', type: 'profile', name: 'Default Risk Profile', version: '1.0.0', path: 'severityOverrides[0]' },
|
||||
sourceB: { id: 'profile-strict', type: 'profile', name: 'Strict Security Profile', version: '1.1.0', path: 'severityOverrides[0]' },
|
||||
affectedScope: ['production'],
|
||||
impactAssessment: 'Live releases may oscillate between warn and block.',
|
||||
suggestedResolution: 'Choose one precedence order.',
|
||||
detectedAt: '2026-03-13T10:00:00Z',
|
||||
status: 'open',
|
||||
},
|
||||
{
|
||||
id: 'conflict-002',
|
||||
type: 'precedence_ambiguity',
|
||||
severity: 'info',
|
||||
summary: 'Ambiguous rule precedence',
|
||||
description: 'Two rules share the same priority.',
|
||||
sourceA: { id: 'gate-cvss-high', type: 'rule', name: 'CVSS High Escalation' },
|
||||
sourceB: { id: 'gate-exploit-available', type: 'rule', name: 'Exploit Available Escalation' },
|
||||
affectedScope: ['all'],
|
||||
impactAssessment: 'Explain traces may differ between runs.',
|
||||
suggestedResolution: 'Assign distinct priorities.',
|
||||
detectedAt: '2026-03-12T12:00:00Z',
|
||||
status: 'acknowledged',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
api = jasmine.createSpyObj<PolicyGovernanceApi>('PolicyGovernanceApi', [
|
||||
'getConflictDashboard',
|
||||
'getConflicts',
|
||||
'resolveConflict',
|
||||
'ignoreConflict',
|
||||
]);
|
||||
tenantActivation = {
|
||||
activeTenantId: jasmine.createSpy('activeTenantId').and.returnValue('demo-prod'),
|
||||
activeProjectId: jasmine.createSpy('activeProjectId').and.returnValue('stage'),
|
||||
};
|
||||
authSession = {
|
||||
getActiveTenantId: jasmine.createSpy('getActiveTenantId').and.returnValue('fallback-tenant'),
|
||||
};
|
||||
|
||||
api.getConflictDashboard.and.returnValue(of(dashboard));
|
||||
api.getConflicts.and.returnValue(of(conflicts));
|
||||
api.resolveConflict.and.returnValue(of({ ...conflicts[0], status: 'resolved', resolutionNotes: 'Consolidated precedence ordering' }));
|
||||
api.ignoreConflict.and.returnValue(of({ ...conflicts[1], status: 'ignored', resolutionNotes: 'Accepted for lab-only profile' }));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PolicyConflictDashboardComponent, FormsModule],
|
||||
providers: [provideRouter([])],
|
||||
imports: [PolicyConflictDashboardComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: POLICY_GOVERNANCE_API, useValue: api },
|
||||
{ provide: TenantActivationService, useValue: tenantActivation },
|
||||
{ provide: AuthSessionStore, useValue: authSession },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyConflictDashboardComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('loads the dashboard and conflicts with the active governance scope', () => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
expect(api.getConflictDashboard).toHaveBeenCalledWith({ tenantId: 'demo-prod', projectId: 'stage' });
|
||||
expect(api.getConflicts).toHaveBeenCalledWith({ tenantId: 'demo-prod', projectId: 'stage' });
|
||||
|
||||
it('should render conflicts header', () => {
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('.conflicts__header')).toBeTruthy();
|
||||
expect(compiled.textContent).toContain('Policy Conflicts');
|
||||
expect(compiled.textContent).toContain('Overlapping severity rules in profiles');
|
||||
expect(compiled.textContent).toContain('Resolve Wizard');
|
||||
});
|
||||
|
||||
it('should display conflict statistics', () => {
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('.conflicts__stats')).toBeTruthy();
|
||||
});
|
||||
it('uses the same resolved scope for quick resolve actions and refreshes the page state', () => {
|
||||
fixture.detectChanges();
|
||||
api.getConflictDashboard.calls.reset();
|
||||
api.getConflicts.calls.reset();
|
||||
spyOn(window, 'prompt').and.returnValue('Consolidated precedence ordering');
|
||||
|
||||
it('should show conflicts list', () => {
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('.conflicts__list, .conflicts__table')).toBeTruthy();
|
||||
});
|
||||
(component as any).resolveConflict(conflicts[0]);
|
||||
|
||||
it('should display resolve buttons for conflicts', () => {
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.textContent).toContain('Resolve');
|
||||
});
|
||||
|
||||
it('should show conflict severity indicators', () => {
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('.conflict__severity')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have filter controls', () => {
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('.conflicts__filters')).toBeTruthy();
|
||||
expect(api.resolveConflict).toHaveBeenCalledWith(
|
||||
'conflict-001',
|
||||
'Consolidated precedence ordering',
|
||||
{ tenantId: 'demo-prod', projectId: 'stage' },
|
||||
);
|
||||
expect(api.getConflictDashboard).toHaveBeenCalledWith({ tenantId: 'demo-prod', projectId: 'stage' });
|
||||
expect(api.getConflicts).toHaveBeenCalledWith({ tenantId: 'demo-prod', projectId: 'stage' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
PolicyConflictType,
|
||||
PolicyConflictSeverity,
|
||||
} from '../../core/api/policy-governance.models';
|
||||
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
|
||||
|
||||
/**
|
||||
* Policy Conflict Dashboard component.
|
||||
@@ -598,6 +599,7 @@ import {
|
||||
})
|
||||
export class PolicyConflictDashboardComponent implements OnInit {
|
||||
private readonly api = inject(POLICY_GOVERNANCE_API);
|
||||
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
|
||||
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly analyzing = signal(false);
|
||||
@@ -613,7 +615,7 @@ export class PolicyConflictDashboardComponent implements OnInit {
|
||||
}
|
||||
|
||||
private loadDashboard(): void {
|
||||
this.api.getConflictDashboard({ tenantId: 'acme-tenant' }).subscribe({
|
||||
this.api.getConflictDashboard(this.governanceScope()).subscribe({
|
||||
next: (d) => this.dashboard.set(d),
|
||||
error: (err) => console.error('Failed to load dashboard:', err),
|
||||
});
|
||||
@@ -621,7 +623,7 @@ export class PolicyConflictDashboardComponent implements OnInit {
|
||||
|
||||
protected loadConflicts(): void {
|
||||
this.loading.set(true);
|
||||
const options: any = { tenantId: 'acme-tenant' };
|
||||
const options: any = { ...this.governanceScope() };
|
||||
if (this.typeFilter) options.type = this.typeFilter;
|
||||
if (this.severityFilter) options.severity = this.severityFilter;
|
||||
|
||||
@@ -684,7 +686,7 @@ export class PolicyConflictDashboardComponent implements OnInit {
|
||||
const resolution = prompt('Enter resolution notes:');
|
||||
if (!resolution) return;
|
||||
|
||||
this.api.resolveConflict(conflict.id, resolution, { tenantId: 'acme-tenant' }).subscribe({
|
||||
this.api.resolveConflict(conflict.id, resolution, this.governanceScope()).subscribe({
|
||||
next: () => {
|
||||
this.loadConflicts();
|
||||
this.loadDashboard();
|
||||
@@ -697,7 +699,7 @@ export class PolicyConflictDashboardComponent implements OnInit {
|
||||
const reason = prompt('Enter reason for ignoring:');
|
||||
if (!reason) return;
|
||||
|
||||
this.api.ignoreConflict(conflict.id, reason, { tenantId: 'acme-tenant' }).subscribe({
|
||||
this.api.ignoreConflict(conflict.id, reason, this.governanceScope()).subscribe({
|
||||
next: () => {
|
||||
this.loadConflicts();
|
||||
this.loadDashboard();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { inject } from '@angular/core';
|
||||
|
||||
import { AuthSessionStore } from '../../core/auth/auth-session.store';
|
||||
import { TenantActivationService } from '../../core/auth/tenant-activation.service';
|
||||
import { GovernanceQueryOptions } from '../../core/api/policy-governance.models';
|
||||
|
||||
export function injectPolicyGovernanceScopeResolver(
|
||||
fallbackTenantId = 'demo-prod',
|
||||
): () => GovernanceQueryOptions {
|
||||
const tenantActivation = inject(TenantActivationService);
|
||||
const authSession = inject(AuthSessionStore);
|
||||
|
||||
return () => {
|
||||
const tenantId =
|
||||
tenantActivation.activeTenantId()?.trim() ||
|
||||
authSession.getActiveTenantId()?.trim() ||
|
||||
fallbackTenantId;
|
||||
const projectId = tenantActivation.activeProjectId()?.trim() || undefined;
|
||||
|
||||
return projectId ? { tenantId, projectId } : { tenantId };
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
RiskBudgetGovernance,
|
||||
RiskBudgetThreshold,
|
||||
} from '../../core/api/policy-governance.models';
|
||||
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
|
||||
|
||||
/**
|
||||
* Risk Budget Configuration component.
|
||||
@@ -515,6 +516,7 @@ import {
|
||||
})
|
||||
export class RiskBudgetConfigComponent implements OnInit {
|
||||
private readonly api = inject(POLICY_GOVERNANCE_API);
|
||||
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
@@ -595,7 +597,7 @@ export class RiskBudgetConfigComponent implements OnInit {
|
||||
this.loadError.set(null);
|
||||
this.loading.set(true);
|
||||
this.api
|
||||
.getRiskBudgetDashboard({ tenantId: 'acme-tenant' })
|
||||
.getRiskBudgetDashboard(this.governanceScope())
|
||||
.pipe(finalize(() => this.loading.set(false)))
|
||||
.subscribe({
|
||||
next: (dashboard) => {
|
||||
@@ -682,8 +684,8 @@ export class RiskBudgetConfigComponent implements OnInit {
|
||||
|
||||
const config: RiskBudgetGovernance = {
|
||||
id: baseline?.id ?? 'budget-001',
|
||||
tenantId: baseline?.tenantId ?? 'acme-tenant',
|
||||
projectId: baseline?.projectId,
|
||||
tenantId: baseline?.tenantId ?? this.governanceScope().tenantId,
|
||||
projectId: baseline?.projectId ?? this.governanceScope().projectId,
|
||||
name: formValue.name,
|
||||
totalBudget: formValue.totalBudget,
|
||||
period: formValue.period,
|
||||
@@ -705,7 +707,7 @@ export class RiskBudgetConfigComponent implements OnInit {
|
||||
};
|
||||
|
||||
this.api
|
||||
.updateRiskBudgetConfig(config, { tenantId: 'acme-tenant' })
|
||||
.updateRiskBudgetConfig(config, this.governanceScope())
|
||||
.pipe(finalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: (updatedConfig) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RiskBudgetContributor,
|
||||
RiskBudgetAlert,
|
||||
} from '../../core/api/policy-governance.models';
|
||||
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
|
||||
|
||||
/**
|
||||
* Risk Budget Dashboard component.
|
||||
@@ -626,6 +627,7 @@ import {
|
||||
})
|
||||
export class RiskBudgetDashboardComponent implements OnInit {
|
||||
private readonly api = inject(POLICY_GOVERNANCE_API);
|
||||
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
|
||||
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly data = signal<RiskBudgetDashboard | null>(null);
|
||||
@@ -639,7 +641,7 @@ export class RiskBudgetDashboardComponent implements OnInit {
|
||||
this.loadError.set(null);
|
||||
this.loading.set(true);
|
||||
this.api
|
||||
.getRiskBudgetDashboard({ tenantId: 'acme-tenant' })
|
||||
.getRiskBudgetDashboard(this.governanceScope())
|
||||
.pipe(finalize(() => this.loading.set(false)))
|
||||
.subscribe({
|
||||
next: (dashboard) => {
|
||||
@@ -663,8 +665,8 @@ export class RiskBudgetDashboardComponent implements OnInit {
|
||||
|
||||
const config = {
|
||||
id: rawConfig?.id ?? 'risk-budget-default',
|
||||
tenantId: rawConfig?.tenantId ?? 'acme-tenant',
|
||||
projectId: rawConfig?.projectId,
|
||||
tenantId: rawConfig?.tenantId ?? this.governanceScope().tenantId,
|
||||
projectId: rawConfig?.projectId ?? this.governanceScope().projectId,
|
||||
name: rawConfig?.name ?? 'Default Risk Budget',
|
||||
totalBudget,
|
||||
warningThreshold,
|
||||
@@ -750,7 +752,7 @@ export class RiskBudgetDashboardComponent implements OnInit {
|
||||
}
|
||||
|
||||
protected acknowledgeAlert(alert: RiskBudgetAlert): void {
|
||||
this.api.acknowledgeAlert(alert.id, { tenantId: 'acme-tenant' }).subscribe({
|
||||
this.api.acknowledgeAlert(alert.id, this.governanceScope()).subscribe({
|
||||
next: () => this.loadData(),
|
||||
error: (err) => console.error('Failed to acknowledge alert:', err),
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
RiskProfileValidation,
|
||||
SignalWeight,
|
||||
} from '../../core/api/policy-governance.models';
|
||||
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
|
||||
|
||||
/**
|
||||
* Risk Profile Editor component.
|
||||
@@ -559,6 +560,7 @@ import {
|
||||
})
|
||||
export class RiskProfileEditorComponent implements OnInit {
|
||||
private readonly api = inject(POLICY_GOVERNANCE_API);
|
||||
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
@@ -610,7 +612,7 @@ export class RiskProfileEditorComponent implements OnInit {
|
||||
}
|
||||
|
||||
private loadAvailableProfiles(): void {
|
||||
this.api.listRiskProfiles({ tenantId: 'acme-tenant', status: 'active' }).subscribe({
|
||||
this.api.listRiskProfiles({ ...this.governanceScope(), status: 'active' }).subscribe({
|
||||
next: (profiles) => this.availableProfiles.set(profiles),
|
||||
});
|
||||
}
|
||||
@@ -618,7 +620,7 @@ export class RiskProfileEditorComponent implements OnInit {
|
||||
private loadProfile(profileId: string): void {
|
||||
this.loading.set(true);
|
||||
this.api
|
||||
.getRiskProfile(profileId, { tenantId: 'acme-tenant' })
|
||||
.getRiskProfile(profileId, this.governanceScope())
|
||||
.pipe(finalize(() => this.loading.set(false)))
|
||||
.subscribe({
|
||||
next: (profile) => {
|
||||
@@ -736,8 +738,8 @@ export class RiskProfileEditorComponent implements OnInit {
|
||||
const profile = this.buildProfile();
|
||||
|
||||
const request$ = this.isNew()
|
||||
? this.api.createRiskProfile(profile, { tenantId: 'acme-tenant' })
|
||||
: this.api.updateRiskProfile(profile.id!, profile, { tenantId: 'acme-tenant' });
|
||||
? this.api.createRiskProfile(profile, this.governanceScope())
|
||||
: this.api.updateRiskProfile(profile.id!, profile, this.governanceScope());
|
||||
|
||||
request$.pipe(finalize(() => this.saving.set(false))).subscribe({
|
||||
next: () => this.router.navigate(['../'], { relativeTo: this.route }),
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RiskProfileGov,
|
||||
RiskProfileGovernanceStatus,
|
||||
} from '../../core/api/policy-governance.models';
|
||||
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
|
||||
|
||||
/**
|
||||
* Risk Profile List component.
|
||||
@@ -389,6 +390,7 @@ import {
|
||||
})
|
||||
export class RiskProfileListComponent implements OnInit {
|
||||
private readonly api = inject(POLICY_GOVERNANCE_API);
|
||||
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
|
||||
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly profiles = signal<RiskProfileGov[]>([]);
|
||||
@@ -400,7 +402,7 @@ export class RiskProfileListComponent implements OnInit {
|
||||
|
||||
private loadProfiles(): void {
|
||||
this.loading.set(true);
|
||||
const options: any = { tenantId: 'acme-tenant' };
|
||||
const options: any = { ...this.governanceScope() };
|
||||
const filter = this.statusFilter();
|
||||
if (filter) {
|
||||
options.status = filter;
|
||||
@@ -423,7 +425,7 @@ export class RiskProfileListComponent implements OnInit {
|
||||
protected activateProfile(profile: RiskProfileGov): void {
|
||||
if (!confirm(`Activate profile "${profile.name}"?`)) return;
|
||||
|
||||
this.api.activateRiskProfile(profile.id, { tenantId: 'acme-tenant' }).subscribe({
|
||||
this.api.activateRiskProfile(profile.id, this.governanceScope()).subscribe({
|
||||
next: () => this.loadProfiles(),
|
||||
error: (err) => console.error('Failed to activate profile:', err),
|
||||
});
|
||||
@@ -433,7 +435,7 @@ export class RiskProfileListComponent implements OnInit {
|
||||
const reason = prompt(`Reason for deprecating "${profile.name}":`);
|
||||
if (!reason) return;
|
||||
|
||||
this.api.deprecateRiskProfile(profile.id, reason, { tenantId: 'acme-tenant' }).subscribe({
|
||||
this.api.deprecateRiskProfile(profile.id, reason, this.governanceScope()).subscribe({
|
||||
next: () => this.loadProfiles(),
|
||||
error: (err) => console.error('Failed to deprecate profile:', err),
|
||||
});
|
||||
@@ -442,7 +444,7 @@ export class RiskProfileListComponent implements OnInit {
|
||||
protected deleteProfile(profile: RiskProfileGov): void {
|
||||
if (!confirm(`Delete profile "${profile.name}"? This cannot be undone.`)) return;
|
||||
|
||||
this.api.deleteRiskProfile(profile.id, { tenantId: 'acme-tenant' }).subscribe({
|
||||
this.api.deleteRiskProfile(profile.id, this.governanceScope()).subscribe({
|
||||
next: () => this.loadProfiles(),
|
||||
error: (err) => console.error('Failed to delete profile:', err),
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SealedModeToggleRequest,
|
||||
SealedModeOverrideRequest,
|
||||
} from '../../core/api/policy-governance.models';
|
||||
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
|
||||
|
||||
/**
|
||||
* Sealed Mode Control component.
|
||||
@@ -754,6 +755,7 @@ import {
|
||||
})
|
||||
export class SealedModeControlComponent implements OnInit {
|
||||
private readonly api = inject(POLICY_GOVERNANCE_API);
|
||||
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
protected readonly loading = signal(false);
|
||||
@@ -788,7 +790,7 @@ export class SealedModeControlComponent implements OnInit {
|
||||
private loadStatus(): void {
|
||||
this.loading.set(true);
|
||||
this.api
|
||||
.getSealedModeStatus({ tenantId: 'acme-tenant' })
|
||||
.getSealedModeStatus(this.governanceScope())
|
||||
.pipe(finalize(() => this.loading.set(false)))
|
||||
.subscribe({
|
||||
next: (status) => this.status.set(this.buildSafeStatus(status)),
|
||||
@@ -823,7 +825,7 @@ export class SealedModeControlComponent implements OnInit {
|
||||
};
|
||||
|
||||
this.api
|
||||
.toggleSealedMode(request, { tenantId: 'acme-tenant' })
|
||||
.toggleSealedMode(request, this.governanceScope())
|
||||
.pipe(finalize(() => this.toggling.set(false)))
|
||||
.subscribe({
|
||||
next: (status) => {
|
||||
@@ -854,7 +856,7 @@ export class SealedModeControlComponent implements OnInit {
|
||||
};
|
||||
|
||||
this.api
|
||||
.toggleSealedMode(request, { tenantId: 'acme-tenant' })
|
||||
.toggleSealedMode(request, this.governanceScope())
|
||||
.pipe(finalize(() => this.toggling.set(false)))
|
||||
.subscribe({
|
||||
next: (status) => {
|
||||
@@ -888,7 +890,7 @@ export class SealedModeControlComponent implements OnInit {
|
||||
};
|
||||
|
||||
this.api
|
||||
.createSealedModeOverride(request, { tenantId: 'acme-tenant' })
|
||||
.createSealedModeOverride(request, this.governanceScope())
|
||||
.pipe(finalize(() => this.creatingOverride.set(false)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
@@ -902,7 +904,7 @@ export class SealedModeControlComponent implements OnInit {
|
||||
protected revokeOverride(override: SealedModeOverride): void {
|
||||
if (!confirm('Revoke this override?')) return;
|
||||
|
||||
this.api.revokeSealedModeOverride(override.id, 'user_revoked', { tenantId: 'acme-tenant' }).subscribe({
|
||||
this.api.revokeSealedModeOverride(override.id, 'user_revoked', this.governanceScope()).subscribe({
|
||||
next: () => this.loadStatus(),
|
||||
error: (err) => console.error('Failed to revoke override:', err),
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
SealedModeOverride,
|
||||
SealedModeOverrideRequest,
|
||||
} from '../../core/api/policy-governance.models';
|
||||
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
|
||||
|
||||
/**
|
||||
* Sealed Mode Overrides component.
|
||||
@@ -615,6 +616,7 @@ import {
|
||||
})
|
||||
export class SealedModeOverridesComponent implements OnInit {
|
||||
private readonly api = inject(POLICY_GOVERNANCE_API);
|
||||
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
|
||||
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly creating = signal(false);
|
||||
@@ -659,7 +661,7 @@ export class SealedModeOverridesComponent implements OnInit {
|
||||
private loadOverrides(): void {
|
||||
this.loading.set(true);
|
||||
this.api
|
||||
.getSealedModeOverrides({ tenantId: 'acme-tenant' })
|
||||
.getSealedModeOverrides(this.governanceScope())
|
||||
.pipe(finalize(() => this.loading.set(false)))
|
||||
.subscribe({
|
||||
next: (overrides) => this.allOverrides.set(overrides),
|
||||
@@ -698,7 +700,7 @@ export class SealedModeOverridesComponent implements OnInit {
|
||||
|
||||
this.creating.set(true);
|
||||
this.api
|
||||
.createSealedModeOverride(this.newOverride, { tenantId: 'acme-tenant' })
|
||||
.createSealedModeOverride(this.newOverride, this.governanceScope())
|
||||
.pipe(finalize(() => this.creating.set(false)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
@@ -726,7 +728,7 @@ export class SealedModeOverridesComponent implements OnInit {
|
||||
reason: `Extension for ${override.id}: ${override.reason}`,
|
||||
durationHours,
|
||||
},
|
||||
{ tenantId: 'acme-tenant' }
|
||||
this.governanceScope()
|
||||
)
|
||||
.pipe(finalize(() => this.creating.set(false)))
|
||||
.subscribe({
|
||||
@@ -739,7 +741,7 @@ export class SealedModeOverridesComponent implements OnInit {
|
||||
const reason = prompt('Reason for revoking this override:');
|
||||
if (!reason) return;
|
||||
|
||||
this.api.revokeSealedModeOverride(override.id, reason, { tenantId: 'acme-tenant' }).subscribe({
|
||||
this.api.revokeSealedModeOverride(override.id, reason, this.governanceScope()).subscribe({
|
||||
next: () => this.loadOverrides(),
|
||||
error: (err) => console.error('Failed to revoke override:', err),
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
StalenessDataType,
|
||||
StalenessLevel,
|
||||
} from '../../core/api/policy-governance.models';
|
||||
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
|
||||
|
||||
/**
|
||||
* Staleness Configuration component.
|
||||
@@ -601,6 +602,7 @@ import {
|
||||
})
|
||||
export class StalenessConfigComponent implements OnInit {
|
||||
private readonly api = inject(POLICY_GOVERNANCE_API);
|
||||
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
|
||||
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly saving = signal(false);
|
||||
@@ -625,7 +627,7 @@ export class StalenessConfigComponent implements OnInit {
|
||||
private loadConfig(): void {
|
||||
this.loading.set(true);
|
||||
this.api
|
||||
.getStalenessConfig({ tenantId: 'acme-tenant' })
|
||||
.getStalenessConfig(this.governanceScope())
|
||||
.pipe(finalize(() => this.loading.set(false)))
|
||||
.subscribe({
|
||||
next: (container) => {
|
||||
@@ -642,7 +644,7 @@ export class StalenessConfigComponent implements OnInit {
|
||||
}
|
||||
|
||||
private loadStatus(): void {
|
||||
this.api.getStalenessStatus({ tenantId: 'acme-tenant' }).subscribe({
|
||||
this.api.getStalenessStatus(this.governanceScope()).subscribe({
|
||||
next: (statuses) => this.statusList.set(Array.isArray(statuses) ? statuses : []),
|
||||
error: (err) => console.error('Failed to load staleness status:', err),
|
||||
});
|
||||
@@ -710,7 +712,7 @@ export class StalenessConfigComponent implements OnInit {
|
||||
protected saveConfig(config: StalenessConfig): void {
|
||||
this.saving.set(true);
|
||||
this.api
|
||||
.updateStalenessConfig(config, { tenantId: 'acme-tenant' })
|
||||
.updateStalenessConfig(config, this.governanceScope())
|
||||
.pipe(finalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: () => this.loadConfig(),
|
||||
@@ -735,8 +737,8 @@ export class StalenessConfigComponent implements OnInit {
|
||||
);
|
||||
|
||||
return {
|
||||
tenantId: container?.tenantId ?? 'acme-tenant',
|
||||
projectId: container?.projectId,
|
||||
tenantId: container?.tenantId ?? this.governanceScope().tenantId,
|
||||
projectId: container?.projectId ?? this.governanceScope().projectId,
|
||||
configs,
|
||||
modifiedAt: container?.modifiedAt ?? now,
|
||||
etag: container?.etag,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
TrustWeightImpact,
|
||||
TrustWeightSource,
|
||||
} from '../../core/api/policy-governance.models';
|
||||
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
|
||||
|
||||
/**
|
||||
* Trust Weighting component.
|
||||
@@ -708,6 +709,7 @@ import {
|
||||
})
|
||||
export class TrustWeightingComponent implements OnInit {
|
||||
private readonly api = inject(POLICY_GOVERNANCE_API);
|
||||
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
protected readonly loading = signal(false);
|
||||
@@ -738,7 +740,7 @@ export class TrustWeightingComponent implements OnInit {
|
||||
private loadConfig(): void {
|
||||
this.loading.set(true);
|
||||
this.api
|
||||
.getTrustWeightConfig({ tenantId: 'acme-tenant' })
|
||||
.getTrustWeightConfig(this.governanceScope())
|
||||
.pipe(finalize(() => this.loading.set(false)))
|
||||
.subscribe({
|
||||
next: (config) => this.config.set(config),
|
||||
@@ -814,7 +816,7 @@ export class TrustWeightingComponent implements OnInit {
|
||||
};
|
||||
|
||||
this.api
|
||||
.updateTrustWeight(weight, { tenantId: 'acme-tenant' })
|
||||
.updateTrustWeight(weight, this.governanceScope())
|
||||
.pipe(finalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
@@ -828,7 +830,7 @@ export class TrustWeightingComponent implements OnInit {
|
||||
protected deleteWeight(weight: TrustWeight): void {
|
||||
if (!confirm(`Delete trust weight for ${weight.issuerName}?`)) return;
|
||||
|
||||
this.api.deleteTrustWeight(weight.id, { tenantId: 'acme-tenant' }).subscribe({
|
||||
this.api.deleteTrustWeight(weight.id, this.governanceScope()).subscribe({
|
||||
next: () => this.loadConfig(),
|
||||
error: (err) => console.error('Failed to delete weight:', err),
|
||||
});
|
||||
@@ -840,7 +842,7 @@ export class TrustWeightingComponent implements OnInit {
|
||||
this.impact.set(null);
|
||||
|
||||
this.api
|
||||
.previewTrustWeightImpact([weight], { tenantId: 'acme-tenant' })
|
||||
.previewTrustWeightImpact([weight], this.governanceScope())
|
||||
.pipe(finalize(() => this.impactLoading.set(false)))
|
||||
.subscribe({
|
||||
next: (result) => this.impact.set(result),
|
||||
|
||||
@@ -662,7 +662,7 @@ export class VexHubDashboardComponent implements OnInit {
|
||||
getSourcePercentage(value: number): number {
|
||||
const s = this.stats();
|
||||
if (!s) return 0;
|
||||
const max = Math.max(...Object.values(s.bySource));
|
||||
const max = Math.max(0, ...Object.values(s.bySource));
|
||||
return max > 0 ? (value / max) * 100 : 0;
|
||||
}
|
||||
|
||||
@@ -673,6 +673,11 @@ export class VexHubDashboardComponent implements OnInit {
|
||||
oss: 'OSS Maintainer',
|
||||
researcher: 'Security Researcher',
|
||||
ai_generated: 'AI Generated',
|
||||
internal: 'Internal',
|
||||
community: 'Community',
|
||||
distributor: 'Distributor',
|
||||
aggregator: 'Aggregator',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { VEX_HUB_API, VexHubApi } from '../../core/api/vex-hub.client';
|
||||
import { VexHubStats } from '../../core/api/vex-hub.models';
|
||||
import { VexHubDashboardComponent } from './vex-hub-dashboard.component';
|
||||
import { VexHubStatsComponent } from './vex-hub-stats.component';
|
||||
|
||||
describe('VexHub source contract coverage', () => {
|
||||
let api: jasmine.SpyObj<VexHubApi>;
|
||||
|
||||
const dashboardContract: VexHubStats = {
|
||||
totalStatements: 12,
|
||||
byStatus: {
|
||||
affected: 4,
|
||||
not_affected: 5,
|
||||
fixed: 2,
|
||||
under_investigation: 1,
|
||||
},
|
||||
bySource: {
|
||||
internal: 9,
|
||||
community: 3,
|
||||
},
|
||||
recentActivity: [],
|
||||
trends: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
api = jasmine.createSpyObj<VexHubApi>('VexHubApi', [
|
||||
'searchStatements',
|
||||
'getStatement',
|
||||
'createStatement',
|
||||
'createStatementSimple',
|
||||
'getStats',
|
||||
'getConsensus',
|
||||
'getConsensusResult',
|
||||
'getConflicts',
|
||||
'getConflictStatements',
|
||||
'resolveConflict',
|
||||
'getVexLensConsensus',
|
||||
'getVexLensConflicts',
|
||||
]);
|
||||
api.getStats.and.returnValue(of(dashboardContract));
|
||||
});
|
||||
|
||||
it('keeps dynamic source buckets in descending order for the stats breakdown', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VexHubStatsComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: VEX_HUB_API, useValue: api },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(VexHubStatsComponent);
|
||||
const component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.sourceItems()).toEqual([
|
||||
{ source: 'internal', count: 9 },
|
||||
{ source: 'community', count: 3 },
|
||||
]);
|
||||
expect(component.formatSourceType('internal')).toBe('Internal');
|
||||
expect(component.formatSourceType('community')).toBe('Community');
|
||||
expect(component.getSourceIcon('aggregator')).toBe('A');
|
||||
});
|
||||
|
||||
it('renders dynamic dashboard source labels from the live stats contract', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VexHubDashboardComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: VEX_HUB_API, useValue: api },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture: ComponentFixture<VexHubDashboardComponent> = TestBed.createComponent(VexHubDashboardComponent);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const labels = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.source-card__label') as NodeListOf<HTMLElement>,
|
||||
).map((node) => node.textContent?.trim());
|
||||
|
||||
expect(labels).toContain('Internal');
|
||||
expect(labels).toContain('Community');
|
||||
});
|
||||
|
||||
it('returns zero dashboard source width when the source map is empty', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VexHubDashboardComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: VEX_HUB_API, useValue: api },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(VexHubDashboardComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component.stats.set({
|
||||
...dashboardContract,
|
||||
bySource: {},
|
||||
});
|
||||
|
||||
expect(component.getSourcePercentage(4)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,18 @@
|
||||
/**
|
||||
* Unit tests for VexHubStatsComponent.
|
||||
* Tests for VEX-AI-004: Statements by status, source breakdown, trends.
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { of, throwError } from 'rxjs';
|
||||
|
||||
import { VexHubStatsComponent } from './vex-hub-stats.component';
|
||||
import { VEX_HUB_API, VexHubApi } from '../../core/api/vex-hub.client';
|
||||
import { VexHubStats, VexActivityItem, VexTrendData } from '../../core/api/vex-hub.models';
|
||||
import { VexHubStats } from '../../core/api/vex-hub.models';
|
||||
import { VexHubStatsComponent } from './vex-hub-stats.component';
|
||||
|
||||
describe('VexHubStatsComponent', () => {
|
||||
let component: VexHubStatsComponent;
|
||||
let fixture: ComponentFixture<VexHubStatsComponent>;
|
||||
let mockVexHubApi: jasmine.SpyObj<VexHubApi>;
|
||||
let component: VexHubStatsComponent;
|
||||
let api: jasmine.SpyObj<VexHubApi>;
|
||||
|
||||
const mockStats: VexHubStats = {
|
||||
const stats: VexHubStats = {
|
||||
totalStatements: 1000,
|
||||
byStatus: {
|
||||
affected: 150,
|
||||
@@ -63,8 +58,8 @@ describe('VexHubStatsComponent', () => {
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockVexHubApi = jasmine.createSpyObj<VexHubApi>('VexHubApi', [
|
||||
async function createComponent(): Promise<void> {
|
||||
api = jasmine.createSpyObj<VexHubApi>('VexHubApi', [
|
||||
'searchStatements',
|
||||
'getStatement',
|
||||
'createStatement',
|
||||
@@ -78,424 +73,135 @@ describe('VexHubStatsComponent', () => {
|
||||
'getVexLensConsensus',
|
||||
'getVexLensConflicts',
|
||||
]);
|
||||
|
||||
mockVexHubApi.getStats.and.returnValue(of(mockStats));
|
||||
api.getStats.and.returnValue(of(stats));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VexHubStatsComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: VEX_HUB_API, useValue: mockVexHubApi },
|
||||
{ provide: VEX_HUB_API, useValue: api },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VexHubStatsComponent);
|
||||
component = fixture.componentInstance;
|
||||
}
|
||||
|
||||
async function renderComponent(): Promise<void> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await createComponent();
|
||||
});
|
||||
|
||||
describe('Component Creation', () => {
|
||||
it('should create the component', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
it('loads stats on init and renders the summary surface', async () => {
|
||||
await renderComponent();
|
||||
|
||||
it('should have default signal values', () => {
|
||||
expect(component.loading()).toBe(false);
|
||||
expect(component.error()).toBeNull();
|
||||
expect(component.stats()).toBeNull();
|
||||
});
|
||||
expect(api.getStats).toHaveBeenCalled();
|
||||
expect(component.stats()).toEqual(stats);
|
||||
expect(component.loading()).toBe(false);
|
||||
|
||||
it('should have default refreshInterval input', () => {
|
||||
expect(component.refreshInterval()).toBe(0);
|
||||
});
|
||||
const header = fixture.debugElement.query(By.css('.stats-header h1'));
|
||||
const summaryValue = fixture.debugElement.query(By.css('.summary-value'));
|
||||
const sourceRows = fixture.debugElement.queryAll(By.css('.source-row'));
|
||||
|
||||
expect(header.nativeElement.textContent).toContain('VEX Hub Statistics');
|
||||
expect(summaryValue.nativeElement.textContent).toContain('1,000');
|
||||
expect(sourceRows.length).toBe(5);
|
||||
});
|
||||
|
||||
describe('Template Rendering', () => {
|
||||
it('should render header', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
it('computes status, source, and trend values from the loaded contract', async () => {
|
||||
await renderComponent();
|
||||
|
||||
const header = fixture.debugElement.query(By.css('.stats-header h1'));
|
||||
expect(header.nativeElement.textContent).toContain('VEX Hub Statistics');
|
||||
}));
|
||||
expect(component.statusItems()).toEqual([
|
||||
{ status: 'affected', count: 150 },
|
||||
{ status: 'not_affected', count: 600 },
|
||||
{ status: 'fixed', count: 200 },
|
||||
{ status: 'under_investigation', count: 50 },
|
||||
]);
|
||||
|
||||
it('should render back button', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const backButton = fixture.debugElement.query(By.css('.btn-back'));
|
||||
expect(backButton).not.toBeNull();
|
||||
expect(backButton.nativeElement.textContent).toContain('Dashboard');
|
||||
}));
|
||||
|
||||
it('should show loading state', () => {
|
||||
component.loading.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const loadingState = fixture.debugElement.query(By.css('.loading-state'));
|
||||
expect(loadingState).not.toBeNull();
|
||||
expect(loadingState.nativeElement.textContent).toContain('Loading statistics');
|
||||
});
|
||||
|
||||
it('should render total statements card', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const summaryCard = fixture.debugElement.query(By.css('.summary-card--total'));
|
||||
expect(summaryCard).not.toBeNull();
|
||||
|
||||
const summaryValue = fixture.debugElement.query(By.css('.summary-value'));
|
||||
expect(summaryValue.nativeElement.textContent).toContain('1,000');
|
||||
}));
|
||||
|
||||
it('should render status distribution section', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const distributionSection = fixture.debugElement.query(By.css('.distribution-section'));
|
||||
expect(distributionSection).not.toBeNull();
|
||||
|
||||
const statusCards = fixture.debugElement.queryAll(By.css('.status-card'));
|
||||
expect(statusCards.length).toBe(4);
|
||||
}));
|
||||
|
||||
it('should render status legend', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const legendItems = fixture.debugElement.queryAll(By.css('.legend-item'));
|
||||
expect(legendItems.length).toBe(4);
|
||||
}));
|
||||
|
||||
it('should render source breakdown section', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const sourcesSection = fixture.debugElement.query(By.css('.sources-section'));
|
||||
expect(sourcesSection).not.toBeNull();
|
||||
|
||||
const sourceRows = fixture.debugElement.queryAll(By.css('.source-row'));
|
||||
expect(sourceRows.length).toBe(5);
|
||||
}));
|
||||
|
||||
it('should render recent activity section', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const activitySection = fixture.debugElement.query(By.css('.activity-section'));
|
||||
expect(activitySection).not.toBeNull();
|
||||
|
||||
const activityItems = fixture.debugElement.queryAll(By.css('.activity-item'));
|
||||
expect(activityItems.length).toBe(3);
|
||||
}));
|
||||
|
||||
it('should render trends section', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const trendsSection = fixture.debugElement.query(By.css('.trends-section'));
|
||||
expect(trendsSection).not.toBeNull();
|
||||
|
||||
const trendBarGroups = fixture.debugElement.queryAll(By.css('.trend-bar-group'));
|
||||
expect(trendBarGroups.length).toBe(7);
|
||||
}));
|
||||
|
||||
it('should show error banner when error is set', () => {
|
||||
component.error.set('Failed to load statistics');
|
||||
fixture.detectChanges();
|
||||
|
||||
const errorBanner = fixture.debugElement.query(By.css('.error-banner'));
|
||||
expect(errorBanner).not.toBeNull();
|
||||
expect(errorBanner.nativeElement.textContent).toContain('Failed to load statistics');
|
||||
});
|
||||
|
||||
it('should show empty activity message when no activity', fakeAsync(() => {
|
||||
mockVexHubApi.getStats.and.returnValue(of({ ...mockStats, recentActivity: [] }));
|
||||
component.ngOnInit();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const emptyActivity = fixture.debugElement.query(By.css('.empty-activity'));
|
||||
expect(emptyActivity).not.toBeNull();
|
||||
expect(emptyActivity.nativeElement.textContent).toContain('No recent activity');
|
||||
}));
|
||||
|
||||
it('should not render trends section when no trends data', fakeAsync(() => {
|
||||
mockVexHubApi.getStats.and.returnValue(of({ ...mockStats, trends: [] }));
|
||||
component.ngOnInit();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const trendsSection = fixture.debugElement.query(By.css('.trends-section'));
|
||||
expect(trendsSection).toBeNull();
|
||||
}));
|
||||
expect(component.sourceItems()[0]).toEqual({ source: 'vendor', count: 400 });
|
||||
expect(component.maxTrendValue()).toBe(75);
|
||||
expect(component.getStatusPercentage(150)).toBe(15);
|
||||
expect(component.getSourcePercentage(400)).toBe(40);
|
||||
expect(component.getTrendHeight(75)).toBe(80);
|
||||
expect(component.getTrendHeight(0)).toBe(4);
|
||||
});
|
||||
|
||||
describe('OnInit', () => {
|
||||
it('should load stats on init', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
it('renders recent activity and trend bars after loading', async () => {
|
||||
await renderComponent();
|
||||
|
||||
expect(mockVexHubApi.getStats).toHaveBeenCalled();
|
||||
expect(component.stats()).toEqual(mockStats);
|
||||
}));
|
||||
const activityItems = fixture.debugElement.queryAll(By.css('.activity-item'));
|
||||
const trendGroups = fixture.debugElement.queryAll(By.css('.trend-bar-group'));
|
||||
const createdIndicator = fixture.debugElement.query(By.css('.activity-indicator--created'));
|
||||
|
||||
it('should set loading state during API call', fakeAsync(() => {
|
||||
component.loadStats();
|
||||
expect(component.loading()).toBe(true);
|
||||
|
||||
tick();
|
||||
expect(component.loading()).toBe(false);
|
||||
}));
|
||||
expect(activityItems.length).toBe(3);
|
||||
expect(trendGroups.length).toBe(7);
|
||||
expect(createdIndicator).not.toBeNull();
|
||||
});
|
||||
|
||||
describe('Service Interactions', () => {
|
||||
it('should set stats when API returns successfully', fakeAsync(() => {
|
||||
component.loadStats();
|
||||
tick();
|
||||
it('renders the empty activity state and hides trends when the backend returns none', async () => {
|
||||
api.getStats.and.returnValue(of({ ...stats, recentActivity: [], trends: [] }));
|
||||
|
||||
expect(component.stats()).toEqual(mockStats);
|
||||
expect(component.error()).toBeNull();
|
||||
}));
|
||||
fixture = TestBed.createComponent(VexHubStatsComponent);
|
||||
component = fixture.componentInstance;
|
||||
await renderComponent();
|
||||
|
||||
it('should set error when API call fails', fakeAsync(() => {
|
||||
const errorMessage = 'Network error';
|
||||
mockVexHubApi.getStats.and.returnValue(throwError(() => new Error(errorMessage)));
|
||||
|
||||
component.loadStats();
|
||||
tick();
|
||||
|
||||
expect(component.error()).toBe(errorMessage);
|
||||
expect(component.loading()).toBe(false);
|
||||
}));
|
||||
|
||||
it('should handle non-Error exceptions', fakeAsync(() => {
|
||||
mockVexHubApi.getStats.and.returnValue(throwError(() => 'String error'));
|
||||
|
||||
component.loadStats();
|
||||
tick();
|
||||
|
||||
expect(component.error()).toBe('Failed to load statistics');
|
||||
}));
|
||||
|
||||
it('should retry when retry button is clicked', fakeAsync(() => {
|
||||
component.error.set('Some error');
|
||||
fixture.detectChanges();
|
||||
|
||||
const retryButton = fixture.debugElement.query(By.css('.btn--text'));
|
||||
retryButton.triggerEventHandler('click', null);
|
||||
tick();
|
||||
|
||||
expect(mockVexHubApi.getStats).toHaveBeenCalled();
|
||||
}));
|
||||
expect(fixture.debugElement.query(By.css('.empty-activity'))).not.toBeNull();
|
||||
expect(fixture.debugElement.query(By.css('.trends-section'))).toBeNull();
|
||||
expect(component.maxTrendValue()).toBe(0);
|
||||
});
|
||||
|
||||
describe('Computed Values', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
}));
|
||||
it('surfaces backend failures and retries through the same API contract', async () => {
|
||||
api.getStats.and.returnValues(
|
||||
throwError(() => new Error('Network error')),
|
||||
of(stats),
|
||||
);
|
||||
|
||||
it('should compute statusItems correctly', () => {
|
||||
const items = component.statusItems();
|
||||
expect(items.length).toBe(4);
|
||||
expect(items.find(i => i.status === 'affected')?.count).toBe(150);
|
||||
expect(items.find(i => i.status === 'not_affected')?.count).toBe(600);
|
||||
});
|
||||
fixture = TestBed.createComponent(VexHubStatsComponent);
|
||||
component = fixture.componentInstance;
|
||||
await renderComponent();
|
||||
|
||||
it('should compute sourceItems sorted by count descending', () => {
|
||||
const items = component.sourceItems();
|
||||
expect(items.length).toBe(5);
|
||||
expect(items[0].source).toBe('vendor');
|
||||
expect(items[0].count).toBe(400);
|
||||
});
|
||||
const errorBanner = fixture.debugElement.query(By.css('.error-banner'));
|
||||
expect(errorBanner).not.toBeNull();
|
||||
expect(errorBanner.nativeElement.textContent).toContain('Network error');
|
||||
expect(component.loading()).toBe(false);
|
||||
|
||||
it('should compute maxTrendValue correctly', () => {
|
||||
expect(component.maxTrendValue()).toBe(75); // max notAffected value
|
||||
});
|
||||
errorBanner.query(By.css('.btn--text')).triggerEventHandler('click', null);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
it('should return 0 for maxTrendValue when no trends', () => {
|
||||
component.stats.set({ ...mockStats, trends: [] });
|
||||
expect(component.maxTrendValue()).toBe(0);
|
||||
});
|
||||
expect(api.getStats).toHaveBeenCalledTimes(2);
|
||||
expect(component.error()).toBeNull();
|
||||
expect(component.stats()).toEqual(stats);
|
||||
});
|
||||
|
||||
describe('Percentage Calculations', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
}));
|
||||
it('formats current source labels and icons for live stats buckets', async () => {
|
||||
await renderComponent();
|
||||
|
||||
it('should calculate status percentage correctly', () => {
|
||||
const percentage = component.getStatusPercentage(150);
|
||||
expect(percentage).toBe(15); // 150/1000 = 15%
|
||||
});
|
||||
expect(component.formatSourceType('internal')).toBe('Internal');
|
||||
expect(component.formatSourceType('community')).toBe('Community');
|
||||
expect(component.formatSourceType('distributor')).toBe('Distributor');
|
||||
expect(component.formatSourceType('aggregator')).toBe('Aggregator');
|
||||
expect(component.formatSourceType('unknown')).toBe('Unknown');
|
||||
|
||||
it('should return 0 for status percentage when total is 0', () => {
|
||||
component.stats.set({ ...mockStats, totalStatements: 0 });
|
||||
expect(component.getStatusPercentage(150)).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate source percentage correctly', () => {
|
||||
const percentage = component.getSourcePercentage(400);
|
||||
expect(percentage).toBe(40); // 400/1000 = 40%
|
||||
});
|
||||
|
||||
it('should return 0 for source percentage when total is 0', () => {
|
||||
component.stats.set({ ...mockStats, totalStatements: 0 });
|
||||
expect(component.getSourcePercentage(400)).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate trend height correctly', () => {
|
||||
const height = component.getTrendHeight(75); // max value
|
||||
expect(height).toBe(80); // (75/75) * 80 = 80
|
||||
});
|
||||
|
||||
it('should return minimum height when value is 0', () => {
|
||||
const height = component.getTrendHeight(0);
|
||||
expect(height).toBe(4);
|
||||
});
|
||||
|
||||
it('should return minimum height when maxTrendValue is 0', () => {
|
||||
component.stats.set({ ...mockStats, trends: [] });
|
||||
const height = component.getTrendHeight(10);
|
||||
expect(height).toBe(4);
|
||||
});
|
||||
expect(component.getSourceIcon('internal')).toBe('I');
|
||||
expect(component.getSourceIcon('community')).toBe('C');
|
||||
expect(component.getSourceIcon('distributor')).toBe('D');
|
||||
expect(component.getSourceIcon('aggregator')).toBe('A');
|
||||
expect(component.getSourceIcon('unknown')).toBe('?');
|
||||
});
|
||||
|
||||
describe('Format Functions', () => {
|
||||
it('should format status correctly', () => {
|
||||
expect(component.formatStatus('affected')).toBe('Affected');
|
||||
expect(component.formatStatus('not_affected')).toBe('Not Affected');
|
||||
expect(component.formatStatus('fixed')).toBe('Fixed');
|
||||
expect(component.formatStatus('under_investigation')).toBe('Investigating');
|
||||
});
|
||||
it('keeps percentage helpers at zero when the total statement count is missing', async () => {
|
||||
await renderComponent();
|
||||
|
||||
it('should return original status for unknown values', () => {
|
||||
expect(component.formatStatus('unknown' as any)).toBe('unknown');
|
||||
});
|
||||
component.stats.set({ ...stats, totalStatements: 0, trends: [] });
|
||||
|
||||
it('should format source type correctly', () => {
|
||||
expect(component.formatSourceType('vendor')).toBe('Vendor');
|
||||
expect(component.formatSourceType('cert')).toBe('CERT/CSIRT');
|
||||
expect(component.formatSourceType('oss')).toBe('OSS Maintainer');
|
||||
expect(component.formatSourceType('researcher')).toBe('Researcher');
|
||||
expect(component.formatSourceType('ai_generated')).toBe('AI Generated');
|
||||
});
|
||||
|
||||
it('should return original source type for unknown values', () => {
|
||||
expect(component.formatSourceType('unknown' as any)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should format activity action correctly', () => {
|
||||
expect(component.formatActivityAction('created')).toBe('Statement created');
|
||||
expect(component.formatActivityAction('updated')).toBe('Statement updated');
|
||||
expect(component.formatActivityAction('superseded')).toBe('Statement superseded');
|
||||
});
|
||||
|
||||
it('should return original action for unknown values', () => {
|
||||
expect(component.formatActivityAction('unknown')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should get source icon correctly', () => {
|
||||
expect(component.getSourceIcon('vendor')).toBe('V');
|
||||
expect(component.getSourceIcon('cert')).toBe('C');
|
||||
expect(component.getSourceIcon('oss')).toBe('O');
|
||||
expect(component.getSourceIcon('researcher')).toBe('R');
|
||||
expect(component.getSourceIcon('ai_generated')).toBe('AI');
|
||||
});
|
||||
|
||||
it('should return ? for unknown source icon', () => {
|
||||
expect(component.getSourceIcon('unknown' as any)).toBe('?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Activity Icons', () => {
|
||||
it('should render correct icon for created action', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const createdIndicator = fixture.debugElement.query(By.css('.activity-indicator--created'));
|
||||
expect(createdIndicator).not.toBeNull();
|
||||
}));
|
||||
|
||||
it('should render correct icon for updated action', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const updatedIndicator = fixture.debugElement.query(By.css('.activity-indicator--updated'));
|
||||
expect(updatedIndicator).not.toBeNull();
|
||||
}));
|
||||
|
||||
it('should render correct icon for superseded action', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const supersededIndicator = fixture.debugElement.query(By.css('.activity-indicator--superseded'));
|
||||
expect(supersededIndicator).not.toBeNull();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Status Cards', () => {
|
||||
it('should render status cards with correct counts', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const statusCounts = fixture.debugElement.queryAll(By.css('.status-count'));
|
||||
const countTexts = statusCounts.map(el => el.nativeElement.textContent.trim());
|
||||
|
||||
expect(countTexts).toContain('150');
|
||||
expect(countTexts).toContain('600');
|
||||
expect(countTexts).toContain('200');
|
||||
expect(countTexts).toContain('50');
|
||||
}));
|
||||
|
||||
it('should render status bars with correct heights', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const statusBars = fixture.debugElement.queryAll(By.css('.status-bar'));
|
||||
expect(statusBars.length).toBe(4);
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Source Bars', () => {
|
||||
it('should render source bars with correct widths', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const sourceBars = fixture.debugElement.queryAll(By.css('.source-bar'));
|
||||
expect(sourceBars.length).toBe(5);
|
||||
}));
|
||||
|
||||
it('should display source counts', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const sourceCounts = fixture.debugElement.queryAll(By.css('.source-count'));
|
||||
expect(sourceCounts.length).toBe(5);
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Input Handling', () => {
|
||||
it('should accept refreshInterval input', () => {
|
||||
fixture.componentRef.setInput('refreshInterval', 30000);
|
||||
fixture.detectChanges();
|
||||
expect(component.refreshInterval()).toBe(30000);
|
||||
});
|
||||
expect(component.getStatusPercentage(10)).toBe(0);
|
||||
expect(component.getSourcePercentage(10)).toBe(0);
|
||||
expect(component.getTrendHeight(10)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ import { VEX_HUB_API, VexHubApi } from '../../core/api/vex-hub.client';
|
||||
import {
|
||||
VexHubStats,
|
||||
VexStatementStatus,
|
||||
VexIssuerType,
|
||||
VexActivityItem,
|
||||
VexTrendData,
|
||||
} from '../../core/api/vex-hub.models';
|
||||
@@ -759,11 +758,10 @@ export class VexHubStatsComponent implements OnInit {
|
||||
readonly sourceItems = computed(() => {
|
||||
const s = this.stats();
|
||||
if (!s) return [];
|
||||
const sources: VexIssuerType[] = ['vendor', 'cert', 'oss', 'researcher', 'ai_generated'];
|
||||
return sources
|
||||
.map((source) => ({
|
||||
return Object.entries(s.bySource)
|
||||
.map(([source, count]) => ({
|
||||
source,
|
||||
count: s.bySource[source] || 0,
|
||||
count,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
});
|
||||
@@ -812,13 +810,18 @@ export class VexHubStatsComponent implements OnInit {
|
||||
return Math.max(4, (value / max) * 80);
|
||||
}
|
||||
|
||||
getSourceIcon(source: VexIssuerType): string {
|
||||
const icons: Record<VexIssuerType, string> = {
|
||||
getSourceIcon(source: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
vendor: 'V',
|
||||
cert: 'C',
|
||||
oss: 'O',
|
||||
researcher: 'R',
|
||||
ai_generated: 'AI',
|
||||
internal: 'I',
|
||||
community: 'C',
|
||||
distributor: 'D',
|
||||
aggregator: 'A',
|
||||
unknown: '?',
|
||||
};
|
||||
return icons[source] || '?';
|
||||
}
|
||||
@@ -833,13 +836,18 @@ export class VexHubStatsComponent implements OnInit {
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
formatSourceType(type: VexIssuerType): string {
|
||||
const labels: Record<VexIssuerType, string> = {
|
||||
formatSourceType(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
vendor: 'Vendor',
|
||||
cert: 'CERT/CSIRT',
|
||||
oss: 'OSS Maintainer',
|
||||
researcher: 'Researcher',
|
||||
ai_generated: 'AI Generated',
|
||||
internal: 'Internal',
|
||||
community: 'Community',
|
||||
distributor: 'Distributor',
|
||||
aggregator: 'Aggregator',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
@@ -23,12 +23,30 @@ function createSpy<T extends (...args: any[]) => any>(
|
||||
// ---------------------------------------------------------------------------
|
||||
// jasmine.createSpyObj → object whose methods are vi.fn()
|
||||
// ---------------------------------------------------------------------------
|
||||
function createSpyObj<T extends string>(
|
||||
baseNameOrMethods: string | T[],
|
||||
methodNamesOrProperties?: T[] | Record<string, unknown>,
|
||||
function createSpyObj<T extends object>(
|
||||
baseName: string,
|
||||
methodNames: ReadonlyArray<Extract<keyof T, string>>,
|
||||
propertyNames?: Partial<Record<Extract<keyof T, string>, unknown>>,
|
||||
): jasmine.SpyObj<T>;
|
||||
function createSpyObj<T extends object>(
|
||||
methodNames: ReadonlyArray<Extract<keyof T, string>>,
|
||||
propertyNames?: Partial<Record<Extract<keyof T, string>, unknown>>,
|
||||
): jasmine.SpyObj<T>;
|
||||
function createSpyObj(
|
||||
baseName: string,
|
||||
methodNames: readonly string[],
|
||||
propertyNames?: Record<string, unknown>,
|
||||
): Record<string, Mock>;
|
||||
function createSpyObj(
|
||||
methodNames: readonly string[],
|
||||
propertyNames?: Record<string, unknown>,
|
||||
): Record<string, Mock>;
|
||||
function createSpyObj(
|
||||
baseNameOrMethods: string | readonly string[],
|
||||
methodNamesOrProperties?: readonly string[] | Record<string, unknown>,
|
||||
propertyNames?: Record<string, unknown>,
|
||||
): Record<string, Mock> {
|
||||
let methods: T[];
|
||||
let methods: readonly string[];
|
||||
let properties: Record<string, unknown> | undefined;
|
||||
|
||||
if (Array.isArray(baseNameOrMethods)) {
|
||||
@@ -38,7 +56,7 @@ function createSpyObj<T extends string>(
|
||||
? (methodNamesOrProperties as Record<string, unknown>)
|
||||
: undefined;
|
||||
} else {
|
||||
methods = (methodNamesOrProperties ?? []) as T[];
|
||||
methods = Array.isArray(methodNamesOrProperties) ? methodNamesOrProperties : [];
|
||||
properties = propertyNames;
|
||||
}
|
||||
|
||||
@@ -124,15 +142,24 @@ declare global {
|
||||
name?: string,
|
||||
originalFn?: T,
|
||||
): Mock<T>;
|
||||
function createSpyObj<T extends string>(
|
||||
function createSpyObj<T extends object>(
|
||||
baseName: string,
|
||||
methodNames: T[],
|
||||
methodNames: ReadonlyArray<Extract<keyof T, string>>,
|
||||
propertyNames?: Partial<Record<Extract<keyof T, string>, unknown>>,
|
||||
): SpyObj<T>;
|
||||
function createSpyObj<T extends object>(
|
||||
methodNames: ReadonlyArray<Extract<keyof T, string>>,
|
||||
propertyNames?: Partial<Record<Extract<keyof T, string>, unknown>>,
|
||||
): SpyObj<T>;
|
||||
function createSpyObj(
|
||||
baseName: string,
|
||||
methodNames: readonly string[],
|
||||
propertyNames?: Record<string, unknown>,
|
||||
): Record<T, Mock>;
|
||||
function createSpyObj<T extends string>(
|
||||
methodNames: T[],
|
||||
): Record<string, Mock>;
|
||||
function createSpyObj(
|
||||
methodNames: readonly string[],
|
||||
propertyNames?: Record<string, unknown>,
|
||||
): Record<T, Mock>;
|
||||
): Record<string, Mock>;
|
||||
function objectContaining(sample: Record<string, unknown>): any;
|
||||
function arrayContaining(sample: unknown[]): any;
|
||||
function stringMatching(pattern: string | RegExp): any;
|
||||
@@ -209,6 +236,14 @@ if (!Object.getOwnPropertyDescriptor(MockPrototype, 'and')) {
|
||||
self.mockRestore();
|
||||
return self;
|
||||
},
|
||||
resolveTo(val: unknown) {
|
||||
self.mockResolvedValue(val);
|
||||
return self;
|
||||
},
|
||||
rejectWith(val: unknown) {
|
||||
self.mockRejectedValue(val);
|
||||
return self;
|
||||
},
|
||||
throwError(msg: string | Error) {
|
||||
self.mockImplementation(() => {
|
||||
throw typeof msg === 'string' ? new Error(msg) : msg;
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
"src/app/features/evidence-export/stella-bundle-export-button/stella-bundle-export-button.component.spec.ts",
|
||||
"src/app/features/jobengine/jobengine-dashboard.component.spec.ts",
|
||||
"src/app/features/platform/ops/platform-jobs-queues-page.component.spec.ts",
|
||||
"src/app/features/policy-governance/conflict-resolution-wizard.component.spec.ts",
|
||||
"src/app/features/policy-governance/policy-conflict-dashboard.component.spec.ts",
|
||||
"src/app/features/policy-simulation/policy-simulation-direct-route-defaults.spec.ts",
|
||||
"src/app/features/policy-simulation/policy-simulation-defaults.spec.ts",
|
||||
"src/app/features/policy-simulation/simulation-dashboard.component.spec.ts",
|
||||
@@ -34,6 +36,8 @@
|
||||
"src/app/features/trust-admin/trust-admin.component.spec.ts",
|
||||
"src/app/features/triage/services/ttfs-telemetry.service.spec.ts",
|
||||
"src/app/features/triage/triage-workspace.component.spec.ts",
|
||||
"src/app/features/vex-hub/vex-hub-stats.component.spec.ts",
|
||||
"src/app/features/vex-hub/vex-hub-source-contract.spec.ts",
|
||||
"src/app/shared/ui/filter-bar/filter-bar.component.spec.ts",
|
||||
"src/app/features/watchlist/watchlist-page.component.spec.ts",
|
||||
"src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts"
|
||||
|
||||
Reference in New Issue
Block a user