Close scratch iteration 009 grouped policy and VEX audit repairs

This commit is contained in:
master
2026-03-13 19:25:48 +02:00
parent 6954ac7967
commit bf4ff5bfd7
41 changed files with 2413 additions and 553 deletions

View File

@@ -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; }

View File

@@ -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());
}
}

View File

@@ -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}",

View File

@@ -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>

View File

@@ -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 ────────────────────────────────────────────────────

View File

@@ -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>();

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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();
}
}
}

View File

@@ -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));
}
}

View File

@@ -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: [

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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[];
}

View File

@@ -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),
});
});
});

View File

@@ -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: () => {

View File

@@ -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,
};

View File

@@ -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),

View File

@@ -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' });
});
});

View File

@@ -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();

View File

@@ -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 };
};
}

View File

@@ -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) => {

View File

@@ -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),
});

View File

@@ -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 }),

View File

@@ -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),
});

View File

@@ -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),
});

View File

@@ -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),
});

View File

@@ -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,

View File

@@ -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),

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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"