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

@@ -0,0 +1,84 @@
# Sprint 20260313_004 - Platform Scratch Iteration 009 Full Route Action Audit
## Topic & Scope
- Wipe Stella-owned runtime state again and rerun the documented setup path from zero state.
- Re-enter the application as a first-time user after bootstrap and rerun the full route, page-load, and page-action audit with Playwright.
- Recheck changed or newly discovered surfaces and convert any new manual findings into retained Playwright scenarios before the iteration is considered complete.
- Group any newly exposed defects before fixing so the next commit closes a full iteration rather than a single page slice.
- Working directory: `.`.
- Expected evidence: wipe proof, setup convergence proof, fresh Playwright route/page/action evidence, retained scenario coverage for new findings, grouped defect list, fixes, and retest results.
## Dependencies & Concurrency
- Depends on local commit `6954ac796` as the clean baseline for the next scratch cycle.
- Safe parallelism: none during wipe/setup because the environment reset is global to the machine.
## Documentation Prerequisites
- `AGENTS.md`
- `docs/INSTALL_GUIDE.md`
- `docs/dev/DEV_ENVIRONMENT_SETUP.md`
- `docs/qa/feature-checks/FLOW.md`
## Delivery Tracker
### PLATFORM-SCRATCH-ITER9-001 - Rebuild from zero Stella runtime state
Status: DONE
Dependency: none
Owners: QA, 3rd line support
Task description:
- Remove Stella-only containers, images, volumes, and the frontdoor network, then rerun the documented setup entrypoint from zero Stella state.
Completion criteria:
- [x] Stella-only Docker state is removed.
- [x] scripts/setup.ps1 is rerun from zero state.
- [x] The first setup outcome is captured before UI verification starts.
### PLATFORM-SCRATCH-ITER9-002 - Re-run the first-user full route/page/action audit
Status: DONE
Dependency: PLATFORM-SCRATCH-ITER9-001
Owners: QA
Task description:
- After scratch setup converges, rerun the canonical route sweep plus the full route/page/action audit suite, including changed-surface and route-ownership checks, and enumerate every newly exposed issue before repair work begins.
Completion criteria:
- [x] Fresh route sweep evidence is captured on the rebuilt stack.
- [x] Fresh route/page/action evidence is captured across the full aggregate suite, including changed-surface and ownership checks.
- [x] Newly exposed defects are grouped and any new manual findings are queued into retained Playwright scenarios before any fix commit is prepared.
### PLATFORM-SCRATCH-ITER9-003 - Repair the grouped defects exposed by the fresh audit
Status: DONE
Dependency: PLATFORM-SCRATCH-ITER9-002
Owners: 3rd line support, Architect, Developer
Task description:
- Diagnose the grouped failures exposed by the fresh audit, choose the clean product/architecture-conformant fix, implement it, add retained Playwright coverage for the new behavior when needed, and rerun the affected verification slices plus the aggregate audit before committing.
Completion criteria:
- [x] Root causes are recorded for the grouped failures.
- [x] Fixes land with focused regression coverage and retained Playwright scenario updates where practical.
- [x] The rebuilt stack is retested before the iteration commit.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-13 | Sprint created for the next scratch iteration after local commit `6954ac796` closed the previous clean baseline. | QA |
| 2026-03-13 | Removed Stella-only containers, `stellaops/*:dev` images, Stella compose volumes, and the `stellaops` / `stellaops_frontdoor` networks to return the machine to zero Stella runtime state for the new iteration. | QA / 3rd line support |
| 2026-03-13 | The zero-state setup rerun completed cleanly: `36/36` solution builds passed, the full image matrix rebuilt, platform services converged, and `60/61` Stella containers are healthy on `https://stella-ops.local`. | QA / 3rd line support |
| 2026-03-13 | The standalone canonical route sweep finished with `111/111` passed routes and `0` failed routes on the rebuilt stack. | QA |
| 2026-03-13 | The first-user aggregate Playwright audit finished cleanly at `22/22` passed suites. The retained surface now includes tightened user-reported admin/trust/report checks, deeper `/ops/policy/*` tab coverage, and corrected uncovered-surface navigation waiting for slower SPA hand-offs like `/releases/environments -> Open Agents`. | QA |
| 2026-03-13 | The aggregate audit recorded one first-pass runtime-only setup-topology failure, auto-retried it, and stabilized cleanly. The behavior did not reproduce after retry, so the issue was recorded as cold-start audit noise rather than a product regression. | QA / 3rd line support |
| 2026-03-13 | Grouped defects from the fresh audit were traced to two root-cause families: policy governance compatibility gaps and placeholder tenant scope on the web shell, plus missing VexHub repository registrations/startup migrations/runtime model compatibility for fresh databases. | 3rd line support |
| 2026-03-13 | Implemented the grouped repair set, then revalidated it with focused retained tests and targeted executable slices: Angular feature specs `14/14`, `GovernanceCompatibilityEndpointsTests` `6/6`, and VexHub registration/model tests `2/2`. | Developer / Test Automation |
## Decisions & Risks
- Decision: each scratch iteration remains a full wipe -> setup -> route/page/action audit -> grouped remediation loop; if the audit comes back clean, that still counts as a completed iteration because the full loop was executed.
- Decision: changed or newly discovered user flows must be converted into retained Playwright coverage before the next scratch iteration starts so the audit surface expands instead of rediscovering the same gaps manually.
- Risk: scratch rebuilds remain expensive, so verification stays Playwright-first with focused test/build slices rather than indiscriminate full-solution test runs.
- Decision: policy governance compatibility stays tenant/project scoped end to end. The repair uses shared live scope resolution in the web shell and deterministic compatibility endpoints in the gateway instead of hardcoded tenants or page-local mock state.
- Decision: fresh-install VexHub convergence stays startup-migration driven. Missing source/conflict/ingestion-job repositories and the `SearchVector` EF model incompatibility were fixed in the persistence layer rather than worked around in the UI.
- Decision: newly discovered manual routes and user-reported surfaces were converted into retained Playwright coverage before the iteration closed, including security reports tab embedding, trust/admin surfaces, deeper policy navigation, and delayed uncovered-surface link hand-offs.
- Risk: the full aggregate audit still sees one cold-start-only topology runtime failure that stabilizes after automatic retry. The iteration keeps that retry evidence recorded so repeated occurrence can be treated as a real product defect rather than silently ignored.
- Risk: `dotnet test --filter` remains unreliable on these Microsoft.Testing.Platform projects. Targeted backend evidence for this iteration therefore uses the direct xUnit executables (`6/6` policy, `2/2` VexHub) instead of solution-level filtered runs.
## Next Checkpoints
- Start iteration 010 from another Stella-only wipe and rerun the documented setup path from zero state.
- Run the full Playwright route/page/action audit, including the expanded policy/admin/trust/reports/search retained coverage, before any new fix work begins.

View File

@@ -850,6 +850,29 @@ stella exception status <request-id>
- [Trust Lattice Policy Gates](#63--trust-lattice-policy-gates) - [Trust Lattice Policy Gates](#63--trust-lattice-policy-gates)
- [Budget Attestation](./budget-attestation.md) - [Budget Attestation](./budget-attestation.md)
### Governance Compatibility Endpoints
The console governance workspaces also depend on a tenant-scoped compatibility surface under `/api/v1/governance/*` that lives in the Policy gateway.
- `GET /api/v1/governance/trust-weights`
- `PUT /api/v1/governance/trust-weights/{weightId}`
- `POST /api/v1/governance/trust-weights/preview-impact`
- `GET /api/v1/governance/staleness/config`
- `PUT /api/v1/governance/staleness/config/{dataType}`
- `GET /api/v1/governance/staleness/status`
- `GET /api/v1/governance/conflicts/dashboard`
- `GET /api/v1/governance/conflicts`
- `POST /api/v1/governance/conflicts/{conflictId}/resolve`
- `POST /api/v1/governance/conflicts/{conflictId}/ignore`
Contract requirements:
- All requests are tenant-scoped and may include an optional `projectId`.
- Console clients must resolve live tenant scope from the active session/context and must not rely on legacy placeholder aliases.
- Conflict dashboard/list responses remain deterministic so scratch rebuilds and replayed Playwright sweeps see stable cards, trend buckets, and action affordances.
Implementation reference:
- `src/Policy/StellaOps.Policy.Gateway/Endpoints/GovernanceCompatibilityEndpoints.cs`
--- ---
## 7·Security & Tenancy ## 7·Security & Tenancy

View File

@@ -19,7 +19,7 @@ Non-goals: policy decisioning (Policy Engine), consensus computation (VexLens),
- **VexHub.Worker**: Background workers for ingestion schedules and validation pipelines. - **VexHub.Worker**: Background workers for ingestion schedules and validation pipelines.
- **Normalization Pipeline**: Canonicalizes statements, deduplicates, and links provenance. - **Normalization Pipeline**: Canonicalizes statements, deduplicates, and links provenance.
- **Validation Pipeline**: Schema validation (OpenVEX/CycloneDX/CSAF) and signature checks. - **Validation Pipeline**: Schema validation (OpenVEX/CycloneDX/CSAF) and signature checks.
- **Storage**: PostgreSQL schema `vexhub` for normalized statements, provenance, conflicts, and export cursors. - **Storage**: PostgreSQL schema `vexhub` for sources, normalized statements, provenance, conflicts, ingestion jobs, and export cursors.
## 4) Data Model (Draft) ## 4) Data Model (Draft)
- `vexhub.statement` - `vexhub.statement`
@@ -37,11 +37,25 @@ All tables must include `tenant_id`, UTC timestamps, and deterministic ordering
- `GET /api/v1/vex/cve/{cve-id}` - `GET /api/v1/vex/cve/{cve-id}`
- `GET /api/v1/vex/package/{purl}` - `GET /api/v1/vex/package/{purl}`
- `GET /api/v1/vex/source/{source-id}` - `GET /api/v1/vex/source/{source-id}`
- `GET /api/v1/vex/stats`
- `GET /api/v1/vex/export` (bulk OpenVEX feed) - `GET /api/v1/vex/export` (bulk OpenVEX feed)
- `GET /api/v1/vex/index` (vex-index.json) - `GET /api/v1/vex/index` (vex-index.json)
Responses are deterministic: stable ordering by `timestamp DESC`, then `source_id ASC`, then `statement_hash ASC`. Responses are deterministic: stable ordering by `timestamp DESC`, then `source_id ASC`, then `statement_hash ASC`.
`GET /api/v1/vex/stats` returns the dashboard contract consumed by the console VEX surfaces:
- `totalStatements`
- `verifiedStatements`
- `flaggedStatements`
- `byStatus`
- `bySource`
- `recentActivity`
- `trends`
- `generatedAt`
The stats endpoint must keep working on fresh installs even when a committed EF compiled-model stub is empty; runtime model fallback is required until a real optimized model is generated.
The service must also auto-apply embedded SQL migrations for schema `vexhub` on startup so wiped volumes converge without manual SQL bootstrap.
## 6) Determinism & Offline Posture ## 6) Determinism & Offline Posture
- Ingestion runs against frozen snapshots where possible; all outputs include `snapshot_hash`. - Ingestion runs against frozen snapshots where possible; all outputs include `snapshot_hash`.
- Canonical JSON serialization with stable key ordering. - Canonical JSON serialization with stable key ordering.
@@ -67,6 +81,7 @@ Responses are deterministic: stable ordering by `timestamp DESC`, then `source_i
## 10) Testing Strategy ## 10) Testing Strategy
- Unit tests for normalization and validation pipelines. - Unit tests for normalization and validation pipelines.
- Integration tests with Postgres for ingestion and API outputs. - Integration tests with Postgres for ingestion and API outputs.
- Persistence registration and runtime-model tests that prove source/conflict/ingestion-job repositories and startup migrations are wired on the service path.
- Determinism tests comparing repeated exports with identical inputs. - Determinism tests comparing repeated exports with identical inputs.
*Last updated: 2025-12-22.* *Last updated: 2026-03-13.*

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, TrustWeightConfigState> TrustWeightStates = new(StringComparer.OrdinalIgnoreCase);
private static readonly ConcurrentDictionary<string, StalenessConfigState> StalenessStates = 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) 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)); var state = StalenessStates.GetOrAdd(scope.Key, _ => CreateDefaultStalenessState(scope.TenantId, scope.ProjectId, timeProvider));
return Results.Ok(state.BuildStatus()); return Results.Ok(state.BuildStatus());
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); }).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) 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) => private static List<StalenessThresholdRecord> BuildThresholds(int fresh, int aging, int stale, int expired) =>
[ [
new("fresh", fresh, "low", [new StalenessActionRecord("warn", "Still within freshness SLA.")]), new("fresh", fresh, "low", [new StalenessActionRecord("warn", "Still within freshness SLA.")]),
@@ -308,6 +403,130 @@ public static class GovernanceCompatibilityEndpoints
}).ToArray(); }).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) => private static string NormalizeSource(string? source) =>
string.IsNullOrWhiteSpace(source) ? "custom" : source.Trim().ToLowerInvariant(); string.IsNullOrWhiteSpace(source) ? "custom" : source.Trim().ToLowerInvariant();
@@ -350,6 +569,34 @@ public static class GovernanceCompatibilityEndpoints
string[]? Channels = null); 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 sealed record TrustWeightWriteModel
{ {
public string? Id { get; init; } 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.ValueKind == JsonValueKind.Array);
Assert.True(status.GetArrayLength() > 0); 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.Core.Models;
using StellaOps.VexHub.WebService.Models; using StellaOps.VexHub.WebService.Models;
using StellaOps.VexHub.WebService.Security; using StellaOps.VexHub.WebService.Security;
using StellaOps.VexLens.Models;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
@@ -86,7 +87,7 @@ public static class VexHubEndpointExtensions
string cveId, string cveId,
[FromQuery] int? limit, [FromQuery] int? limit,
[FromQuery] int? offset, [FromQuery] int? offset,
IVexStatementRepository repository, [FromServices] IVexStatementRepository repository,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var statements = await repository.GetByCveAsync(cveId, limit ?? 100, offset ?? 0, cancellationToken); var statements = await repository.GetByCveAsync(cveId, limit ?? 100, offset ?? 0, cancellationToken);
@@ -107,7 +108,7 @@ public static class VexHubEndpointExtensions
string purl, string purl,
[FromQuery] int? limit, [FromQuery] int? limit,
[FromQuery] int? offset, [FromQuery] int? offset,
IVexStatementRepository repository, [FromServices] IVexStatementRepository repository,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// URL decode the PURL // URL decode the PURL
@@ -130,7 +131,7 @@ public static class VexHubEndpointExtensions
string sourceId, string sourceId,
[FromQuery] int? limit, [FromQuery] int? limit,
[FromQuery] int? offset, [FromQuery] int? offset,
IVexStatementRepository repository, [FromServices] IVexStatementRepository repository,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var statements = await repository.GetBySourceAsync(sourceId, limit ?? 100, offset ?? 0, 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( private static async Task<IResult> GetById(
Guid id, Guid id,
IVexStatementRepository repository, [FromServices] IVexStatementRepository repository,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var statement = await repository.GetByIdAsync(id, cancellationToken); var statement = await repository.GetByIdAsync(id, cancellationToken);
@@ -168,7 +169,7 @@ public static class VexHubEndpointExtensions
[FromQuery] bool? isFlagged, [FromQuery] bool? isFlagged,
[FromQuery] int? limit, [FromQuery] int? limit,
[FromQuery] int? offset, [FromQuery] int? offset,
IVexStatementRepository repository, [FromServices] IVexStatementRepository repository,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var filter = new VexStatementFilter var filter = new VexStatementFilter
@@ -192,7 +193,9 @@ public static class VexHubEndpointExtensions
} }
private static async Task<IResult> GetStats( private static async Task<IResult> GetStats(
IVexStatementRepository repository, [FromServices] IVexSourceRepository sourceRepository,
[FromServices] IVexStatementRepository repository,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var totalCount = await repository.GetCountAsync(cancellationToken: cancellationToken); var totalCount = await repository.GetCountAsync(cancellationToken: cancellationToken);
@@ -202,22 +205,123 @@ public static class VexHubEndpointExtensions
var flaggedCount = await repository.GetCountAsync( var flaggedCount = await repository.GetCountAsync(
new VexStatementFilter { IsFlagged = true }, new VexStatementFilter { IsFlagged = true },
cancellationToken); 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 return Results.Ok(new VexHubStats
{ {
TotalStatements = totalCount, TotalStatements = totalCount,
VerifiedStatements = verifiedCount, VerifiedStatements = verifiedCount,
FlaggedStatements = flaggedCount, 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 return Results.Ok(new VexIndexManifest
{ {
Version = "1.0", Version = "1.0",
LastUpdated = DateTimeOffset.UtcNow, LastUpdated = timeProvider.GetUtcNow(),
Endpoints = new VexIndexEndpoints Endpoints = new VexIndexEndpoints
{ {
ByCve = "/api/v1/vex/cve/{cve}", 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 TotalStatements { get; init; }
public required long VerifiedStatements { get; init; } public required long VerifiedStatements { get; init; }
public required long FlaggedStatements { 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 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> /// <summary>
/// VEX Hub index manifest for tool integration. /// VEX Hub index manifest for tool integration.
/// </summary> /// </summary>

View File

@@ -121,10 +121,9 @@ public partial class VexHubDbContext : DbContext
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at"); entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
entity.Property(e => e.ContentDigest).HasColumnName("content_digest"); entity.Property(e => e.ContentDigest).HasColumnName("content_digest");
// search_vector is maintained by a DB trigger; EF should not write to it // search_vector is maintained by a DB trigger and is not used by the EF repositories.
entity.Property(e => e.SearchVector) // Keeping it as a mapped string property breaks runtime model validation for Npgsql.
.HasColumnType("tsvector") entity.Ignore(e => e.SearchVector);
.HasColumnName("search_vector");
}); });
// ── conflicts ──────────────────────────────────────────────────── // ── conflicts ────────────────────────────────────────────────────

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.Infrastructure.Postgres.Options; using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.VexHub.Core; using StellaOps.VexHub.Core;
using StellaOps.VexHub.Persistence.Postgres; using StellaOps.VexHub.Persistence.Postgres;
@@ -22,6 +23,13 @@ public static class VexHubPersistenceExtensions
services.Configure<PostgresOptions>(configuration.GetSection("Postgres")); services.Configure<PostgresOptions>(configuration.GetSection("Postgres"));
services.AddSingleton<VexHubDataSource>(); 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<IVexStatementRepository, PostgresVexStatementRepository>();
services.AddScoped<IVexProvenanceRepository, PostgresVexProvenanceRepository>(); services.AddScoped<IVexProvenanceRepository, PostgresVexProvenanceRepository>();
@@ -38,6 +46,13 @@ public static class VexHubPersistenceExtensions
services.Configure(configureOptions); services.Configure(configureOptions);
services.AddSingleton<VexHubDataSource>(); 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<IVexStatementRepository, PostgresVexStatementRepository>();
services.AddScoped<IVexProvenanceRepository, PostgresVexProvenanceRepository>(); 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;
using System.Linq;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Npgsql; using Npgsql;
using StellaOps.VexHub.Persistence.EfCore.CompiledModels; using StellaOps.VexHub.Persistence.EfCore.CompiledModels;
@@ -22,7 +23,8 @@ internal static class VexHubDbContextFactory
var optionsBuilder = new DbContextOptionsBuilder<VexHubDbContext>() var optionsBuilder = new DbContextOptionsBuilder<VexHubDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds)); .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. // Use the static compiled model when schema mapping matches the default model.
optionsBuilder.UseModel(VexHubDbContextModel.Instance); 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 System.Collections.Concurrent;
using FluentAssertions; using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using StellaOps.VexHub.Core; using StellaOps.VexHub.Core;
using StellaOps.VexHub.Core.Models; using StellaOps.VexHub.Core.Models;
using StellaOps.VexLens.Models;
using Xunit; using Xunit;
namespace StellaOps.VexHub.WebService.Tests.Integration; namespace StellaOps.VexHub.WebService.Tests.Integration;
@@ -16,12 +18,25 @@ namespace StellaOps.VexHub.WebService.Tests.Integration;
/// </summary> /// </summary>
public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFactory<StellaOps.VexHub.WebService.Program>> 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; private readonly HttpClient _client;
public VexExportCompatibilityTests(WebApplicationFactory<StellaOps.VexHub.WebService.Program> factory) public VexExportCompatibilityTests(WebApplicationFactory<StellaOps.VexHub.WebService.Program> factory)
{ {
_client = factory.WithWebHostBuilder(builder => _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 => builder.ConfigureServices(services =>
{ {
services.AddSingleton<IVexSourceRepository, InMemoryVexSourceRepository>(); services.AddSingleton<IVexSourceRepository, InMemoryVexSourceRepository>();
@@ -30,6 +45,8 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
services.AddSingleton<IVexStatementRepository, InMemoryVexStatementRepository>(); services.AddSingleton<IVexStatementRepository, InMemoryVexStatementRepository>();
}); });
}).CreateClient(); }).CreateClient();
_client.DefaultRequestHeaders.Add("X-Api-Key", TestApiKey);
_client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
} }
[Fact] [Fact]
@@ -118,6 +135,21 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound); 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] [Fact]
public async Task SourceEndpoint_ReturnsValidResponse() public async Task SourceEndpoint_ReturnsValidResponse()
{ {
@@ -207,6 +239,45 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
{ {
private readonly ConcurrentDictionary<string, VexSource> _sources = new(StringComparer.OrdinalIgnoreCase); 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) public Task<VexSource> AddAsync(VexSource source, CancellationToken cancellationToken = default)
{ {
_sources[source.SourceId] = source; _sources[source.SourceId] = source;
@@ -248,7 +319,7 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
{ {
LastPolledAt = timestamp, LastPolledAt = timestamp,
LastErrorMessage = errorMessage, LastErrorMessage = errorMessage,
UpdatedAt = DateTimeOffset.UtcNow UpdatedAt = timestamp
}; };
} }
@@ -331,7 +402,7 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
ResolutionStatus = status, ResolutionStatus = status,
ResolutionMethod = resolutionMethod, ResolutionMethod = resolutionMethod,
WinningStatementId = winningStatementId, WinningStatementId = winningStatementId,
ResolvedAt = DateTimeOffset.UtcNow ResolvedAt = FixedNow
}; };
} }
@@ -446,7 +517,7 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
StatementsIngested = statementsIngested, StatementsIngested = statementsIngested,
StatementsDeduplicated = statementsDeduplicated, StatementsDeduplicated = statementsDeduplicated,
ConflictsDetected = conflictsDetected, ConflictsDetected = conflictsDetected,
CompletedAt = DateTimeOffset.UtcNow CompletedAt = FixedNow
}; };
} }
@@ -461,7 +532,7 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
{ {
Status = IngestionJobStatus.Failed, Status = IngestionJobStatus.Failed,
ErrorMessage = errorMessage, ErrorMessage = errorMessage,
CompletedAt = DateTimeOffset.UtcNow CompletedAt = FixedNow
}; };
} }
@@ -473,6 +544,57 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
{ {
private readonly ConcurrentDictionary<Guid, AggregatedVexStatement> _statements = new(); 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) public Task<AggregatedVexStatement> UpsertAsync(AggregatedVexStatement statement, CancellationToken cancellationToken = default)
{ {
_statements[statement.Id] = statement; _statements[statement.Id] = statement;
@@ -502,7 +624,8 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
int? offset = null, int? offset = null,
CancellationToken cancellationToken = default) 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( public Task<IReadOnlyList<AggregatedVexStatement>> GetByPackageAsync(
@@ -511,7 +634,8 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
int? offset = null, int? offset = null,
CancellationToken cancellationToken = default) 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( public Task<IReadOnlyList<AggregatedVexStatement>> GetBySourceAsync(
@@ -520,14 +644,15 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
int? offset = null, int? offset = null,
CancellationToken cancellationToken = default) 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) 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) 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( public Task<IReadOnlyList<AggregatedVexStatement>> SearchAsync(
VexStatementFilter filter, VexStatementFilter filter,
@@ -535,13 +660,85 @@ public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFa
int? offset = null, int? offset = null,
CancellationToken cancellationToken = default) 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) 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) 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); 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 = [ const candidates = [
{ role: 'link', locator: page.getByRole('link', { name }) }, { role: 'link', locator: page.getByRole('link', { name }) },
{ role: 'tab', locator: page.getByRole('tab', { 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) { async function waitForNavigationTarget(page, name, index = 0, timeoutMs = ELEMENT_WAIT_MS) {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) { 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) { if (target) {
return 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) { async function clickButton(page, route, name, index = 0) {
await navigate(page, route); await navigate(page, route);
const locator = await waitForButton(page, name, index); const locator = await waitForButton(page, name, index);
@@ -812,6 +942,68 @@ async function main() {
}); });
try { 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({ results.push({
route: '/ops/operations/quotas', route: '/ops/operations/quotas',
actions: [ actions: [

View File

@@ -191,6 +191,8 @@ async function findLinkForRoute(page, route, name, timeoutMs = 10_000) {
if (route === '/releases/environments' && name === 'Open Environment') { if (route === '/releases/environments' && name === 'Open Environment') {
locator = page.locator('.actions').getByRole('link', { name }).first(); 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)) { } 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); locator = await findScopedLink(page, '[data-testid="jobengine-quotas-card"]', name);
} else if (route === '/ops/operations/offline-kit' && name === 'Bundles') { } else if (route === '/ops/operations/offline-kit' && name === 'Bundles') {
@@ -209,7 +211,44 @@ async function findLinkForRoute(page, route, name, timeoutMs = 10_000) {
return null; 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; const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) { while (Date.now() < deadline) {
for (const role of ['button', 'tab']) { for (const role of ['button', 'tab']) {
@@ -269,6 +308,9 @@ async function runLinkCheck(page, route, name, expectedPath) {
} }
await link.click({ timeout: 10_000 }); await link.click({ timeout: 10_000 });
if (expectedPath) {
await waitForPath(page, expectedPath, 10_000);
}
await settle(page); await settle(page);
const snapshot = await captureSnapshot(page, action); const snapshot = await captureSnapshot(page, action);
const finalUrl = normalizeUrl(snapshot.url); const finalUrl = normalizeUrl(snapshot.url);
@@ -294,7 +336,7 @@ async function runButtonCheck(page, route, name, expectedPath = null) {
const action = `${route} -> button:${name}`; const action = `${route} -> button:${name}`;
try { try {
await navigate(page, route); 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) { if (!button) {
return { action, ok: false, reason: 'missing-button', snapshot: await captureSnapshot(page, action) }; 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 }); await button.click({ timeout: 10_000 });
if (expectedPath) {
await waitForPath(page, expectedPath, 10_000);
}
await settle(page); await settle(page);
const snapshot = await captureSnapshot(page, action); const snapshot = await captureSnapshot(page, action);
const finalUrl = normalizeUrl(snapshot.url); 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) { async function runSearchQueryCheck(page, query) {
const searchInput = page.locator('input[aria-label="Global search"]').first(); const searchInput = page.locator('input[aria-label="Global search"]').first();
const responses = []; const responses = [];
@@ -487,6 +491,33 @@ async function main() {
if (!tabState.url.includes('/security/reports')) { if (!tabState.url.includes('/security/reports')) {
failures.push(`Security Reports tab "${tabState.tab}" still navigates away instead of embedding its workspace.`); 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) { if (byAction.get('security-triage:raw-svg-visible')?.triageRawSvgTextVisible) {

View File

@@ -90,7 +90,7 @@ export interface VexStatementSearchResponse {
export interface VexHubStats { export interface VexHubStats {
totalStatements: number; totalStatements: number;
byStatus: Record<VexStatementStatus, number>; byStatus: Record<VexStatementStatus, number>;
bySource: Record<VexIssuerType, number>; bySource: Record<string, number>;
recentActivity: VexActivityItem[]; recentActivity: VexActivityItem[];
trends?: VexTrendData[]; trends?: VexTrendData[];
} }

View File

@@ -1,65 +1,105 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; 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 { 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'; import { ConflictResolutionWizardComponent } from './conflict-resolution-wizard.component';
describe('ConflictResolutionWizardComponent', () => { describe('ConflictResolutionWizardComponent', () => {
let component: ConflictResolutionWizardComponent; let component: ConflictResolutionWizardComponent;
let fixture: ComponentFixture<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 = { const conflict: PolicyConflict = {
paramMap: of({ id: 'conflict-001',
get: (key: string) => key === 'conflictId' ? 'conflict-123' : null, 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 () => { 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({ await TestBed.configureTestingModule({
imports: [ConflictResolutionWizardComponent], imports: [ConflictResolutionWizardComponent],
providers: [ providers: [
provideRouter([]), 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(); }).compileComponents();
router = TestBed.inject(Router);
spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
fixture = TestBed.createComponent(ConflictResolutionWizardComponent); fixture = TestBed.createComponent(ConflictResolutionWizardComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
});
it('loads the requested conflict with the resolved tenant scope', () => {
fixture.detectChanges(); fixture.detectChanges();
});
it('should create', () => { expect(api.getConflicts).toHaveBeenCalledWith({ tenantId: 'session-tenant' });
expect(component).toBeTruthy(); expect((component as any).conflict()).toEqual(conflict);
});
it('should render wizard header', () => {
const compiled = fixture.nativeElement as HTMLElement; 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', () => { it('applies the resolution through the resolved scope and returns to the conflicts page', async () => {
const compiled = fixture.nativeElement as HTMLElement; fixture.detectChanges();
const steps = compiled.querySelectorAll('.wizard__step'); (component as any).selectedWinner.set('A');
expect(steps.length).toBe(4); (component as any).selectedStrategy.set('keep_higher_priority');
}); (component as any).resolutionNotes = 'Consolidated precedence ordering';
it('should show step labels for all steps', () => { (component as any).applyResolution();
const compiled = fixture.nativeElement as HTMLElement; await fixture.whenStable();
const content = compiled.textContent;
expect(content).toContain('Review');
expect(content).toContain('Compare');
expect(content).toContain('Strategy');
expect(content).toContain('Confirm');
});
it('should display navigation buttons', () => { expect(api.resolveConflict).toHaveBeenCalledWith(
const compiled = fixture.nativeElement as HTMLElement; 'conflict-001',
expect(compiled.querySelector('.wizard__actions')).toBeTruthy(); 'Consolidated precedence ordering',
}); { tenantId: 'session-tenant' },
);
it('should have back to conflicts link', () => { expect(router.navigate).toHaveBeenCalledWith(['../conflicts'], {
const compiled = fixture.nativeElement as HTMLElement; relativeTo: TestBed.inject(ActivatedRoute),
const backLink = compiled.querySelector('a[routerLink="../.."]'); });
expect(backLink).toBeTruthy();
}); });
}); });

View File

@@ -11,6 +11,7 @@ import {
PolicyConflict, PolicyConflict,
PolicyConflictSource, PolicyConflictSource,
} from '../../core/api/policy-governance.models'; } from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/** /**
* Conflict Resolution Wizard component. * Conflict Resolution Wizard component.
@@ -957,6 +958,7 @@ export class ConflictResolutionWizardComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API); private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
protected readonly loading = signal(false); protected readonly loading = signal(false);
protected readonly applying = signal(false); protected readonly applying = signal(false);
@@ -1006,7 +1008,7 @@ export class ConflictResolutionWizardComponent implements OnInit {
private loadConflict(conflictId: string): void { private loadConflict(conflictId: string): void {
this.loading.set(true); this.loading.set(true);
this.api this.api
.getConflicts({ tenantId: 'acme-tenant' }) .getConflicts(this.governanceScope())
.pipe(finalize(() => this.loading.set(false))) .pipe(finalize(() => this.loading.set(false)))
.subscribe({ .subscribe({
next: (conflicts) => { next: (conflicts) => {
@@ -1104,7 +1106,7 @@ export class ConflictResolutionWizardComponent implements OnInit {
this.applying.set(true); this.applying.set(true);
this.api this.api
.resolveConflict(c.id, this.resolutionNotes, { tenantId: 'acme-tenant' }) .resolveConflict(c.id, this.resolutionNotes, this.governanceScope())
.pipe(finalize(() => this.applying.set(false))) .pipe(finalize(() => this.applying.set(false)))
.subscribe({ .subscribe({
next: () => { next: () => {

View File

@@ -13,6 +13,7 @@ import {
AuditEventType, AuditEventType,
GovernanceAuditDiff, GovernanceAuditDiff,
} from '../../core/api/policy-governance.models'; } from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/** /**
* Governance Audit component. * Governance Audit component.
@@ -571,6 +572,7 @@ import {
}) })
export class GovernanceAuditComponent implements OnInit { export class GovernanceAuditComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API); private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
protected readonly loading = signal(false); protected readonly loading = signal(false);
protected readonly events = signal<GovernanceAuditEvent[]>([]); protected readonly events = signal<GovernanceAuditEvent[]>([]);
@@ -606,7 +608,7 @@ export class GovernanceAuditComponent implements OnInit {
this.loading.set(true); this.loading.set(true);
const options: AuditQueryOptions = { const options: AuditQueryOptions = {
tenantId: 'acme-tenant', ...this.governanceScope(),
page, page,
pageSize: 20, pageSize: 20,
sortOrder: 'desc', sortOrder: 'desc',
@@ -735,7 +737,7 @@ export class GovernanceAuditComponent implements OnInit {
targetResourceType: this.toString(record?.['targetResourceType']) || 'unknown', targetResourceType: this.toString(record?.['targetResourceType']) || 'unknown',
summary, summary,
traceId: this.toString(record?.['traceId']) || undefined, 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, projectId: this.toString(record?.['projectId']) || undefined,
}; };

View File

@@ -11,6 +11,7 @@ import {
TrustWeightAffectedFinding, TrustWeightAffectedFinding,
Severity, Severity,
} from '../../core/api/policy-governance.models'; } from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/** /**
* Impact Preview component. * Impact Preview component.
@@ -525,6 +526,7 @@ import {
}) })
export class ImpactPreviewComponent implements OnInit { export class ImpactPreviewComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API); private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
protected readonly loading = signal(true); protected readonly loading = signal(true);
@@ -541,7 +543,7 @@ export class ImpactPreviewComponent implements OnInit {
this.loading.set(true); this.loading.set(true);
// In real implementation, get changes from query params or service // In real implementation, get changes from query params or service
this.api this.api
.previewTrustWeightImpact([], { tenantId: 'acme-tenant' }) .previewTrustWeightImpact([], this.governanceScope())
.pipe(finalize(() => this.loading.set(false))) .pipe(finalize(() => this.loading.set(false)))
.subscribe({ .subscribe({
next: (impact) => this.impact.set(impact), next: (impact) => this.impact.set(impact),

View File

@@ -1,55 +1,140 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router'; 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'; import { PolicyConflictDashboardComponent } from './policy-conflict-dashboard.component';
describe('PolicyConflictDashboardComponent', () => { describe('PolicyConflictDashboardComponent', () => {
let component: PolicyConflictDashboardComponent; let component: PolicyConflictDashboardComponent;
let fixture: ComponentFixture<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 () => { 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({ await TestBed.configureTestingModule({
imports: [PolicyConflictDashboardComponent, FormsModule], imports: [PolicyConflictDashboardComponent],
providers: [provideRouter([])], providers: [
provideRouter([]),
{ provide: POLICY_GOVERNANCE_API, useValue: api },
{ provide: TenantActivationService, useValue: tenantActivation },
{ provide: AuthSessionStore, useValue: authSession },
],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(PolicyConflictDashboardComponent); fixture = TestBed.createComponent(PolicyConflictDashboardComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
});
it('loads the dashboard and conflicts with the active governance scope', () => {
fixture.detectChanges(); fixture.detectChanges();
});
it('should create', () => { expect(api.getConflictDashboard).toHaveBeenCalledWith({ tenantId: 'demo-prod', projectId: 'stage' });
expect(component).toBeTruthy(); expect(api.getConflicts).toHaveBeenCalledWith({ tenantId: 'demo-prod', projectId: 'stage' });
});
it('should render conflicts header', () => {
const compiled = fixture.nativeElement as HTMLElement; 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', () => { it('uses the same resolved scope for quick resolve actions and refreshes the page state', () => {
const compiled = fixture.nativeElement as HTMLElement; fixture.detectChanges();
expect(compiled.querySelector('.conflicts__stats')).toBeTruthy(); api.getConflictDashboard.calls.reset();
}); api.getConflicts.calls.reset();
spyOn(window, 'prompt').and.returnValue('Consolidated precedence ordering');
it('should show conflicts list', () => { (component as any).resolveConflict(conflicts[0]);
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.conflicts__list, .conflicts__table')).toBeTruthy();
});
it('should display resolve buttons for conflicts', () => { expect(api.resolveConflict).toHaveBeenCalledWith(
const compiled = fixture.nativeElement as HTMLElement; 'conflict-001',
expect(compiled.textContent).toContain('Resolve'); 'Consolidated precedence ordering',
}); { tenantId: 'demo-prod', projectId: 'stage' },
);
it('should show conflict severity indicators', () => { expect(api.getConflictDashboard).toHaveBeenCalledWith({ tenantId: 'demo-prod', projectId: 'stage' });
const compiled = fixture.nativeElement as HTMLElement; expect(api.getConflicts).toHaveBeenCalledWith({ tenantId: 'demo-prod', projectId: 'stage' });
expect(compiled.querySelector('.conflict__severity')).toBeTruthy();
});
it('should have filter controls', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.conflicts__filters')).toBeTruthy();
}); });
}); });

View File

@@ -13,6 +13,7 @@ import {
PolicyConflictType, PolicyConflictType,
PolicyConflictSeverity, PolicyConflictSeverity,
} from '../../core/api/policy-governance.models'; } from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/** /**
* Policy Conflict Dashboard component. * Policy Conflict Dashboard component.
@@ -598,6 +599,7 @@ import {
}) })
export class PolicyConflictDashboardComponent implements OnInit { export class PolicyConflictDashboardComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API); private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
protected readonly loading = signal(false); protected readonly loading = signal(false);
protected readonly analyzing = signal(false); protected readonly analyzing = signal(false);
@@ -613,7 +615,7 @@ export class PolicyConflictDashboardComponent implements OnInit {
} }
private loadDashboard(): void { private loadDashboard(): void {
this.api.getConflictDashboard({ tenantId: 'acme-tenant' }).subscribe({ this.api.getConflictDashboard(this.governanceScope()).subscribe({
next: (d) => this.dashboard.set(d), next: (d) => this.dashboard.set(d),
error: (err) => console.error('Failed to load dashboard:', err), error: (err) => console.error('Failed to load dashboard:', err),
}); });
@@ -621,7 +623,7 @@ export class PolicyConflictDashboardComponent implements OnInit {
protected loadConflicts(): void { protected loadConflicts(): void {
this.loading.set(true); this.loading.set(true);
const options: any = { tenantId: 'acme-tenant' }; const options: any = { ...this.governanceScope() };
if (this.typeFilter) options.type = this.typeFilter; if (this.typeFilter) options.type = this.typeFilter;
if (this.severityFilter) options.severity = this.severityFilter; if (this.severityFilter) options.severity = this.severityFilter;
@@ -684,7 +686,7 @@ export class PolicyConflictDashboardComponent implements OnInit {
const resolution = prompt('Enter resolution notes:'); const resolution = prompt('Enter resolution notes:');
if (!resolution) return; if (!resolution) return;
this.api.resolveConflict(conflict.id, resolution, { tenantId: 'acme-tenant' }).subscribe({ this.api.resolveConflict(conflict.id, resolution, this.governanceScope()).subscribe({
next: () => { next: () => {
this.loadConflicts(); this.loadConflicts();
this.loadDashboard(); this.loadDashboard();
@@ -697,7 +699,7 @@ export class PolicyConflictDashboardComponent implements OnInit {
const reason = prompt('Enter reason for ignoring:'); const reason = prompt('Enter reason for ignoring:');
if (!reason) return; if (!reason) return;
this.api.ignoreConflict(conflict.id, reason, { tenantId: 'acme-tenant' }).subscribe({ this.api.ignoreConflict(conflict.id, reason, this.governanceScope()).subscribe({
next: () => { next: () => {
this.loadConflicts(); this.loadConflicts();
this.loadDashboard(); 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, RiskBudgetGovernance,
RiskBudgetThreshold, RiskBudgetThreshold,
} from '../../core/api/policy-governance.models'; } from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/** /**
* Risk Budget Configuration component. * Risk Budget Configuration component.
@@ -515,6 +516,7 @@ import {
}) })
export class RiskBudgetConfigComponent implements OnInit { export class RiskBudgetConfigComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API); private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
private readonly fb = inject(FormBuilder); private readonly fb = inject(FormBuilder);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
@@ -595,7 +597,7 @@ export class RiskBudgetConfigComponent implements OnInit {
this.loadError.set(null); this.loadError.set(null);
this.loading.set(true); this.loading.set(true);
this.api this.api
.getRiskBudgetDashboard({ tenantId: 'acme-tenant' }) .getRiskBudgetDashboard(this.governanceScope())
.pipe(finalize(() => this.loading.set(false))) .pipe(finalize(() => this.loading.set(false)))
.subscribe({ .subscribe({
next: (dashboard) => { next: (dashboard) => {
@@ -682,8 +684,8 @@ export class RiskBudgetConfigComponent implements OnInit {
const config: RiskBudgetGovernance = { const config: RiskBudgetGovernance = {
id: baseline?.id ?? 'budget-001', id: baseline?.id ?? 'budget-001',
tenantId: baseline?.tenantId ?? 'acme-tenant', tenantId: baseline?.tenantId ?? this.governanceScope().tenantId,
projectId: baseline?.projectId, projectId: baseline?.projectId ?? this.governanceScope().projectId,
name: formValue.name, name: formValue.name,
totalBudget: formValue.totalBudget, totalBudget: formValue.totalBudget,
period: formValue.period, period: formValue.period,
@@ -705,7 +707,7 @@ export class RiskBudgetConfigComponent implements OnInit {
}; };
this.api this.api
.updateRiskBudgetConfig(config, { tenantId: 'acme-tenant' }) .updateRiskBudgetConfig(config, this.governanceScope())
.pipe(finalize(() => this.saving.set(false))) .pipe(finalize(() => this.saving.set(false)))
.subscribe({ .subscribe({
next: (updatedConfig) => { next: (updatedConfig) => {

View File

@@ -11,6 +11,7 @@ import {
RiskBudgetContributor, RiskBudgetContributor,
RiskBudgetAlert, RiskBudgetAlert,
} from '../../core/api/policy-governance.models'; } from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/** /**
* Risk Budget Dashboard component. * Risk Budget Dashboard component.
@@ -626,6 +627,7 @@ import {
}) })
export class RiskBudgetDashboardComponent implements OnInit { export class RiskBudgetDashboardComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API); private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
protected readonly loading = signal(false); protected readonly loading = signal(false);
protected readonly data = signal<RiskBudgetDashboard | null>(null); protected readonly data = signal<RiskBudgetDashboard | null>(null);
@@ -639,7 +641,7 @@ export class RiskBudgetDashboardComponent implements OnInit {
this.loadError.set(null); this.loadError.set(null);
this.loading.set(true); this.loading.set(true);
this.api this.api
.getRiskBudgetDashboard({ tenantId: 'acme-tenant' }) .getRiskBudgetDashboard(this.governanceScope())
.pipe(finalize(() => this.loading.set(false))) .pipe(finalize(() => this.loading.set(false)))
.subscribe({ .subscribe({
next: (dashboard) => { next: (dashboard) => {
@@ -663,8 +665,8 @@ export class RiskBudgetDashboardComponent implements OnInit {
const config = { const config = {
id: rawConfig?.id ?? 'risk-budget-default', id: rawConfig?.id ?? 'risk-budget-default',
tenantId: rawConfig?.tenantId ?? 'acme-tenant', tenantId: rawConfig?.tenantId ?? this.governanceScope().tenantId,
projectId: rawConfig?.projectId, projectId: rawConfig?.projectId ?? this.governanceScope().projectId,
name: rawConfig?.name ?? 'Default Risk Budget', name: rawConfig?.name ?? 'Default Risk Budget',
totalBudget, totalBudget,
warningThreshold, warningThreshold,
@@ -750,7 +752,7 @@ export class RiskBudgetDashboardComponent implements OnInit {
} }
protected acknowledgeAlert(alert: RiskBudgetAlert): void { 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(), next: () => this.loadData(),
error: (err) => console.error('Failed to acknowledge alert:', err), error: (err) => console.error('Failed to acknowledge alert:', err),
}); });

View File

@@ -12,6 +12,7 @@ import {
RiskProfileValidation, RiskProfileValidation,
SignalWeight, SignalWeight,
} from '../../core/api/policy-governance.models'; } from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/** /**
* Risk Profile Editor component. * Risk Profile Editor component.
@@ -559,6 +560,7 @@ import {
}) })
export class RiskProfileEditorComponent implements OnInit { export class RiskProfileEditorComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API); private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
private readonly fb = inject(FormBuilder); private readonly fb = inject(FormBuilder);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
@@ -610,7 +612,7 @@ export class RiskProfileEditorComponent implements OnInit {
} }
private loadAvailableProfiles(): void { 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), next: (profiles) => this.availableProfiles.set(profiles),
}); });
} }
@@ -618,7 +620,7 @@ export class RiskProfileEditorComponent implements OnInit {
private loadProfile(profileId: string): void { private loadProfile(profileId: string): void {
this.loading.set(true); this.loading.set(true);
this.api this.api
.getRiskProfile(profileId, { tenantId: 'acme-tenant' }) .getRiskProfile(profileId, this.governanceScope())
.pipe(finalize(() => this.loading.set(false))) .pipe(finalize(() => this.loading.set(false)))
.subscribe({ .subscribe({
next: (profile) => { next: (profile) => {
@@ -736,8 +738,8 @@ export class RiskProfileEditorComponent implements OnInit {
const profile = this.buildProfile(); const profile = this.buildProfile();
const request$ = this.isNew() const request$ = this.isNew()
? this.api.createRiskProfile(profile, { tenantId: 'acme-tenant' }) ? this.api.createRiskProfile(profile, this.governanceScope())
: this.api.updateRiskProfile(profile.id!, profile, { tenantId: 'acme-tenant' }); : this.api.updateRiskProfile(profile.id!, profile, this.governanceScope());
request$.pipe(finalize(() => this.saving.set(false))).subscribe({ request$.pipe(finalize(() => this.saving.set(false))).subscribe({
next: () => this.router.navigate(['../'], { relativeTo: this.route }), next: () => this.router.navigate(['../'], { relativeTo: this.route }),

View File

@@ -10,6 +10,7 @@ import {
RiskProfileGov, RiskProfileGov,
RiskProfileGovernanceStatus, RiskProfileGovernanceStatus,
} from '../../core/api/policy-governance.models'; } from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/** /**
* Risk Profile List component. * Risk Profile List component.
@@ -389,6 +390,7 @@ import {
}) })
export class RiskProfileListComponent implements OnInit { export class RiskProfileListComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API); private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
protected readonly loading = signal(false); protected readonly loading = signal(false);
protected readonly profiles = signal<RiskProfileGov[]>([]); protected readonly profiles = signal<RiskProfileGov[]>([]);
@@ -400,7 +402,7 @@ export class RiskProfileListComponent implements OnInit {
private loadProfiles(): void { private loadProfiles(): void {
this.loading.set(true); this.loading.set(true);
const options: any = { tenantId: 'acme-tenant' }; const options: any = { ...this.governanceScope() };
const filter = this.statusFilter(); const filter = this.statusFilter();
if (filter) { if (filter) {
options.status = filter; options.status = filter;
@@ -423,7 +425,7 @@ export class RiskProfileListComponent implements OnInit {
protected activateProfile(profile: RiskProfileGov): void { protected activateProfile(profile: RiskProfileGov): void {
if (!confirm(`Activate profile "${profile.name}"?`)) return; 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(), next: () => this.loadProfiles(),
error: (err) => console.error('Failed to activate profile:', err), 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}":`); const reason = prompt(`Reason for deprecating "${profile.name}":`);
if (!reason) return; 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(), next: () => this.loadProfiles(),
error: (err) => console.error('Failed to deprecate profile:', err), error: (err) => console.error('Failed to deprecate profile:', err),
}); });
@@ -442,7 +444,7 @@ export class RiskProfileListComponent implements OnInit {
protected deleteProfile(profile: RiskProfileGov): void { protected deleteProfile(profile: RiskProfileGov): void {
if (!confirm(`Delete profile "${profile.name}"? This cannot be undone.`)) return; 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(), next: () => this.loadProfiles(),
error: (err) => console.error('Failed to delete profile:', err), error: (err) => console.error('Failed to delete profile:', err),
}); });

View File

@@ -12,6 +12,7 @@ import {
SealedModeToggleRequest, SealedModeToggleRequest,
SealedModeOverrideRequest, SealedModeOverrideRequest,
} from '../../core/api/policy-governance.models'; } from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/** /**
* Sealed Mode Control component. * Sealed Mode Control component.
@@ -754,6 +755,7 @@ import {
}) })
export class SealedModeControlComponent implements OnInit { export class SealedModeControlComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API); private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
private readonly fb = inject(FormBuilder); private readonly fb = inject(FormBuilder);
protected readonly loading = signal(false); protected readonly loading = signal(false);
@@ -788,7 +790,7 @@ export class SealedModeControlComponent implements OnInit {
private loadStatus(): void { private loadStatus(): void {
this.loading.set(true); this.loading.set(true);
this.api this.api
.getSealedModeStatus({ tenantId: 'acme-tenant' }) .getSealedModeStatus(this.governanceScope())
.pipe(finalize(() => this.loading.set(false))) .pipe(finalize(() => this.loading.set(false)))
.subscribe({ .subscribe({
next: (status) => this.status.set(this.buildSafeStatus(status)), next: (status) => this.status.set(this.buildSafeStatus(status)),
@@ -823,7 +825,7 @@ export class SealedModeControlComponent implements OnInit {
}; };
this.api this.api
.toggleSealedMode(request, { tenantId: 'acme-tenant' }) .toggleSealedMode(request, this.governanceScope())
.pipe(finalize(() => this.toggling.set(false))) .pipe(finalize(() => this.toggling.set(false)))
.subscribe({ .subscribe({
next: (status) => { next: (status) => {
@@ -854,7 +856,7 @@ export class SealedModeControlComponent implements OnInit {
}; };
this.api this.api
.toggleSealedMode(request, { tenantId: 'acme-tenant' }) .toggleSealedMode(request, this.governanceScope())
.pipe(finalize(() => this.toggling.set(false))) .pipe(finalize(() => this.toggling.set(false)))
.subscribe({ .subscribe({
next: (status) => { next: (status) => {
@@ -888,7 +890,7 @@ export class SealedModeControlComponent implements OnInit {
}; };
this.api this.api
.createSealedModeOverride(request, { tenantId: 'acme-tenant' }) .createSealedModeOverride(request, this.governanceScope())
.pipe(finalize(() => this.creatingOverride.set(false))) .pipe(finalize(() => this.creatingOverride.set(false)))
.subscribe({ .subscribe({
next: () => { next: () => {
@@ -902,7 +904,7 @@ export class SealedModeControlComponent implements OnInit {
protected revokeOverride(override: SealedModeOverride): void { protected revokeOverride(override: SealedModeOverride): void {
if (!confirm('Revoke this override?')) return; 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(), next: () => this.loadStatus(),
error: (err) => console.error('Failed to revoke override:', err), error: (err) => console.error('Failed to revoke override:', err),
}); });

View File

@@ -11,6 +11,7 @@ import {
SealedModeOverride, SealedModeOverride,
SealedModeOverrideRequest, SealedModeOverrideRequest,
} from '../../core/api/policy-governance.models'; } from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/** /**
* Sealed Mode Overrides component. * Sealed Mode Overrides component.
@@ -615,6 +616,7 @@ import {
}) })
export class SealedModeOverridesComponent implements OnInit { export class SealedModeOverridesComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API); private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
protected readonly loading = signal(false); protected readonly loading = signal(false);
protected readonly creating = signal(false); protected readonly creating = signal(false);
@@ -659,7 +661,7 @@ export class SealedModeOverridesComponent implements OnInit {
private loadOverrides(): void { private loadOverrides(): void {
this.loading.set(true); this.loading.set(true);
this.api this.api
.getSealedModeOverrides({ tenantId: 'acme-tenant' }) .getSealedModeOverrides(this.governanceScope())
.pipe(finalize(() => this.loading.set(false))) .pipe(finalize(() => this.loading.set(false)))
.subscribe({ .subscribe({
next: (overrides) => this.allOverrides.set(overrides), next: (overrides) => this.allOverrides.set(overrides),
@@ -698,7 +700,7 @@ export class SealedModeOverridesComponent implements OnInit {
this.creating.set(true); this.creating.set(true);
this.api this.api
.createSealedModeOverride(this.newOverride, { tenantId: 'acme-tenant' }) .createSealedModeOverride(this.newOverride, this.governanceScope())
.pipe(finalize(() => this.creating.set(false))) .pipe(finalize(() => this.creating.set(false)))
.subscribe({ .subscribe({
next: () => { next: () => {
@@ -726,7 +728,7 @@ export class SealedModeOverridesComponent implements OnInit {
reason: `Extension for ${override.id}: ${override.reason}`, reason: `Extension for ${override.id}: ${override.reason}`,
durationHours, durationHours,
}, },
{ tenantId: 'acme-tenant' } this.governanceScope()
) )
.pipe(finalize(() => this.creating.set(false))) .pipe(finalize(() => this.creating.set(false)))
.subscribe({ .subscribe({
@@ -739,7 +741,7 @@ export class SealedModeOverridesComponent implements OnInit {
const reason = prompt('Reason for revoking this override:'); const reason = prompt('Reason for revoking this override:');
if (!reason) return; 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(), next: () => this.loadOverrides(),
error: (err) => console.error('Failed to revoke override:', err), error: (err) => console.error('Failed to revoke override:', err),
}); });

View File

@@ -13,6 +13,7 @@ import {
StalenessDataType, StalenessDataType,
StalenessLevel, StalenessLevel,
} from '../../core/api/policy-governance.models'; } from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/** /**
* Staleness Configuration component. * Staleness Configuration component.
@@ -601,6 +602,7 @@ import {
}) })
export class StalenessConfigComponent implements OnInit { export class StalenessConfigComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API); private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
protected readonly loading = signal(false); protected readonly loading = signal(false);
protected readonly saving = signal(false); protected readonly saving = signal(false);
@@ -625,7 +627,7 @@ export class StalenessConfigComponent implements OnInit {
private loadConfig(): void { private loadConfig(): void {
this.loading.set(true); this.loading.set(true);
this.api this.api
.getStalenessConfig({ tenantId: 'acme-tenant' }) .getStalenessConfig(this.governanceScope())
.pipe(finalize(() => this.loading.set(false))) .pipe(finalize(() => this.loading.set(false)))
.subscribe({ .subscribe({
next: (container) => { next: (container) => {
@@ -642,7 +644,7 @@ export class StalenessConfigComponent implements OnInit {
} }
private loadStatus(): void { 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 : []), next: (statuses) => this.statusList.set(Array.isArray(statuses) ? statuses : []),
error: (err) => console.error('Failed to load staleness status:', err), error: (err) => console.error('Failed to load staleness status:', err),
}); });
@@ -710,7 +712,7 @@ export class StalenessConfigComponent implements OnInit {
protected saveConfig(config: StalenessConfig): void { protected saveConfig(config: StalenessConfig): void {
this.saving.set(true); this.saving.set(true);
this.api this.api
.updateStalenessConfig(config, { tenantId: 'acme-tenant' }) .updateStalenessConfig(config, this.governanceScope())
.pipe(finalize(() => this.saving.set(false))) .pipe(finalize(() => this.saving.set(false)))
.subscribe({ .subscribe({
next: () => this.loadConfig(), next: () => this.loadConfig(),
@@ -735,8 +737,8 @@ export class StalenessConfigComponent implements OnInit {
); );
return { return {
tenantId: container?.tenantId ?? 'acme-tenant', tenantId: container?.tenantId ?? this.governanceScope().tenantId,
projectId: container?.projectId, projectId: container?.projectId ?? this.governanceScope().projectId,
configs, configs,
modifiedAt: container?.modifiedAt ?? now, modifiedAt: container?.modifiedAt ?? now,
etag: container?.etag, etag: container?.etag,

View File

@@ -12,6 +12,7 @@ import {
TrustWeightImpact, TrustWeightImpact,
TrustWeightSource, TrustWeightSource,
} from '../../core/api/policy-governance.models'; } from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/** /**
* Trust Weighting component. * Trust Weighting component.
@@ -708,6 +709,7 @@ import {
}) })
export class TrustWeightingComponent implements OnInit { export class TrustWeightingComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API); private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
private readonly fb = inject(FormBuilder); private readonly fb = inject(FormBuilder);
protected readonly loading = signal(false); protected readonly loading = signal(false);
@@ -738,7 +740,7 @@ export class TrustWeightingComponent implements OnInit {
private loadConfig(): void { private loadConfig(): void {
this.loading.set(true); this.loading.set(true);
this.api this.api
.getTrustWeightConfig({ tenantId: 'acme-tenant' }) .getTrustWeightConfig(this.governanceScope())
.pipe(finalize(() => this.loading.set(false))) .pipe(finalize(() => this.loading.set(false)))
.subscribe({ .subscribe({
next: (config) => this.config.set(config), next: (config) => this.config.set(config),
@@ -814,7 +816,7 @@ export class TrustWeightingComponent implements OnInit {
}; };
this.api this.api
.updateTrustWeight(weight, { tenantId: 'acme-tenant' }) .updateTrustWeight(weight, this.governanceScope())
.pipe(finalize(() => this.saving.set(false))) .pipe(finalize(() => this.saving.set(false)))
.subscribe({ .subscribe({
next: () => { next: () => {
@@ -828,7 +830,7 @@ export class TrustWeightingComponent implements OnInit {
protected deleteWeight(weight: TrustWeight): void { protected deleteWeight(weight: TrustWeight): void {
if (!confirm(`Delete trust weight for ${weight.issuerName}?`)) return; 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(), next: () => this.loadConfig(),
error: (err) => console.error('Failed to delete weight:', err), error: (err) => console.error('Failed to delete weight:', err),
}); });
@@ -840,7 +842,7 @@ export class TrustWeightingComponent implements OnInit {
this.impact.set(null); this.impact.set(null);
this.api this.api
.previewTrustWeightImpact([weight], { tenantId: 'acme-tenant' }) .previewTrustWeightImpact([weight], this.governanceScope())
.pipe(finalize(() => this.impactLoading.set(false))) .pipe(finalize(() => this.impactLoading.set(false)))
.subscribe({ .subscribe({
next: (result) => this.impact.set(result), next: (result) => this.impact.set(result),

View File

@@ -662,7 +662,7 @@ export class VexHubDashboardComponent implements OnInit {
getSourcePercentage(value: number): number { getSourcePercentage(value: number): number {
const s = this.stats(); const s = this.stats();
if (!s) return 0; 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; return max > 0 ? (value / max) * 100 : 0;
} }
@@ -673,6 +673,11 @@ export class VexHubDashboardComponent implements OnInit {
oss: 'OSS Maintainer', oss: 'OSS Maintainer',
researcher: 'Security Researcher', researcher: 'Security Researcher',
ai_generated: 'AI Generated', ai_generated: 'AI Generated',
internal: 'Internal',
community: 'Community',
distributor: 'Distributor',
aggregator: 'Aggregator',
unknown: 'Unknown',
}; };
return labels[type] || type; 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 @@
/** import { ComponentFixture, TestBed } from '@angular/core/testing';
* 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 { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { of, throwError } from 'rxjs'; import { of, throwError } from 'rxjs';
import { VexHubStatsComponent } from './vex-hub-stats.component';
import { VEX_HUB_API, VexHubApi } from '../../core/api/vex-hub.client'; 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', () => { describe('VexHubStatsComponent', () => {
let component: VexHubStatsComponent;
let fixture: ComponentFixture<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, totalStatements: 1000,
byStatus: { byStatus: {
affected: 150, affected: 150,
@@ -63,8 +58,8 @@ describe('VexHubStatsComponent', () => {
], ],
}; };
beforeEach(async () => { async function createComponent(): Promise<void> {
mockVexHubApi = jasmine.createSpyObj<VexHubApi>('VexHubApi', [ api = jasmine.createSpyObj<VexHubApi>('VexHubApi', [
'searchStatements', 'searchStatements',
'getStatement', 'getStatement',
'createStatement', 'createStatement',
@@ -78,424 +73,135 @@ describe('VexHubStatsComponent', () => {
'getVexLensConsensus', 'getVexLensConsensus',
'getVexLensConflicts', 'getVexLensConflicts',
]); ]);
api.getStats.and.returnValue(of(stats));
mockVexHubApi.getStats.and.returnValue(of(mockStats));
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [VexHubStatsComponent], imports: [VexHubStatsComponent],
providers: [ providers: [
provideRouter([]), provideRouter([]),
{ provide: VEX_HUB_API, useValue: mockVexHubApi }, { provide: VEX_HUB_API, useValue: api },
], ],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(VexHubStatsComponent); fixture = TestBed.createComponent(VexHubStatsComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
}
async function renderComponent(): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(async () => {
await createComponent();
}); });
describe('Component Creation', () => { it('loads stats on init and renders the summary surface', async () => {
it('should create the component', () => { await renderComponent();
expect(component).toBeTruthy();
});
it('should have default signal values', () => { expect(api.getStats).toHaveBeenCalled();
expect(component.loading()).toBe(false); expect(component.stats()).toEqual(stats);
expect(component.error()).toBeNull(); expect(component.loading()).toBe(false);
expect(component.stats()).toBeNull();
});
it('should have default refreshInterval input', () => { const header = fixture.debugElement.query(By.css('.stats-header h1'));
expect(component.refreshInterval()).toBe(0); 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('computes status, source, and trend values from the loaded contract', async () => {
it('should render header', fakeAsync(() => { await renderComponent();
fixture.detectChanges();
tick();
fixture.detectChanges();
const header = fixture.debugElement.query(By.css('.stats-header h1')); expect(component.statusItems()).toEqual([
expect(header.nativeElement.textContent).toContain('VEX Hub Statistics'); { status: 'affected', count: 150 },
})); { status: 'not_affected', count: 600 },
{ status: 'fixed', count: 200 },
{ status: 'under_investigation', count: 50 },
]);
it('should render back button', fakeAsync(() => { expect(component.sourceItems()[0]).toEqual({ source: 'vendor', count: 400 });
fixture.detectChanges(); expect(component.maxTrendValue()).toBe(75);
tick(); expect(component.getStatusPercentage(150)).toBe(15);
fixture.detectChanges(); expect(component.getSourcePercentage(400)).toBe(40);
expect(component.getTrendHeight(75)).toBe(80);
const backButton = fixture.debugElement.query(By.css('.btn-back')); expect(component.getTrendHeight(0)).toBe(4);
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();
}));
}); });
describe('OnInit', () => { it('renders recent activity and trend bars after loading', async () => {
it('should load stats on init', fakeAsync(() => { await renderComponent();
fixture.detectChanges();
tick();
expect(mockVexHubApi.getStats).toHaveBeenCalled(); const activityItems = fixture.debugElement.queryAll(By.css('.activity-item'));
expect(component.stats()).toEqual(mockStats); 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(() => { expect(activityItems.length).toBe(3);
component.loadStats(); expect(trendGroups.length).toBe(7);
expect(component.loading()).toBe(true); expect(createdIndicator).not.toBeNull();
tick();
expect(component.loading()).toBe(false);
}));
}); });
describe('Service Interactions', () => { it('renders the empty activity state and hides trends when the backend returns none', async () => {
it('should set stats when API returns successfully', fakeAsync(() => { api.getStats.and.returnValue(of({ ...stats, recentActivity: [], trends: [] }));
component.loadStats();
tick();
expect(component.stats()).toEqual(mockStats); fixture = TestBed.createComponent(VexHubStatsComponent);
expect(component.error()).toBeNull(); component = fixture.componentInstance;
})); await renderComponent();
it('should set error when API call fails', fakeAsync(() => { expect(fixture.debugElement.query(By.css('.empty-activity'))).not.toBeNull();
const errorMessage = 'Network error'; expect(fixture.debugElement.query(By.css('.trends-section'))).toBeNull();
mockVexHubApi.getStats.and.returnValue(throwError(() => new Error(errorMessage))); expect(component.maxTrendValue()).toBe(0);
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();
}));
}); });
describe('Computed Values', () => { it('surfaces backend failures and retries through the same API contract', async () => {
beforeEach(fakeAsync(() => { api.getStats.and.returnValues(
fixture.detectChanges(); throwError(() => new Error('Network error')),
tick(); of(stats),
})); );
it('should compute statusItems correctly', () => { fixture = TestBed.createComponent(VexHubStatsComponent);
const items = component.statusItems(); component = fixture.componentInstance;
expect(items.length).toBe(4); await renderComponent();
expect(items.find(i => i.status === 'affected')?.count).toBe(150);
expect(items.find(i => i.status === 'not_affected')?.count).toBe(600);
});
it('should compute sourceItems sorted by count descending', () => { const errorBanner = fixture.debugElement.query(By.css('.error-banner'));
const items = component.sourceItems(); expect(errorBanner).not.toBeNull();
expect(items.length).toBe(5); expect(errorBanner.nativeElement.textContent).toContain('Network error');
expect(items[0].source).toBe('vendor'); expect(component.loading()).toBe(false);
expect(items[0].count).toBe(400);
});
it('should compute maxTrendValue correctly', () => { errorBanner.query(By.css('.btn--text')).triggerEventHandler('click', null);
expect(component.maxTrendValue()).toBe(75); // max notAffected value await fixture.whenStable();
}); fixture.detectChanges();
it('should return 0 for maxTrendValue when no trends', () => { expect(api.getStats).toHaveBeenCalledTimes(2);
component.stats.set({ ...mockStats, trends: [] }); expect(component.error()).toBeNull();
expect(component.maxTrendValue()).toBe(0); expect(component.stats()).toEqual(stats);
});
}); });
describe('Percentage Calculations', () => { it('formats current source labels and icons for live stats buckets', async () => {
beforeEach(fakeAsync(() => { await renderComponent();
fixture.detectChanges();
tick();
}));
it('should calculate status percentage correctly', () => { expect(component.formatSourceType('internal')).toBe('Internal');
const percentage = component.getStatusPercentage(150); expect(component.formatSourceType('community')).toBe('Community');
expect(percentage).toBe(15); // 150/1000 = 15% 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', () => { expect(component.getSourceIcon('internal')).toBe('I');
component.stats.set({ ...mockStats, totalStatements: 0 }); expect(component.getSourceIcon('community')).toBe('C');
expect(component.getStatusPercentage(150)).toBe(0); expect(component.getSourceIcon('distributor')).toBe('D');
}); expect(component.getSourceIcon('aggregator')).toBe('A');
expect(component.getSourceIcon('unknown')).toBe('?');
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);
});
}); });
describe('Format Functions', () => { it('keeps percentage helpers at zero when the total statement count is missing', async () => {
it('should format status correctly', () => { await renderComponent();
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('should return original status for unknown values', () => { component.stats.set({ ...stats, totalStatements: 0, trends: [] });
expect(component.formatStatus('unknown' as any)).toBe('unknown');
});
it('should format source type correctly', () => { expect(component.getStatusPercentage(10)).toBe(0);
expect(component.formatSourceType('vendor')).toBe('Vendor'); expect(component.getSourcePercentage(10)).toBe(0);
expect(component.formatSourceType('cert')).toBe('CERT/CSIRT'); expect(component.getTrendHeight(10)).toBe(4);
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);
});
}); });
}); });

View File

@@ -20,7 +20,6 @@ import { VEX_HUB_API, VexHubApi } from '../../core/api/vex-hub.client';
import { import {
VexHubStats, VexHubStats,
VexStatementStatus, VexStatementStatus,
VexIssuerType,
VexActivityItem, VexActivityItem,
VexTrendData, VexTrendData,
} from '../../core/api/vex-hub.models'; } from '../../core/api/vex-hub.models';
@@ -759,11 +758,10 @@ export class VexHubStatsComponent implements OnInit {
readonly sourceItems = computed(() => { readonly sourceItems = computed(() => {
const s = this.stats(); const s = this.stats();
if (!s) return []; if (!s) return [];
const sources: VexIssuerType[] = ['vendor', 'cert', 'oss', 'researcher', 'ai_generated']; return Object.entries(s.bySource)
return sources .map(([source, count]) => ({
.map((source) => ({
source, source,
count: s.bySource[source] || 0, count,
})) }))
.sort((a, b) => b.count - a.count); .sort((a, b) => b.count - a.count);
}); });
@@ -812,13 +810,18 @@ export class VexHubStatsComponent implements OnInit {
return Math.max(4, (value / max) * 80); return Math.max(4, (value / max) * 80);
} }
getSourceIcon(source: VexIssuerType): string { getSourceIcon(source: string): string {
const icons: Record<VexIssuerType, string> = { const icons: Record<string, string> = {
vendor: 'V', vendor: 'V',
cert: 'C', cert: 'C',
oss: 'O', oss: 'O',
researcher: 'R', researcher: 'R',
ai_generated: 'AI', ai_generated: 'AI',
internal: 'I',
community: 'C',
distributor: 'D',
aggregator: 'A',
unknown: '?',
}; };
return icons[source] || '?'; return icons[source] || '?';
} }
@@ -833,13 +836,18 @@ export class VexHubStatsComponent implements OnInit {
return labels[status] || status; return labels[status] || status;
} }
formatSourceType(type: VexIssuerType): string { formatSourceType(type: string): string {
const labels: Record<VexIssuerType, string> = { const labels: Record<string, string> = {
vendor: 'Vendor', vendor: 'Vendor',
cert: 'CERT/CSIRT', cert: 'CERT/CSIRT',
oss: 'OSS Maintainer', oss: 'OSS Maintainer',
researcher: 'Researcher', researcher: 'Researcher',
ai_generated: 'AI Generated', ai_generated: 'AI Generated',
internal: 'Internal',
community: 'Community',
distributor: 'Distributor',
aggregator: 'Aggregator',
unknown: 'Unknown',
}; };
return labels[type] || type; 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() // jasmine.createSpyObj → object whose methods are vi.fn()
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function createSpyObj<T extends string>( function createSpyObj<T extends object>(
baseNameOrMethods: string | T[], baseName: string,
methodNamesOrProperties?: T[] | Record<string, unknown>, 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>, propertyNames?: Record<string, unknown>,
): Record<string, Mock> { ): Record<string, Mock> {
let methods: T[]; let methods: readonly string[];
let properties: Record<string, unknown> | undefined; let properties: Record<string, unknown> | undefined;
if (Array.isArray(baseNameOrMethods)) { if (Array.isArray(baseNameOrMethods)) {
@@ -38,7 +56,7 @@ function createSpyObj<T extends string>(
? (methodNamesOrProperties as Record<string, unknown>) ? (methodNamesOrProperties as Record<string, unknown>)
: undefined; : undefined;
} else { } else {
methods = (methodNamesOrProperties ?? []) as T[]; methods = Array.isArray(methodNamesOrProperties) ? methodNamesOrProperties : [];
properties = propertyNames; properties = propertyNames;
} }
@@ -124,15 +142,24 @@ declare global {
name?: string, name?: string,
originalFn?: T, originalFn?: T,
): Mock<T>; ): Mock<T>;
function createSpyObj<T extends string>( function createSpyObj<T extends object>(
baseName: string, 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>, propertyNames?: Record<string, unknown>,
): Record<T, Mock>; ): Record<string, Mock>;
function createSpyObj<T extends string>( function createSpyObj(
methodNames: T[], methodNames: readonly string[],
propertyNames?: Record<string, unknown>, propertyNames?: Record<string, unknown>,
): Record<T, Mock>; ): Record<string, Mock>;
function objectContaining(sample: Record<string, unknown>): any; function objectContaining(sample: Record<string, unknown>): any;
function arrayContaining(sample: unknown[]): any; function arrayContaining(sample: unknown[]): any;
function stringMatching(pattern: string | RegExp): any; function stringMatching(pattern: string | RegExp): any;
@@ -209,6 +236,14 @@ if (!Object.getOwnPropertyDescriptor(MockPrototype, 'and')) {
self.mockRestore(); self.mockRestore();
return self; return self;
}, },
resolveTo(val: unknown) {
self.mockResolvedValue(val);
return self;
},
rejectWith(val: unknown) {
self.mockRejectedValue(val);
return self;
},
throwError(msg: string | Error) { throwError(msg: string | Error) {
self.mockImplementation(() => { self.mockImplementation(() => {
throw typeof msg === 'string' ? new Error(msg) : msg; 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/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/jobengine/jobengine-dashboard.component.spec.ts",
"src/app/features/platform/ops/platform-jobs-queues-page.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-direct-route-defaults.spec.ts",
"src/app/features/policy-simulation/policy-simulation-defaults.spec.ts", "src/app/features/policy-simulation/policy-simulation-defaults.spec.ts",
"src/app/features/policy-simulation/simulation-dashboard.component.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/trust-admin/trust-admin.component.spec.ts",
"src/app/features/triage/services/ttfs-telemetry.service.spec.ts", "src/app/features/triage/services/ttfs-telemetry.service.spec.ts",
"src/app/features/triage/triage-workspace.component.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/shared/ui/filter-bar/filter-bar.component.spec.ts",
"src/app/features/watchlist/watchlist-page.component.spec.ts", "src/app/features/watchlist/watchlist-page.component.spec.ts",
"src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts" "src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts"