diff --git a/devops/compose/docker-compose.stella-ops.yml b/devops/compose/docker-compose.stella-ops.yml index 0c887247a..d788f47a1 100644 --- a/devops/compose/docker-compose.stella-ops.yml +++ b/devops/compose/docker-compose.stella-ops.yml @@ -353,7 +353,7 @@ services: Platform__EnvironmentSettings__TokenEndpoint: "https://stella-ops.local/connect/token" Platform__EnvironmentSettings__RedirectUri: "https://stella-ops.local/auth/callback" Platform__EnvironmentSettings__PostLogoutRedirectUri: "https://stella-ops.local/" - Platform__EnvironmentSettings__Scope: "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate timeline:read timeline:write" + Platform__EnvironmentSettings__Scope: "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate registry.admin timeline:read timeline:write" STELLAOPS_ROUTER_URL: "http://router.stella-ops.local" STELLAOPS_PLATFORM_URL: "http://platform.stella-ops.local" STELLAOPS_AUTHORITY_URL: "http://authority.stella-ops.local" @@ -452,6 +452,14 @@ services: STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapUser__Username: "admin" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapUser__Password: "Admin@Stella2026!" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapUser__Roles__0: "admin" + STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__ClientId: "stella-ops-ui" + STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__DisplayName: "Stella Ops Console" + STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__AllowedGrantTypes: "authorization_code refresh_token" + STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__AllowedScopes: "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate registry.admin timeline:read timeline:write" + STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__RedirectUris: "https://stella-ops.local/auth/callback https://stella-ops.local/auth/silent-refresh https://127.1.0.1/auth/callback https://127.1.0.1/auth/silent-refresh" + STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__PostLogoutRedirectUris: "https://stella-ops.local/ https://127.1.0.1/" + STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__RequirePkce: "true" + STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__AllowPlainTextPkce: "false" STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__ID: "demo-prod" STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__DISPLAYNAME: "Demo Production" STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__STATUS: "active" diff --git a/devops/compose/postgres-init/04-authority-schema.sql b/devops/compose/postgres-init/04-authority-schema.sql index 87f7ba404..7580108d6 100644 --- a/devops/compose/postgres-init/04-authority-schema.sql +++ b/devops/compose/postgres-init/04-authority-schema.sql @@ -654,9 +654,8 @@ VALUES 'vuln:view', 'vuln:investigate', 'vuln:operate', 'vuln:audit', 'platform.context.read', 'platform.context.write', 'doctor:run', 'doctor:admin', 'ops.health', - 'integration:read', 'integration:write', 'integration:operate', + 'integration:read', 'integration:write', 'integration:operate', 'registry.admin', 'timeline:read', 'timeline:write'], ARRAY['authorization_code', 'refresh_token'], false, true, '{"tenant": "demo-prod"}'::jsonb) ON CONFLICT (client_id) DO NOTHING; - diff --git a/devops/compose/router-gateway-local.json b/devops/compose/router-gateway-local.json index 4a332ee0f..aff884280 100644 --- a/devops/compose/router-gateway-local.json +++ b/devops/compose/router-gateway-local.json @@ -489,6 +489,12 @@ "TranslatesTo": "https://vexhub.stella-ops.local/api/vex", "PreserveAuthHeaders": true }, + { + "Type": "Microservice", + "Path": "/api/admin/plans", + "TranslatesTo": "http://registry-token.stella-ops.local/api/admin/plans", + "PreserveAuthHeaders": true + }, { "Type": "Microservice", "Path": "/api/admin", diff --git a/devops/compose/router-gateway-local.reverseproxy.json b/devops/compose/router-gateway-local.reverseproxy.json index 668e2addb..13b123e22 100644 --- a/devops/compose/router-gateway-local.reverseproxy.json +++ b/devops/compose/router-gateway-local.reverseproxy.json @@ -448,6 +448,12 @@ "TranslatesTo": "https://vexhub.stella-ops.local/api/vex", "PreserveAuthHeaders": true }, + { + "Type": "ReverseProxy", + "Path": "/api/admin/plans", + "TranslatesTo": "http://registry-token.stella-ops.local/api/admin/plans", + "PreserveAuthHeaders": true + }, { "Type": "ReverseProxy", "Path": "/api/admin", diff --git a/docs/features/checked/web/evidence-thread-browser.md b/docs/features/checked/web/evidence-thread-browser.md index 600ce908f..eaaae0cf7 100644 --- a/docs/features/checked/web/evidence-thread-browser.md +++ b/docs/features/checked/web/evidence-thread-browser.md @@ -7,41 +7,43 @@ Web VERIFIED ## Description -Browse and inspect evidence threads per artifact digest. List view shows all evidence threads; detail view shows the full thread of evidence for a specific artifact including all linked attestations, proofs, and verification results. +Search and inspect canonical evidence records by package URL. The live route uses the shipped EvidenceLocker API only: +- `/api/v1/evidence/thread?purl=...` for the list/search view +- `/api/v1/evidence/thread/{canonicalId}` for the detail view + +The current route supports PURL search, empty-result handling, missing-detail handling, and back-navigation. Legacy graph/transcript/export affordances are not exposed on the live route because the current API does not provide those capabilities. ## Implementation Details - **Feature directory**: `src/Web/StellaOps.Web/src/app/features/evidence-thread/` -- **Routes**: `evidence-thread.routes.ts` -- **Components**: - - `evidence-export-dialog` (`src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-export-dialog/evidence-export-dialog.component.ts`) - - `evidence-graph-panel` (`src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-graph-panel/evidence-graph-panel.component.ts`) - - `evidence-node-card` (`src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-node-card/evidence-node-card.component.ts`) +- **Routes**: `evidence-thread.routes.ts` exposes `/evidence/threads` and `/evidence/threads/:canonicalId` +- **Supported live components**: - `evidence-thread-list` (`src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.ts`) - `evidence-thread-view` (`src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.ts`) - - `evidence-timeline-panel` (`src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-timeline-panel/evidence-timeline-panel.component.ts`) - - `evidence-transcript-panel` (`src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-transcript-panel/evidence-transcript-panel.component.ts`) - **Services**: - `evidence-thread` (`src/Web/StellaOps.Web/src/app/features/evidence-thread/services/evidence-thread.service.ts`) +- **Contract note**: + - Legacy graph/export/transcript helper components still exist in the feature directory, but they are not part of the supported live route because the current EvidenceLocker API does not return graph, transcript, or export resources. - **Source**: Feature matrix scan ## E2E Test Plan - **Setup**: - [ ] Log in with a user that has appropriate permissions - - [ ] Navigate to `/evidence` - - [ ] Ensure test data exists (scanned artifacts, SBOM data, or seed data as needed) + - [ ] Navigate to `/evidence/threads` + - [ ] If positive-path detail data is required, seed or ingest a package URL that exists in EvidenceLocker first - **Core verification**: - - [ ] Verify the component renders correctly with sample data - - [ ] Verify interactive elements respond to user input - - [ ] Verify data is fetched and displayed from the correct API endpoints + - [ ] Verify the list page renders the PURL search form and instruction state + - [ ] Verify searching by PURL issues requests to `/api/v1/evidence/thread?purl=...` + - [ ] Verify empty-result and missing-detail states are handled without runtime errors + - [ ] Verify detail back-navigation preserves the originating `purl` query when present - **Edge cases**: - - [ ] Verify graceful handling when backend API is unavailable (error state) + - [ ] Verify graceful handling when the backend returns `404` for an unknown canonical record - [ ] Verify responsive layout at different viewport sizes - [ ] Verify accessibility (keyboard navigation, screen reader labels, ARIA attributes) ## Verification - Run: docs/qa/feature-checks/runs/web/evidence-thread-browser/run-001/ -- Tier 0 (source): pass ( ier0-source-check.json) -- Tier 1 (build/tests): pass ( ier1-build-check.json) -- Tier 2 (behavior): pass ( ier2-e2e-check.json) +- Tier 0 (source): pass (`tier0-source-check.json`) +- Tier 1 (build/tests): pass (`tier1-build-check.json`) +- Tier 2 (behavior): pass (`tier2-e2e-check.json`) - Verified on (UTC): 2026-02-10 ## Recheck (run-003) @@ -49,3 +51,16 @@ Browse and inspect evidence threads per artifact digest. List view shows all evi - Status: VERIFIED (strict Tier 2 UI replay) - Tier 2 evidence: docs/qa/feature-checks/runs/web/evidence-thread-browser/run-003/tier2-ui-check.json. +## Recheck (2026-03-08 live frontdoor) +- Date (UTC): 2026-03-08T20:49:05Z +- Status: VERIFIED for the shipped EvidenceLocker contract on `https://stella-ops.local` +- Live evidence: + - `src/Web/StellaOps.Web/output/playwright/live-frontdoor-auth-report.json` + - `src/Web/StellaOps.Web/output/playwright/live-frontdoor-changed-surfaces.json` +- Live behavior confirmed: + - `/evidence/threads` renders the PURL search workflow without console/runtime failures + - Searching `pkg:npm/example@1.0.0` produces the expected empty-result state + - `/evidence/threads/missing-demo-canonical?purl=pkg:npm/example@1.0.0` renders the expected missing-detail state and `Back to Search` returns to the scoped search view +- Environment note: + - The compose demo stack currently has no seeded EvidenceLocker thread rows, so live verification covers the supported empty-result and missing-detail flows. Positive-path detail normalization remains covered by the focused frontend tests for the route and service. + diff --git a/src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs b/src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs index 51bb1df58..1ce2f5751 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs @@ -668,6 +668,11 @@ public static class StellaOpsScopes /// public const string IntegrationOperate = "integration:operate"; + /// + /// Scope granting administrative access to registry plan and audit surfaces. + /// + public const string RegistryAdmin = "registry.admin"; + private static readonly IReadOnlyList AllScopes = BuildAllScopes(); private static readonly HashSet KnownScopes = new(AllScopes, StringComparer.OrdinalIgnoreCase); diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Bootstrap/StandardPluginBootstrapper.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Bootstrap/StandardPluginBootstrapper.cs index 04721be47..bb3db1496 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Bootstrap/StandardPluginBootstrapper.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Bootstrap/StandardPluginBootstrapper.cs @@ -36,14 +36,24 @@ internal sealed class StandardPluginBootstrapper : IHostedService { using var scope = scopeFactory.CreateScope(); var optionsMonitor = scope.ServiceProvider.GetRequiredService>(); - var credentialStore = scope.ServiceProvider.GetRequiredService(); - var options = optionsMonitor.Get(pluginName); + var tenantId = options.TenantId ?? DefaultTenantId; + + try + { + await EnsureBootstrapClientsAsync(scope.ServiceProvider, tenantId, options.BootstrapClients, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "Standard Authority plugin '{PluginName}' failed to ensure bootstrap clients.", pluginName); + } + if (options.BootstrapUser is null || !options.BootstrapUser.IsConfigured) { return; } + var credentialStore = scope.ServiceProvider.GetRequiredService(); logger.LogInformation("Standard Authority plugin '{PluginName}' ensuring bootstrap user.", pluginName); try { @@ -54,7 +64,6 @@ internal sealed class StandardPluginBootstrapper : IHostedService logger.LogError(ex, "Standard Authority plugin '{PluginName}' failed to ensure bootstrap user.", pluginName); } - var tenantId = options.TenantId ?? DefaultTenantId; var bootstrapRoles = options.BootstrapUser.Roles ?? new[] { "admin" }; try @@ -171,5 +180,38 @@ internal sealed class StandardPluginBootstrapper : IHostedService } } + private async Task EnsureBootstrapClientsAsync( + IServiceProvider services, + string tenantId, + IReadOnlyCollection bootstrapClients, + CancellationToken cancellationToken) + { + if (bootstrapClients.Count == 0) + { + return; + } + + var clientProvisioningStore = services.GetRequiredService(); + foreach (var bootstrapClient in bootstrapClients) + { + var registration = bootstrapClient.ToRegistration(tenantId); + var result = await clientProvisioningStore.CreateOrUpdateAsync(registration, cancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + logger.LogWarning( + "Standard Authority plugin '{PluginName}' failed to ensure bootstrap client '{ClientId}': {Message}", + pluginName, + registration.ClientId, + result.Message ?? result.ErrorCode ?? "unknown_error"); + continue; + } + + logger.LogInformation( + "Standard Authority plugin '{PluginName}' ensured bootstrap client '{ClientId}'.", + pluginName, + registration.ClientId); + } + } + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/StandardPluginOptions.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/StandardPluginOptions.cs index cea81e588..792203a4e 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/StandardPluginOptions.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/StandardPluginOptions.cs @@ -1,7 +1,10 @@ - +using StellaOps.Auth.Abstractions; +using StellaOps.Authority.Plugins.Abstractions; using StellaOps.Cryptography; using System; +using System.Collections.Generic; using System.IO; +using System.Linq; namespace StellaOps.Authority.Plugin.Standard; @@ -11,6 +14,8 @@ internal sealed class StandardPluginOptions public BootstrapUserOptions? BootstrapUser { get; set; } + public BootstrapClientOptions[] BootstrapClients { get; set; } = Array.Empty(); + public PasswordPolicyOptions PasswordPolicy { get; set; } = new(); public LockoutOptions Lockout { get; set; } = new(); @@ -23,12 +28,25 @@ internal sealed class StandardPluginOptions { TenantId = NormalizeTenantId(TenantId); BootstrapUser?.Normalize(); + BootstrapClients = BootstrapClients ?? Array.Empty(); + + foreach (var client in BootstrapClients) + { + client.Normalize(); + } + TokenSigning.Normalize(configPath); } public void Validate(string pluginName) { BootstrapUser?.Validate(pluginName); + + foreach (var client in BootstrapClients) + { + client.Validate(pluginName); + } + PasswordPolicy.Validate(pluginName); Lockout.Validate(pluginName); PasswordHashing.Validate(); @@ -44,6 +62,231 @@ internal sealed class StandardPluginOptions => string.IsNullOrWhiteSpace(tenantId) ? null : tenantId.Trim().ToLowerInvariant(); } +internal sealed class BootstrapClientOptions +{ + public string? ClientId { get; set; } + + public string? DisplayName { get; set; } + + public bool Confidential { get; set; } + + public string? ClientSecret { get; set; } + + public string? AllowedGrantTypes { get; set; } + + public string? AllowedScopes { get; set; } + + public string? AllowedAudiences { get; set; } + + public string? RedirectUris { get; set; } + + public string? PostLogoutRedirectUris { get; set; } + + public string? TenantId { get; set; } + + public string? Project { get; set; } + + public string? SenderConstraint { get; set; } + + public bool Enabled { get; set; } = true; + + public bool RequirePkce { get; set; } = true; + + public bool AllowPlainTextPkce { get; set; } + + public void Normalize() + { + ClientId = NormalizeOptional(ClientId); + DisplayName = NormalizeOptional(DisplayName); + ClientSecret = NormalizeSecret(ClientSecret); + AllowedGrantTypes = NormalizeJoinedValues(AllowedGrantTypes); + AllowedScopes = NormalizeJoinedScopes(AllowedScopes); + AllowedAudiences = NormalizeJoinedValues(AllowedAudiences); + RedirectUris = NormalizeJoinedValues(RedirectUris); + PostLogoutRedirectUris = NormalizeJoinedValues(PostLogoutRedirectUris); + TenantId = NormalizeTenantId(TenantId); + Project = NormalizeProject(Project); + SenderConstraint = NormalizeSenderConstraint(SenderConstraint); + } + + public void Validate(string pluginName) + { + if (string.IsNullOrWhiteSpace(ClientId)) + { + throw new InvalidOperationException( + $"Standard plugin '{pluginName}' requires bootstrapClients.clientId."); + } + + if (Confidential && string.IsNullOrWhiteSpace(ClientSecret)) + { + throw new InvalidOperationException( + $"Standard plugin '{pluginName}' bootstrap client '{ClientId}' requires clientSecret when confidential=true."); + } + + var grantTypes = SplitValues(AllowedGrantTypes); + if (grantTypes.Length == 0) + { + throw new InvalidOperationException( + $"Standard plugin '{pluginName}' bootstrap client '{ClientId}' requires at least one allowed grant type."); + } + + var scopes = SplitValues(AllowedScopes); + if (scopes.Length == 0) + { + throw new InvalidOperationException( + $"Standard plugin '{pluginName}' bootstrap client '{ClientId}' requires at least one allowed scope."); + } + + var redirectUris = ParseUris(RedirectUris, pluginName, ClientId!, "redirectUris"); + _ = ParseUris(PostLogoutRedirectUris, pluginName, ClientId!, "postLogoutRedirectUris"); + + if (grantTypes.Contains("authorization_code", StringComparer.OrdinalIgnoreCase) && redirectUris.Count == 0) + { + throw new InvalidOperationException( + $"Standard plugin '{pluginName}' bootstrap client '{ClientId}' requires at least one redirect URI for authorization_code."); + } + + if (AllowPlainTextPkce && !RequirePkce) + { + throw new InvalidOperationException( + $"Standard plugin '{pluginName}' bootstrap client '{ClientId}' cannot allow plain-text PKCE when PKCE is disabled."); + } + + if (!string.IsNullOrWhiteSpace(SenderConstraint) && + !string.Equals(SenderConstraint, "dpop", StringComparison.Ordinal) && + !string.Equals(SenderConstraint, "mtls", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Standard plugin '{pluginName}' bootstrap client '{ClientId}' must use senderConstraint 'dpop' or 'mtls' when configured."); + } + } + + public AuthorityClientRegistration ToRegistration(string defaultTenantId) + { + var properties = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [StandardClientMetadataKeys.Enabled] = Enabled ? "true" : "false", + [StandardClientMetadataKeys.RequirePkce] = RequirePkce ? "true" : "false", + [StandardClientMetadataKeys.AllowPlainTextPkce] = AllowPlainTextPkce ? "true" : "false", + }; + + if (!string.IsNullOrWhiteSpace(SenderConstraint)) + { + properties[AuthorityClientMetadataKeys.SenderConstraint] = SenderConstraint; + } + + return new AuthorityClientRegistration( + clientId: ClientId!, + confidential: Confidential, + displayName: DisplayName ?? ClientId, + clientSecret: ClientSecret, + allowedGrantTypes: SplitValues(AllowedGrantTypes), + allowedScopes: SplitValues(AllowedScopes), + allowedAudiences: SplitValues(AllowedAudiences), + redirectUris: ParseUris(RedirectUris, pluginName: null, ClientId!, "redirectUris"), + postLogoutRedirectUris: ParseUris(PostLogoutRedirectUris, pluginName: null, ClientId!, "postLogoutRedirectUris"), + tenant: TenantId ?? defaultTenantId, + project: Project ?? StellaOpsTenancyDefaults.AnyProject, + properties: properties); + } + + private static string? NormalizeOptional(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string? NormalizeSecret(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string? NormalizeTenantId(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); + + private static string? NormalizeProject(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); + + private static string? NormalizeSenderConstraint(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return value.Trim().ToLowerInvariant(); + } + + private static string? NormalizeJoinedValues(string? raw) + { + var values = SplitValues(raw); + if (values.Length == 0) + { + return null; + } + + return string.Join(" ", values.OrderBy(static value => value, StringComparer.Ordinal)); + } + + private static string? NormalizeJoinedScopes(string? raw) + { + var values = SplitValues(raw) + .Select(StellaOpsScopes.Normalize) + .Where(static value => value is not null) + .Select(static value => value!) + .Distinct(StringComparer.Ordinal) + .OrderBy(static value => value, StringComparer.Ordinal) + .ToArray(); + + return values.Length == 0 ? null : string.Join(" ", values); + } + + private static string[] SplitValues(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return Array.Empty(); + } + + return raw + .Split([' ', ',', ';', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + private static IReadOnlyCollection ParseUris(string? raw, string? pluginName, string clientId, string propertyName) + { + var values = SplitValues(raw); + if (values.Length == 0) + { + return Array.Empty(); + } + + var uris = new List(values.Length); + foreach (var value in values) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) + { + if (!string.IsNullOrWhiteSpace(pluginName)) + { + throw new InvalidOperationException( + $"Standard plugin '{pluginName}' bootstrap client '{clientId}' requires absolute URIs in {propertyName}."); + } + + throw new InvalidOperationException( + $"Bootstrap client '{clientId}' requires absolute URIs in {propertyName}."); + } + + uris.Add(uri); + } + + return uris; + } +} + +internal static class StandardClientMetadataKeys +{ + public const string Enabled = "enabled"; + public const string RequirePkce = "requirePkce"; + public const string AllowPlainTextPkce = "allowPlainTextPkce"; +} + internal sealed class BootstrapUserOptions { public string? Username { get; set; } diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Storage/StandardClientProvisioningStore.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Storage/StandardClientProvisioningStore.cs index 46a53ad58..521d15d3f 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Storage/StandardClientProvisioningStore.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Storage/StandardClientProvisioningStore.cs @@ -1,4 +1,4 @@ - +using StellaOps.Auth.Abstractions; using StellaOps.Authority.Persistence.Documents; using StellaOps.Authority.Persistence.InMemory.Stores; using StellaOps.Authority.Plugins.Abstractions; @@ -40,78 +40,7 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore var document = await clientStore.FindByClientIdAsync(registration.ClientId, cancellationToken).ConfigureAwait(false) ?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = clock.GetUtcNow() }; - document.Plugin = pluginName; - document.ClientType = registration.Confidential ? "confidential" : "public"; - document.DisplayName = registration.DisplayName; - document.SecretHash = registration.Confidential && registration.ClientSecret is not null - ? AuthoritySecretHasher.ComputeHash(registration.ClientSecret) - : null; - document.UpdatedAt = clock.GetUtcNow(); - - document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList(); - document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList(); - - document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = JoinValues(registration.AllowedGrantTypes); - document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = JoinValues(registration.AllowedScopes); - document.Properties[AuthorityClientMetadataKeys.Audiences] = JoinValues(registration.AllowedAudiences); - document.Properties[AuthorityClientMetadataKeys.RedirectUris] = string.Join(" ", document.RedirectUris); - document.Properties[AuthorityClientMetadataKeys.PostLogoutRedirectUris] = string.Join(" ", document.PostLogoutRedirectUris); - - if (registration.CertificateBindings is not null) - { - var now = clock.GetUtcNow(); - document.CertificateBindings = registration.CertificateBindings - .Select(binding => MapCertificateBinding(binding, now)) - .OrderBy(binding => binding.Thumbprint, StringComparer.Ordinal) - .ToList(); - } - - foreach (var (key, value) in registration.Properties) - { - document.Properties[key] = value; - } - - var normalizedTenant = NormalizeTenant(registration.Tenant); - var normalizedTenants = NormalizeTenants( - registration.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenants, out var tenantAssignments) ? tenantAssignments : null, - normalizedTenant); - - if (normalizedTenants.Count > 0) - { - document.Properties[AuthorityClientMetadataKeys.Tenants] = string.Join(" ", normalizedTenants); - } - else - { - document.Properties.Remove(AuthorityClientMetadataKeys.Tenants); - } - - if (normalizedTenant is not null) - { - document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenant; - } - else if (normalizedTenants.Count == 1) - { - document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenants[0]; - } - else - { - document.Properties.Remove(AuthorityClientMetadataKeys.Tenant); - } - - if (registration.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var senderConstraintRaw)) - { - var normalizedConstraint = NormalizeSenderConstraint(senderConstraintRaw); - if (normalizedConstraint is not null) - { - document.SenderConstraint = normalizedConstraint; - document.Properties[AuthorityClientMetadataKeys.SenderConstraint] = normalizedConstraint; - } - else - { - document.SenderConstraint = null; - document.Properties.Remove(AuthorityClientMetadataKeys.SenderConstraint); - } - } + ApplyRegistration(document, registration); await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false); await revocationStore.RemoveAsync("client", registration.ClientId, cancellationToken).ConfigureAwait(false); @@ -163,6 +92,86 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore return AuthorityPluginOperationResult.Success(); } + private void ApplyRegistration(AuthorityClientDocument document, AuthorityClientRegistration registration) + { + document.Plugin = pluginName; + document.ClientType = registration.Confidential ? "confidential" : "public"; + document.DisplayName = registration.DisplayName; + document.ClientSecret = registration.Confidential ? registration.ClientSecret : null; + document.SecretHash = registration.Confidential && registration.ClientSecret is not null + ? AuthoritySecretHasher.ComputeHash(registration.ClientSecret) + : null; + document.Enabled = ParseBoolean(registration.Properties, StandardClientMetadataKeys.Enabled) ?? true; + document.Disabled = !document.Enabled; + document.RequireClientSecret = registration.Confidential; + document.RequirePkce = ParseBoolean(registration.Properties, StandardClientMetadataKeys.RequirePkce) ?? document.RequirePkce; + document.AllowPlainTextPkce = ParseBoolean(registration.Properties, StandardClientMetadataKeys.AllowPlainTextPkce) ?? document.AllowPlainTextPkce; + document.UpdatedAt = clock.GetUtcNow(); + + document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList(); + document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList(); + document.AllowedGrantTypes = registration.AllowedGrantTypes.OrderBy(static value => value, StringComparer.Ordinal).ToList(); + document.AllowedScopes = registration.AllowedScopes.OrderBy(static value => value, StringComparer.Ordinal).ToList(); + + document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = JoinValues(registration.AllowedGrantTypes); + document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = JoinValues(registration.AllowedScopes); + document.Properties[AuthorityClientMetadataKeys.Audiences] = JoinValues(registration.AllowedAudiences); + document.Properties[AuthorityClientMetadataKeys.RedirectUris] = string.Join(" ", document.RedirectUris); + document.Properties[AuthorityClientMetadataKeys.PostLogoutRedirectUris] = string.Join(" ", document.PostLogoutRedirectUris); + document.Properties[AuthorityClientMetadataKeys.Project] = registration.Project ?? StellaOpsTenancyDefaults.AnyProject; + + foreach (var (key, value) in registration.Properties) + { + document.Properties[key] = value; + } + + var normalizedTenant = NormalizeTenant(registration.Tenant); + var normalizedTenants = NormalizeTenants( + registration.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenants, out var tenantAssignments) ? tenantAssignments : null, + normalizedTenant); + + if (normalizedTenants.Count > 0) + { + document.Properties[AuthorityClientMetadataKeys.Tenants] = string.Join(" ", normalizedTenants); + } + else + { + document.Properties.Remove(AuthorityClientMetadataKeys.Tenants); + } + + if (normalizedTenant is not null) + { + document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenant; + } + else if (normalizedTenants.Count == 1) + { + document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenants[0]; + } + else + { + document.Properties.Remove(AuthorityClientMetadataKeys.Tenant); + } + + if (registration.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var senderConstraintRaw)) + { + var normalizedConstraint = NormalizeSenderConstraint(senderConstraintRaw); + if (normalizedConstraint is not null) + { + document.SenderConstraint = normalizedConstraint; + document.Properties[AuthorityClientMetadataKeys.SenderConstraint] = normalizedConstraint; + } + else + { + document.SenderConstraint = null; + document.Properties.Remove(AuthorityClientMetadataKeys.SenderConstraint); + } + } + + document.CertificateBindings = registration.CertificateBindings.Count == 0 + ? new List() + : registration.CertificateBindings.Select(binding => MapCertificateBinding(binding, clock.GetUtcNow())).ToList(); + } + private static AuthorityClientDescriptor ToDescriptor(AuthorityClientDocument document) { var allowedGrantTypes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes); @@ -250,6 +259,16 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore .ToArray(); } + private static bool? ParseBoolean(IReadOnlyDictionary properties, string key) + { + if (!properties.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return bool.TryParse(value, out var parsed) ? parsed : null; + } + private static AuthorityClientCertificateBinding MapCertificateBinding( AuthorityClientCertificateBindingRegistration registration, DateTimeOffset now) diff --git a/src/Authority/__Libraries/StellaOps.Authority.Persistence/Migrations/S001_demo_seed.sql b/src/Authority/__Libraries/StellaOps.Authority.Persistence/Migrations/S001_demo_seed.sql index 7496a2cfc..0beb18572 100644 --- a/src/Authority/__Libraries/StellaOps.Authority.Persistence/Migrations/S001_demo_seed.sql +++ b/src/Authority/__Libraries/StellaOps.Authority.Persistence/Migrations/S001_demo_seed.sql @@ -100,7 +100,7 @@ VALUES 'ui.preferences.read', 'ui.preferences.write', 'doctor:run', 'doctor:admin', 'ops.health', - 'integration:read', 'integration:write', 'integration:operate', + 'integration:read', 'integration:write', 'integration:operate', 'registry.admin', 'advisory-ai:view', 'advisory-ai:operate', 'timeline:read', 'timeline:write'], ARRAY['authorization_code', 'refresh_token'], diff --git a/src/Router/StellaOps.Gateway.WebService/appsettings.json b/src/Router/StellaOps.Gateway.WebService/appsettings.json index 0fb1a6245..ba301959a 100644 --- a/src/Router/StellaOps.Gateway.WebService/appsettings.json +++ b/src/Router/StellaOps.Gateway.WebService/appsettings.json @@ -130,6 +130,7 @@ { "Type": "ReverseProxy", "Path": "/api/sbomservice", "TranslatesTo": "http://sbomservice.stella-ops.local/api/sbomservice" }, { "Type": "ReverseProxy", "Path": "/api/vuln-explorer", "TranslatesTo": "http://vulnexplorer.stella-ops.local/api/vuln-explorer" }, { "Type": "ReverseProxy", "Path": "/api/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/vex" }, + { "Type": "ReverseProxy", "Path": "/api/admin/plans", "TranslatesTo": "http://registry-token.stella-ops.local/api/admin/plans", "PreserveAuthHeaders": true }, { "Type": "ReverseProxy", "Path": "/api/admin", "TranslatesTo": "http://platform.stella-ops.local/api/admin" }, { "Type": "ReverseProxy", "Path": "/api", "TranslatesTo": "http://platform.stella-ops.local/api" }, { "Type": "ReverseProxy", "Path": "/platform", "TranslatesTo": "http://platform.stella-ops.local/platform" }, diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts index 72656e3ae..a690c05f7 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts @@ -2,160 +2,107 @@ // Copyright (c) StellaOps. Licensed under the BUSL-1.1. // +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; -import { of, BehaviorSubject } from 'rxjs'; +import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router'; +import { BehaviorSubject, of } from 'rxjs'; import { EvidenceThreadViewComponent } from '../components/evidence-thread-view/evidence-thread-view.component'; -import { EvidenceThreadService, EvidenceThreadGraph, EvidenceNode } from '../services/evidence-thread.service'; +import { EvidenceThreadService } from '../services/evidence-thread.service'; describe('EvidenceThreadViewComponent', () => { let component: EvidenceThreadViewComponent; let fixture: ComponentFixture; - let mockEvidenceService: jasmine.SpyObj; - let routeParams$: BehaviorSubject; + let router: Router; + let routeParamMap$: BehaviorSubject>; + let queryParamMap$: BehaviorSubject>; + let evidenceServiceStub: any; - const mockThread: EvidenceThreadGraph = { - thread: { - id: 'thread-1', - tenantId: 'tenant-1', - artifactDigest: 'sha256:abc123', - artifactName: 'test-image:latest', - status: 'active', - verdict: 'allow', - riskScore: 2.5, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z' + const mockThread = { + canonicalId: 'canon-1', + format: 'dsse-envelope', + artifactDigest: 'sha256:artifact-1', + purl: 'pkg:oci/acme/api@sha256:abc123', + createdAt: '2026-03-08T09:00:00Z', + transparencyStatus: { + mode: 'rekor', + reason: 'entry-confirmed', }, - nodes: [ + attestations: [ { - id: 'node-1', - tenantId: 'tenant-1', - threadId: 'thread-1', - kind: 'sbom_diff', - refId: 'ref-1', - title: 'SBOM Comparison', - anchors: [], - content: {}, - createdAt: '2024-01-01T00:00:00Z' - } + predicateType: 'https://slsa.dev/provenance/v1', + dsseDigest: 'sha256:dsse-1', + signerKeyId: 'signer-1', + rekorEntryId: 'entry-1', + signedAt: '2026-03-08T09:05:00Z', + }, ], - links: [] }; beforeEach(async () => { - routeParams$ = new BehaviorSubject({ artifactDigest: 'sha256:abc123' }); + routeParamMap$ = new BehaviorSubject(convertToParamMap({ canonicalId: 'canon-1' })); + queryParamMap$ = new BehaviorSubject( + convertToParamMap({ purl: 'pkg:oci/acme/api@sha256:abc123' }) + ); - mockEvidenceService = jasmine.createSpyObj('EvidenceThreadService', [ - 'getThreadByDigest', - 'clearCurrentThread', - 'getVerdictColor', - 'getNodeKindLabel', - 'getNodeKindIcon' - ], { - currentThread: jasmine.createSpy().and.returnValue(mockThread), - loading: jasmine.createSpy().and.returnValue(false), - error: jasmine.createSpy().and.returnValue(null), - currentNodes: jasmine.createSpy().and.returnValue(mockThread.nodes), - currentLinks: jasmine.createSpy().and.returnValue([]), - nodesByKind: jasmine.createSpy().and.returnValue({ sbom_diff: mockThread.nodes }) - }); - - mockEvidenceService.getThreadByDigest.and.returnValue(of(mockThread)); - mockEvidenceService.getVerdictColor.and.returnValue('success'); + evidenceServiceStub = { + currentThread: signal(mockThread), + loading: signal(false), + error: signal(null), + getThreadByCanonicalId: jasmine + .createSpy('getThreadByCanonicalId') + .and.returnValue(of(mockThread)), + clearCurrentThread: jasmine.createSpy('clearCurrentThread'), + }; await TestBed.configureTestingModule({ - imports: [ - EvidenceThreadViewComponent, - NoopAnimationsModule - ], + imports: [EvidenceThreadViewComponent], providers: [ provideRouter([]), - { provide: EvidenceThreadService, useValue: mockEvidenceService }, + { provide: EvidenceThreadService, useValue: evidenceServiceStub }, { provide: ActivatedRoute, useValue: { - params: routeParams$.asObservable() - } - } - ] + paramMap: routeParamMap$.asObservable(), + queryParamMap: queryParamMap$.asObservable(), + }, + }, + ], }).compileComponents(); + router = TestBed.inject(Router); + spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + fixture = TestBed.createComponent(EvidenceThreadViewComponent); component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should load thread on init', () => { fixture.detectChanges(); - expect(mockEvidenceService.getThreadByDigest).toHaveBeenCalledWith('sha256:abc123'); }); - it('should set artifact digest from route params', () => { - fixture.detectChanges(); - expect(component.artifactDigest()).toBe('sha256:abc123'); + it('loads the canonical record selected by the route parameter', () => { + expect(component.canonicalId()).toBe('canon-1'); + expect(component.thread()?.canonicalId).toBe('canon-1'); + expect(evidenceServiceStub.getThreadByCanonicalId).toHaveBeenCalledWith('canon-1'); }); - it('should clear thread on destroy', () => { - fixture.detectChanges(); - component.ngOnDestroy(); - expect(mockEvidenceService.clearCurrentThread).toHaveBeenCalled(); + it('preserves the current PURL lookup when navigating back to the list', () => { + component.onBack(); + + expect(router.navigate).toHaveBeenCalledWith(['/evidence/threads'], { + queryParams: { purl: 'pkg:oci/acme/api@sha256:abc123' }, + }); }); - it('should refresh thread when onRefresh is called', () => { - fixture.detectChanges(); - mockEvidenceService.getThreadByDigest.calls.reset(); + it('reloads the same canonical record when refreshed', () => { + evidenceServiceStub.getThreadByCanonicalId.calls.reset(); component.onRefresh(); - expect(mockEvidenceService.getThreadByDigest).toHaveBeenCalled(); + + expect(evidenceServiceStub.getThreadByCanonicalId).toHaveBeenCalledWith('canon-1'); }); - it('should update selected tab index', () => { - fixture.detectChanges(); - expect(component.selectedTabIndex()).toBe(0); + it('clears the cached record state on destroy', () => { + component.ngOnDestroy(); - component.onTabChange(1); - expect(component.selectedTabIndex()).toBe(1); - }); - - it('should update selected node ID', () => { - fixture.detectChanges(); - expect(component.selectedNodeId()).toBeNull(); - - component.onNodeSelect('node-1'); - expect(component.selectedNodeId()).toBe('node-1'); - }); - - it('should return correct verdict label', () => { - expect(component.getVerdictLabel('allow')).toBe('Allow'); - expect(component.getVerdictLabel('block')).toBe('Block'); - expect(component.getVerdictLabel(undefined)).toBe('Unknown'); - }); - - it('should return correct verdict icon', () => { - expect(component.getVerdictIcon('allow')).toBe('check_circle'); - expect(component.getVerdictIcon('warn')).toBe('warning'); - expect(component.getVerdictIcon('block')).toBe('block'); - expect(component.getVerdictIcon('pending')).toBe('schedule'); - expect(component.getVerdictIcon('unknown')).toBe('help_outline'); - }); - - it('should set artifact digest for the digest chip', () => { - fixture.detectChanges(); - expect(component.artifactDigest()).toBe('sha256:abc123'); - }); - - it('should compute node count correctly', () => { - fixture.detectChanges(); - expect(component.nodeCount()).toBe(1); - }); - - it('should compute link count correctly', () => { - fixture.detectChanges(); - expect(component.linkCount()).toBe(0); + expect(evidenceServiceStub.clearCurrentThread).toHaveBeenCalled(); }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/__tests__/evidence-thread.service.spec.ts b/src/Web/StellaOps.Web/src/app/features/evidence-thread/__tests__/evidence-thread.service.spec.ts index 924134026..b3d9e4477 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/__tests__/evidence-thread.service.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/__tests__/evidence-thread.service.spec.ts @@ -2,58 +2,19 @@ // Copyright (c) StellaOps. Licensed under the BUSL-1.1. // -import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; -import { - EvidenceThreadService, - EvidenceThread, - EvidenceThreadGraph, - EvidenceNode, - EvidenceTranscript -} from '../services/evidence-thread.service'; +import { EvidenceThreadService } from '../services/evidence-thread.service'; describe('EvidenceThreadService', () => { let service: EvidenceThreadService; let httpMock: HttpTestingController; - const mockThread: EvidenceThread = { - id: 'thread-1', - tenantId: 'tenant-1', - artifactDigest: 'sha256:abc123', - artifactName: 'test-image', - status: 'active', - verdict: 'allow', - riskScore: 2.5, - reachabilityMode: 'unreachable', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z' - }; - - const mockNode: EvidenceNode = { - id: 'node-1', - tenantId: 'tenant-1', - threadId: 'thread-1', - kind: 'sbom_diff', - refId: 'ref-1', - title: 'SBOM Comparison', - summary: 'Test summary', - confidence: 0.95, - anchors: [], - content: {}, - createdAt: '2024-01-01T00:00:00Z' - }; - - const mockGraph: EvidenceThreadGraph = { - thread: mockThread, - nodes: [mockNode], - links: [] - }; - beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], - providers: [EvidenceThreadService] + providers: [EvidenceThreadService], }); service = TestBed.inject(EvidenceThreadService); @@ -64,247 +25,127 @@ describe('EvidenceThreadService', () => { httpMock.verify(); }); - it('should be created', () => { - expect(service).toBeTruthy(); - }); + it('fetches canonical thread summaries by PURL', () => { + let actualCount = 0; - describe('getThreads', () => { - it('should fetch threads with default parameters', () => { - const mockResponse = { - items: [mockThread], + service.getThreads({ purl: 'pkg:oci/acme/api@sha256:abc123' }).subscribe((response) => { + actualCount = response.threads.length; + expect(response.pagination.total).toBe(1); + expect(response.threads[0]).toEqual({ + canonicalId: 'canon-1', + format: 'dsse-envelope', + purl: 'pkg:oci/acme/api@sha256:abc123', + attestationCount: 2, + createdAt: '2026-03-08T09:00:00Z', + }); + }); + + const request = httpMock.expectOne( + (candidate) => + candidate.url === '/api/v1/evidence/thread/' && + candidate.params.get('purl') === 'pkg:oci/acme/api@sha256:abc123' + ); + + expect(request.request.method).toBe('GET'); + expect(service.loading()).toBeTrue(); + + request.flush({ + threads: [ + { + canonical_id: 'canon-1', + format: 'dsse-envelope', + purl: 'pkg:oci/acme/api@sha256:abc123', + attestation_count: 2, + created_at: '2026-03-08T09:00:00Z', + }, + ], + pagination: { total: 1, - page: 1, - pageSize: 20 - }; - - service.getThreads().subscribe(response => { - expect(response.items.length).toBe(1); - expect(response.items[0].id).toBe('thread-1'); - }); - - const req = httpMock.expectOne('/api/v1/evidence'); - expect(req.request.method).toBe('GET'); - req.flush(mockResponse); + limit: 25, + offset: 0, + }, }); - it('should include filter parameters in request', () => { - const mockResponse = { - items: [], - total: 0, - page: 1, - pageSize: 20 - }; - - service.getThreads({ - status: 'active', - verdict: 'allow', - page: 2, - pageSize: 50 - }).subscribe(); - - const req = httpMock.expectOne(request => { - return request.url === '/api/v1/evidence' && - request.params.get('status') === 'active' && - request.params.get('verdict') === 'allow' && - request.params.get('page') === '2' && - request.params.get('pageSize') === '50'; - }); - req.flush(mockResponse); - }); - - it('should update loading state', () => { - const mockResponse = { items: [], total: 0, page: 1, pageSize: 20 }; - - expect(service.loading()).toBe(false); - - service.getThreads().subscribe(); - - // Loading should be true during request - expect(service.loading()).toBe(true); - - httpMock.expectOne('/api/v1/evidence').flush(mockResponse); - - // Loading should be false after response - expect(service.loading()).toBe(false); - }); - - it('should handle errors gracefully', () => { - service.getThreads().subscribe(response => { - expect(response.items.length).toBe(0); - }); - - const req = httpMock.expectOne('/api/v1/evidence'); - req.error(new ErrorEvent('Network error')); - - expect(service.error()).toBeTruthy(); - expect(service.loading()).toBe(false); - }); + expect(actualCount).toBe(1); + expect(service.loading()).toBeFalse(); + expect(service.threads()[0]?.canonicalId).toBe('canon-1'); }); - describe('getThreadByDigest', () => { - it('should fetch thread graph by digest', () => { - const digest = 'sha256:abc123'; + it('returns an empty result without issuing a request when the PURL is blank', () => { + let actualTotal = -1; - service.getThreadByDigest(digest).subscribe(graph => { - expect(graph).toBeTruthy(); - expect(graph?.thread.id).toBe('thread-1'); - expect(graph?.nodes.length).toBe(1); - }); - - const req = httpMock.expectOne(`/api/v1/evidence/${encodeURIComponent(digest)}`); - expect(req.request.method).toBe('GET'); - req.flush(mockGraph); + service.getThreads({ purl: ' ' }).subscribe((response) => { + actualTotal = response.pagination.total; + expect(response.threads).toEqual([]); }); - it('should update current thread state', () => { - const digest = 'sha256:abc123'; - - service.getThreadByDigest(digest).subscribe(); - httpMock.expectOne(`/api/v1/evidence/${encodeURIComponent(digest)}`).flush(mockGraph); - - expect(service.currentThread()).toEqual(mockGraph); - expect(service.currentNodes().length).toBe(1); - }); + expect(actualTotal).toBe(0); + expect(service.threads()).toEqual([]); + expect(httpMock.match(() => true).length).toBe(0); }); - describe('generateTranscript', () => { - it('should generate transcript with options', () => { - const digest = 'sha256:abc123'; - const mockTranscript: EvidenceTranscript = { - id: 'transcript-1', - tenantId: 'tenant-1', - threadId: 'thread-1', - transcriptType: 'summary', - templateVersion: '1.0', - content: 'Test transcript content', - anchors: [], - generatedAt: '2024-01-01T00:00:00Z' - }; - - service.generateTranscript(digest, { - transcriptType: 'summary', - useLlm: true - }).subscribe(result => { - expect(result).toBeTruthy(); - expect(result?.content).toBe('Test transcript content'); - }); - - const req = httpMock.expectOne(`/api/v1/evidence/${encodeURIComponent(digest)}/transcript`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ - transcriptType: 'summary', - useLlm: true - }); - req.flush(mockTranscript); + it('loads a canonical thread by canonical id and normalizes the record', () => { + service.getThreadByCanonicalId('canon-1').subscribe((thread) => { + expect(thread?.canonicalId).toBe('canon-1'); + expect(thread?.attestations.length).toBe(1); + expect(thread?.transparencyStatus?.mode).toBe('rekor'); }); + + const request = httpMock.expectOne('/api/v1/evidence/thread/canon-1'); + expect(request.request.method).toBe('GET'); + + request.flush({ + canonical_id: 'canon-1', + format: 'dsse-envelope', + artifact_digest: 'sha256:artifact-1', + purl: 'pkg:oci/acme/api@sha256:abc123', + created_at: '2026-03-08T09:00:00Z', + transparency_status: { + mode: 'rekor', + reason: 'entry-confirmed', + }, + attestations: [ + { + predicate_type: 'https://slsa.dev/provenance/v1', + dsse_digest: 'sha256:dsse-1', + signer_keyid: 'signer-1', + rekor_entry_id: 'entry-1', + signed_at: '2026-03-08T09:05:00Z', + }, + ], + }); + + expect(service.currentThread()?.canonicalId).toBe('canon-1'); + expect(service.currentNodes()).toEqual([]); + expect(service.currentLinks()).toEqual([]); }); - describe('exportThread', () => { - it('should export thread with signing options', () => { - const digest = 'sha256:abc123'; - const mockExport = { - id: 'export-1', - tenantId: 'tenant-1', - threadId: 'thread-1', - exportFormat: 'dsse', - contentHash: 'sha256:export123', - storagePath: '/exports/export-1.dsse', - createdAt: '2024-01-01T00:00:00Z' - }; + it('fails closed for transcript and export actions that are not supported by the shipped API', () => { + let transcriptResult: unknown = 'pending'; + let exportResult: unknown = 'pending'; - service.exportThread(digest, { - format: 'dsse', - sign: true, - keyRef: 'my-key' - }).subscribe(result => { - expect(result).toBeTruthy(); - expect(result?.exportFormat).toBe('dsse'); - }); - - const req = httpMock.expectOne(`/api/v1/evidence/${encodeURIComponent(digest)}/export`); - expect(req.request.method).toBe('POST'); - expect(req.request.body.sign).toBe(true); - req.flush(mockExport); + service.generateTranscript('canon-1', { transcriptType: 'summary' }).subscribe((value) => { + transcriptResult = value; }); + service.exportThread('canon-1', { format: 'json', sign: false }).subscribe((value) => { + exportResult = value; + }); + + expect(transcriptResult).toBeNull(); + expect(exportResult).toBeNull(); + expect(service.error()).toContain('not supported'); + expect(httpMock.match(() => true).length).toBe(0); }); - describe('helper methods', () => { - it('should return correct node kind labels', () => { - expect(service.getNodeKindLabel('sbom_diff')).toBe('SBOM Diff'); - expect(service.getNodeKindLabel('reachability')).toBe('Reachability'); - expect(service.getNodeKindLabel('vex')).toBe('VEX'); - expect(service.getNodeKindLabel('attestation')).toBe('Attestation'); + it('surfaces not-found detail errors with a stable message', () => { + service.getThreadByCanonicalId('missing-canon').subscribe((thread) => { + expect(thread).toBeNull(); }); - it('should return correct node kind icons', () => { - expect(service.getNodeKindIcon('sbom_diff')).toBe('compare_arrows'); - expect(service.getNodeKindIcon('reachability')).toBe('route'); - expect(service.getNodeKindIcon('vex')).toBe('security'); - }); + const request = httpMock.expectOne('/api/v1/evidence/thread/missing-canon'); + request.flush({ error: 'missing' }, { status: 404, statusText: 'Not Found' }); - it('should return correct verdict colors', () => { - expect(service.getVerdictColor('allow')).toBe('success'); - expect(service.getVerdictColor('warn')).toBe('warning'); - expect(service.getVerdictColor('block')).toBe('error'); - expect(service.getVerdictColor('pending')).toBe('info'); - expect(service.getVerdictColor('unknown')).toBe('neutral'); - expect(service.getVerdictColor(undefined)).toBe('neutral'); - }); - - it('should return correct link relation labels', () => { - expect(service.getLinkRelationLabel('supports')).toBe('Supports'); - expect(service.getLinkRelationLabel('contradicts')).toBe('Contradicts'); - expect(service.getLinkRelationLabel('derived_from')).toBe('Derived From'); - }); - }); - - describe('computed signals', () => { - it('should compute nodesByKind correctly', () => { - const digest = 'sha256:abc123'; - const graphWithMultipleNodes: EvidenceThreadGraph = { - thread: mockThread, - nodes: [ - { ...mockNode, id: 'node-1', kind: 'sbom_diff' }, - { ...mockNode, id: 'node-2', kind: 'vex' }, - { ...mockNode, id: 'node-3', kind: 'sbom_diff' } - ], - links: [] - }; - - service.getThreadByDigest(digest).subscribe(); - httpMock.expectOne(`/api/v1/evidence/${encodeURIComponent(digest)}`).flush(graphWithMultipleNodes); - - const byKind = service.nodesByKind(); - expect(byKind['sbom_diff']?.length).toBe(2); - expect(byKind['vex']?.length).toBe(1); - }); - }); - - describe('clearCurrentThread', () => { - it('should clear current thread state', () => { - const digest = 'sha256:abc123'; - - service.getThreadByDigest(digest).subscribe(); - httpMock.expectOne(`/api/v1/evidence/${encodeURIComponent(digest)}`).flush(mockGraph); - - expect(service.currentThread()).toBeTruthy(); - - service.clearCurrentThread(); - - expect(service.currentThread()).toBeNull(); - }); - }); - - describe('clearError', () => { - it('should clear error state', () => { - service.getThreads().subscribe(); - httpMock.expectOne('/api/v1/evidence').error(new ErrorEvent('Error')); - - expect(service.error()).toBeTruthy(); - - service.clearError(); - - expect(service.error()).toBeNull(); - }); + expect(service.currentThread()).toBeNull(); + expect(service.error()).toBe('missing'); }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.html b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.html index fc9b1d638..2167d476c 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.html +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.html @@ -1,10 +1,10 @@ -
-

Evidence Threads

-

View and manage evidence chains for your artifacts

+

+ Search canonical evidence records by package URL against the shipped EvidenceLocker API. +

-
- Search artifacts + Package URL + placeholder="pkg:oci/acme/api@sha256:..."> @if (searchQuery) { - } - - Status - - @for (option of statusOptions; track option.value) { - {{ option.label }} - } - - - - - Verdict - - @for (option of verdictOptions; track option.value) { - {{ option.label }} - } - - -
+

+ EvidenceLocker lists threads only for a specific PURL. Enter the exact package URL you want to inspect. +

- @if (loading()) {
@@ -64,7 +47,6 @@
} - @if (error() && !loading()) {
@@ -75,76 +57,59 @@
} - @if (!loading() && !error()) { - @if (threads().length === 0) { + @if (!searchedPurl()) { +
+ +

Enter a package URL to search for canonical evidence records.

+

Example: pkg:oci/stellaops/api@sha256:...

+
+ } @else if (threads().length === 0) {
-

No evidence threads found

-

Evidence threads are created when artifacts are scanned and evaluated.

+

No evidence threads matched this package URL.

+

Try the exact PURL stored by EvidenceLocker for the artifact you are investigating.

} @else { - - - + + - - - + + - - - + + - - - + + - - - + + -
ArtifactCanonical ID
- {{ thread.artifactName ?? 'Unnamed' }} - + {{ thread.canonicalId }}
VerdictFormat - - - {{ thread.verdict ?? 'Unknown' | titlecase }} - + {{ thread.format }} StatusPURL - - - {{ thread.status | titlecase }} - + {{ thread.purl ?? '-' }} Risk ScoreAttestations - @if (thread.riskScore !== undefined && thread.riskScore !== null) { - - {{ thread.riskScore | number:'1.1-1' }} - - } @else { - - - } + {{ formatCount(thread.attestationCount) }} Last UpdatedCreated - {{ formatDate(thread.updatedAt) }} + {{ formatDate(thread.createdAt) }} @@ -162,15 +127,6 @@ class="clickable-row">
- - - }
} diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.scss b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.scss index 6f11f9050..d5d9970d5 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.scss @@ -1,17 +1,11 @@ @use 'tokens/breakpoints' as *; -/** - * Evidence Thread List Component Styles - * Migrated to design system tokens - */ - .evidence-thread-list { padding: var(--space-6); max-width: 1400px; margin: 0 auto; } -// Header .list-header { display: flex; justify-content: space-between; @@ -34,7 +28,6 @@ } } -// Filters .filters-card { margin-bottom: var(--space-6); @@ -48,81 +41,42 @@ flex: 1; min-width: 250px; } - - .filter-field { - min-width: 150px; - } } } -// Loading state -.loading-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: var(--space-16) var(--space-6); - gap: var(--space-4); - - p { - color: var(--color-text-muted); - margin: 0; - } +.filters-hint { + margin: var(--space-3) 0 0; + color: var(--color-text-muted); + font-size: var(--font-size-sm); } -// Error state -.error-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: var(--space-16) var(--space-6); - gap: var(--space-4); - - mat-icon { - font-size: 48px; - width: 48px; - height: 48px; - color: var(--color-status-error); - } - - p { - color: var(--color-status-error); - margin: 0; - text-align: center; - max-width: 400px; - } -} - -// Empty state +.loading-container, +.error-container, .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--space-16) var(--space-6); + gap: var(--space-4); text-align: center; - mat-icon { - font-size: 64px; - width: 64px; - height: 64px; - color: var(--color-text-muted); - opacity: 0.3; - margin-bottom: var(--space-4); - } - p { color: var(--color-text-muted); - margin: 0 0 var(--space-2) 0; + margin: 0; + } - &.hint { - font-size: var(--font-size-sm); - } + .hint { + font-size: var(--font-size-sm); + } +} + +.error-container { + p { + color: var(--color-status-error); } } -// Table .table-card { overflow: hidden; } @@ -137,11 +91,6 @@ &:hover { background: var(--color-surface-secondary); } - - &:focus-visible { - outline: 2px solid var(--color-brand-primary); - outline-offset: -2px; - } } .artifact-cell { @@ -149,115 +98,24 @@ display: block; font-weight: var(--font-weight-medium); color: var(--color-text-primary); - } - - .artifact-digest { - display: block; - font-family: var(--font-family-mono); - font-size: var(--font-size-xs); - color: var(--color-text-muted); - margin-top: 2px; + word-break: break-word; } } } -// Verdict chips -.verdict-success { - --mat-chip-elevated-container-color: var(--color-status-success-bg); - --mat-chip-label-text-color: var(--color-status-success); -} - -.verdict-warning { - --mat-chip-elevated-container-color: var(--color-status-warning-bg); - --mat-chip-label-text-color: var(--color-status-warning); -} - -.verdict-error { - --mat-chip-elevated-container-color: var(--color-status-error-bg); - --mat-chip-label-text-color: var(--color-status-error); -} - -.verdict-info { - --mat-chip-elevated-container-color: var(--color-status-info-bg); - --mat-chip-label-text-color: var(--color-status-info); -} - -.verdict-neutral { - --mat-chip-elevated-container-color: var(--color-surface-tertiary); - --mat-chip-label-text-color: var(--color-text-muted); -} - -// Status badges -.status-badge { - display: inline-flex; - align-items: center; - gap: var(--space-1); +.cell-code { + font-family: var(--font-family-mono); font-size: var(--font-size-xs); - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-sm); - - mat-icon { - font-size: 14px; - width: 14px; - height: 14px; - } - - &.status-active { - background: var(--color-status-success-bg); - color: var(--color-status-success); - } - - &.status-archived { - background: var(--color-surface-tertiary); - color: var(--color-text-muted); - } - - &.status-exported { - background: var(--color-status-info-bg); - color: var(--color-status-info); - } -} - -// Risk scores -.risk-score { - font-weight: var(--font-weight-medium); - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-sm); - - &.risk-critical { - background: var(--color-status-error-bg); - color: var(--color-status-error); - } - - &.risk-high { - background: var(--color-status-warning-bg); - color: var(--color-status-warning); - } - - &.risk-medium { - background: var(--color-severity-medium-bg); - color: var(--color-severity-medium); - } - - &.risk-low { - background: var(--color-status-success-bg); - color: var(--color-status-success); - } -} - -.no-score { color: var(--color-text-muted); + word-break: break-word; } -/* High contrast mode */ @media (prefers-contrast: high) { - .status-badge, - .risk-score { + .cell-code { border: 1px solid currentColor; } } -/* Reduced motion */ @media (prefers-reduced-motion: reduce) { .clickable-row { transition: none; diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.ts index 397ea0438..4c4f3061f 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.ts @@ -2,31 +2,30 @@ // Copyright (c) StellaOps. Licensed under the BUSL-1.1. // -import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + OnInit, + inject, + signal, +} from '@angular/core'; import { CommonModule } from '@angular/common'; -import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; -import { Router, RouterModule } from '@angular/router'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { MatTableModule } from '@angular/material/table'; -import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; -import { MatSortModule, Sort } from '@angular/material/sort'; import { MatInputModule } from '@angular/material/input'; import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; import { MatButtonModule } from '@angular/material/button'; -import { MatChipsModule } from '@angular/material/chips'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatCardModule } from '@angular/material/card'; +import { Subject, takeUntil } from 'rxjs'; import { - EvidenceThread, EvidenceThreadService, - EvidenceThreadStatus, - EvidenceVerdict, - EvidenceThreadFilter + EvidenceThreadSummary, } from '../../services/evidence-thread.service'; -import { DigestChipComponent } from '../../../../shared/domain/digest-chip/digest-chip.component'; @Component({ selector: 'stella-evidence-thread-list', @@ -36,170 +35,103 @@ import { DigestChipComponent } from '../../../../shared/domain/digest-chip/diges RouterModule, FormsModule, MatTableModule, - MatPaginatorModule, - MatSortModule, MatInputModule, MatFormFieldModule, - MatSelectModule, MatButtonModule, - MatChipsModule, MatProgressSpinnerModule, MatTooltipModule, MatCardModule, - DigestChipComponent ], templateUrl: './evidence-thread-list.component.html', styleUrls: ['./evidence-thread-list.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class EvidenceThreadListComponent implements OnInit { +export class EvidenceThreadListComponent implements OnInit, OnDestroy { private readonly router = inject(Router); - private readonly sanitizer = inject(DomSanitizer); + private readonly route = inject(ActivatedRoute); readonly evidenceService = inject(EvidenceThreadService); - private readonly verdictIconSvgMap: Record = { - check_circle: '', - warning: '', - block: '', - schedule: '', - help_outline: '', - }; + private readonly destroy$ = new Subject(); - private readonly statusIconSvgMap: Record = { - play_circle: '', - archive: '', - cloud_done: '', - }; - - getVerdictIconSvg(verdict?: EvidenceVerdict): SafeHtml { - const iconName = this.getVerdictIcon(verdict); - return this.sanitizer.bypassSecurityTrustHtml(this.verdictIconSvgMap[iconName] || this.verdictIconSvgMap['help_outline']); - } - - getStatusIconSvg(status: EvidenceThreadStatus): SafeHtml { - const iconName = this.getStatusIcon(status); - return this.sanitizer.bypassSecurityTrustHtml(this.statusIconSvgMap[iconName] || this.statusIconSvgMap['play_circle']); - } - - readonly displayedColumns = ['artifactName', 'verdict', 'status', 'riskScore', 'updatedAt', 'actions']; + readonly displayedColumns = [ + 'canonicalId', + 'format', + 'purl', + 'attestationCount', + 'createdAt', + 'actions', + ]; readonly threads = this.evidenceService.threads; readonly loading = this.evidenceService.loading; readonly error = this.evidenceService.error; + readonly searchedPurl = signal(null); - // Pagination - readonly totalItems = signal(0); - readonly pageSize = signal(20); - readonly pageIndex = signal(0); - - // Filters searchQuery = ''; - statusFilter: EvidenceThreadStatus | '' = ''; - verdictFilter: EvidenceVerdict | '' = ''; - - readonly statusOptions: { value: EvidenceThreadStatus | ''; label: string }[] = [ - { value: '', label: 'All Statuses' }, - { value: 'active', label: 'Active' }, - { value: 'archived', label: 'Archived' }, - { value: 'exported', label: 'Exported' } - ]; - - readonly verdictOptions: { value: EvidenceVerdict | ''; label: string }[] = [ - { value: '', label: 'All Verdicts' }, - { value: 'allow', label: 'Allow' }, - { value: 'warn', label: 'Warn' }, - { value: 'block', label: 'Block' }, - { value: 'pending', label: 'Pending' }, - { value: 'unknown', label: 'Unknown' } - ]; ngOnInit(): void { - this.loadThreads(); + this.route.queryParamMap + .pipe(takeUntil(this.destroy$)) + .subscribe((queryParams) => { + const purl = queryParams.get('purl')?.trim() ?? ''; + this.searchQuery = purl; + this.searchedPurl.set(purl || null); + this.loadThreads(purl); + }); } - loadThreads(): void { - const filter: EvidenceThreadFilter = { - page: this.pageIndex() + 1, - pageSize: this.pageSize() - }; - - if (this.statusFilter) { - filter.status = this.statusFilter; - } - if (this.verdictFilter) { - filter.verdict = this.verdictFilter; - } - if (this.searchQuery) { - filter.artifactName = this.searchQuery; - } - - this.evidenceService.getThreads(filter).subscribe(response => { - this.totalItems.set(response.total); - }); + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); } onSearch(): void { - this.pageIndex.set(0); - this.loadThreads(); - } - - onFilterChange(): void { - this.pageIndex.set(0); - this.loadThreads(); - } - - onPageChange(event: PageEvent): void { - this.pageIndex.set(event.pageIndex); - this.pageSize.set(event.pageSize); - this.loadThreads(); - } - - onRowClick(thread: EvidenceThread): void { - const encodedDigest = encodeURIComponent(thread.artifactDigest); - this.router.navigate(['/evidence/threads', encodedDigest]); - } - - onRefresh(): void { - this.loadThreads(); - } - - getVerdictColor(verdict?: EvidenceVerdict): string { - return this.evidenceService.getVerdictColor(verdict); - } - - getVerdictIcon(verdict?: EvidenceVerdict): string { - const icons: Record = { - allow: 'check_circle', - warn: 'warning', - block: 'block', - pending: 'schedule', - unknown: 'help_outline' - }; - return icons[verdict ?? 'unknown'] ?? 'help_outline'; - } - - getStatusIcon(status: EvidenceThreadStatus): string { - const icons: Record = { - active: 'play_circle', - archived: 'archive', - exported: 'cloud_done' - }; - return icons[status] ?? 'help_outline'; - } - - formatDate(dateStr: string): string { - const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric' + const purl = this.searchQuery.trim(); + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { purl: purl || null }, + queryParamsHandling: 'merge', }); } - getRiskClass(riskScore: number): string { - if (riskScore >= 7) return 'risk-critical'; - if (riskScore >= 4) return 'risk-high'; - if (riskScore >= 2) return 'risk-medium'; - return 'risk-low'; + onClearSearch(): void { + this.searchQuery = ''; + this.onSearch(); + } + + onRowClick(thread: EvidenceThreadSummary): void { + const purl = this.searchedPurl(); + void this.router.navigate(['/evidence/threads', encodeURIComponent(thread.canonicalId)], { + queryParams: purl ? { purl } : {}, + }); + } + + onRefresh(): void { + this.loadThreads(this.searchedPurl() ?? this.searchQuery); + } + + formatDate(value: string): string { + if (!value) { + return '-'; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } + + formatCount(count: number): string { + return `${count} attestation${count === 1 ? '' : 's'}`; + } + + private loadThreads(purl: string): void { + this.evidenceService.getThreads({ purl }).subscribe(); } } diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.html b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.html index aa5c3ebfe..704a947c3 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.html +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.html @@ -1,169 +1,146 @@ -
-
-
-

- @if (thread()?.thread?.artifactName) { - {{ thread()?.thread?.artifactName }} - } @else { - {{ 'ui.evidence_thread.title_default' | translate }} - } -

+

{{ thread()?.purl ?? canonicalId() }}

- + {{ canonicalId() }}
- @if (thread()?.thread) { + @if (thread()) { - - - {{ getVerdictLabel(thread()?.thread?.verdict) }} + + {{ thread()?.format | uppercase }} - - @if (thread()?.thread?.riskScore !== undefined && thread()?.thread?.riskScore !== null) { + + {{ attestationCount() }} attestations + + @if (thread()?.transparencyStatus?.mode) { - - {{ 'ui.evidence_thread.risk_label' | translate }} {{ thread()?.thread?.riskScore | number:'1.1-1' }} + {{ formatTransparencyMode(thread()?.transparencyStatus?.mode) }} transparency } - - - - {{ nodeCount() }} {{ 'ui.evidence_thread.nodes' | translate }} - } -
- - - -
+
- @if (loading()) {
-

{{ 'ui.evidence_thread.loading' | translate }}

+

Loading evidence thread...

} - @if (error() && !loading()) {

{{ error() }}

+
} - @if (thread() && !loading()) {
- - + +

Record Summary

+
+
+
Canonical ID
+
{{ thread()?.canonicalId }}
+
+
+
Format
+
{{ thread()?.format }}
+
+
+
Created
+
{{ formatDate(thread()?.createdAt ?? '') }}
+
+
+
Package URL
+
{{ thread()?.purl ?? '-' }}
+
+
+
Artifact Digest
+
+ @if (thread()?.artifactDigest) { + + } @else { + - + } +
+
+
+
Transparency
+
+ @if (thread()?.transparencyStatus?.mode) { + {{ formatTransparencyMode(thread()?.transparencyStatus?.mode) }} + @if (thread()?.transparencyStatus?.reason) { + ({{ thread()?.transparencyStatus?.reason }}) + } + } @else { + - + } +
+
+
+
- - - - - {{ 'ui.evidence_thread.graph_tab' | translate }} - - -
- - -
-
-
- - - - - - {{ 'ui.evidence_thread.timeline_tab' | translate }} - - -
- - -
-
-
- - - - - - {{ 'ui.evidence_thread.transcript_tab' | translate }} - - -
- - -
-
-
-
- - - @if (selectedNodeId()) { - - } + +

Attestations

+ @if (thread()?.attestations?.length === 0) { +
+

No attestations are currently attached to this canonical record.

+
+ } @else { + + + + + + + + + + + + @for (attestation of thread()?.attestations ?? []; track trackAttestation($index, attestation)) { + + + + + + + + } + +
Predicate TypeSignedDSSE DigestSignerRekor Entry
{{ attestation.predicateType }}{{ formatDate(attestation.signedAt) }}{{ attestation.dsseDigest }}{{ attestation.signerKeyId ?? '-' }}{{ attestation.rekorEntryId ?? '-' }}
+ } +
} - @if (!thread() && !loading() && !error()) {
-

{{ 'ui.evidence_thread.not_found' | translate }}

+

No evidence thread is selected.

} diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.scss b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.scss index a3d6ad195..93c69bbfe 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.scss @@ -1,18 +1,11 @@ @use 'tokens/breakpoints' as *; -/** - * Evidence Thread View Component Styles - * Migrated to design system tokens - */ - .evidence-thread-view { - display: flex; - flex-direction: column; - height: 100%; - min-height: 0; + display: grid; + gap: var(--space-4); + min-height: 100%; } -// Header .thread-header { display: flex; justify-content: space-between; @@ -41,15 +34,13 @@ font-weight: var(--font-weight-medium); margin: 0; color: var(--color-text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + word-break: break-word; } .thread-digest { display: flex; align-items: center; - gap: var(--space-1); + gap: var(--space-2); margin-top: var(--space-1); code { @@ -59,18 +50,7 @@ background: var(--color-surface-tertiary); padding: var(--space-0-5) var(--space-2); border-radius: var(--radius-sm); - } - - .copy-btn { - width: 24px; - height: 24px; - line-height: 24px; - - mat-icon { - font-size: 14px; - width: 14px; - height: 14px; - } + word-break: break-word; } } @@ -80,80 +60,10 @@ gap: var(--space-4); flex-wrap: wrap; } - - .header-actions { - display: flex; - align-items: center; - gap: var(--space-2); - } } -// Verdict chips styling -.verdict-success { - --mat-chip-elevated-container-color: var(--color-status-success-bg); - --mat-chip-label-text-color: var(--color-status-success); -} - -.verdict-warning { - --mat-chip-elevated-container-color: var(--color-status-warning-bg); - --mat-chip-label-text-color: var(--color-status-warning); -} - -.verdict-error { - --mat-chip-elevated-container-color: var(--color-status-error-bg); - --mat-chip-label-text-color: var(--color-status-error); -} - -.verdict-info { - --mat-chip-elevated-container-color: var(--color-status-info-bg); - --mat-chip-label-text-color: var(--color-status-info); -} - -.verdict-neutral { - --mat-chip-elevated-container-color: var(--color-surface-tertiary); - --mat-chip-label-text-color: var(--color-text-muted); -} - -// Loading state -.loading-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: var(--space-16) var(--space-6); - gap: var(--space-4); - - p { - color: var(--color-text-muted); - margin: 0; - } -} - -// Error state -.error-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: var(--space-16) var(--space-6); - gap: var(--space-4); - - mat-icon { - font-size: 48px; - width: 48px; - height: 48px; - color: var(--color-status-error); - } - - p { - color: var(--color-status-error); - margin: 0; - text-align: center; - max-width: 400px; - } -} - -// Empty state +.loading-container, +.error-container, .empty-container { display: flex; flex-direction: column; @@ -162,96 +72,91 @@ padding: var(--space-16) var(--space-6); gap: var(--space-4); - mat-icon { - font-size: 64px; - width: 64px; - height: 64px; - color: var(--color-text-muted); - opacity: 0.5; - } - p { color: var(--color-text-muted); margin: 0; + text-align: center; + } + + &.compact { + align-items: flex-start; + justify-content: flex-start; + padding: var(--space-6) 0; + } +} + +.error-container { + p { + color: var(--color-status-error); } } -// Content area .thread-content { - display: flex; - flex: 1; - min-height: 0; - overflow: hidden; + display: grid; + gap: var(--space-4); + padding: 0 var(--space-6) var(--space-6); +} - mat-tab-group { - flex: 1; - display: flex; - flex-direction: column; +.summary-card { + padding: var(--space-2); +} - ::ng-deep .mat-mdc-tab-body-wrapper { - flex: 1; - } +.summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: var(--space-4); + + dt { + color: var(--color-text-muted); + font-size: var(--font-size-sm); + margin-bottom: var(--space-1); } - .tab-content { - height: 100%; - padding: var(--space-4); - overflow: auto; + dd { + margin: 0; + color: var(--color-text-primary); + word-break: break-word; } } -// Tab label styling -::ng-deep .mat-mdc-tab { - .mat-mdc-tab-label-content { - display: flex; - align-items: center; - gap: var(--space-2); +.attestations-table { + width: 100%; + border-collapse: collapse; - mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - } + th, + td { + padding: var(--space-3); + text-align: left; + border-bottom: 1px solid var(--color-border-primary); + vertical-align: top; + } + + th { + color: var(--color-text-muted); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + } + + code { + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + word-break: break-word; } } -// Node detail side panel -.node-detail-panel { - width: 400px; - max-width: 40%; - border-left: 1px solid var(--color-border-primary); - background: var(--color-surface-primary); - overflow-y: auto; - padding: var(--space-4); - - @include screen-below-md { - position: fixed; - top: 0; - right: 0; - bottom: 0; - width: 100%; - max-width: 100%; - z-index: 100; - box-shadow: var(--shadow-xl); - } +.transparency-reason { + color: var(--color-text-muted); + font-size: var(--font-size-sm); } -/* High contrast mode */ -@media (prefers-contrast: high) { - .thread-header { - border-bottom-width: 2px; +@include screen-below-md { + .thread-content { + padding-left: var(--space-4); + padding-right: var(--space-4); } - .node-detail-panel { - border-left-width: 2px; - } -} - -/* Reduced motion */ -@media (prefers-reduced-motion: reduce) { - .evidence-thread-view *, - .thread-header *, - .node-detail-panel * { - transition: none !important; + .attestations-table { + display: block; + overflow-x: auto; } } diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.ts index 10a894fc1..b54c2f54d 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.ts @@ -2,27 +2,28 @@ // Copyright (c) StellaOps. Licensed under the BUSL-1.1. // -import { Component, OnInit, OnDestroy, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + OnInit, + computed, + inject, + signal, +} from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { MatTabsModule } from '@angular/material/tabs'; -import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; import { MatChipsModule } from '@angular/material/chips'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { Subject, takeUntil } from 'rxjs'; -import { EvidenceThreadService, EvidenceThreadGraph, EvidenceVerdict } from '../../services/evidence-thread.service'; -import { EvidenceGraphPanelComponent } from '../evidence-graph-panel/evidence-graph-panel.component'; -import { EvidenceTimelinePanelComponent } from '../evidence-timeline-panel/evidence-timeline-panel.component'; -import { EvidenceTranscriptPanelComponent } from '../evidence-transcript-panel/evidence-transcript-panel.component'; -import { EvidenceNodeCardComponent } from '../evidence-node-card/evidence-node-card.component'; -import { EvidenceExportDialogComponent } from '../evidence-export-dialog/evidence-export-dialog.component'; -import { TranslatePipe } from '../../../../core/i18n/translate.pipe'; +import { + EvidenceThreadAttestation, + EvidenceThreadService, +} from '../../services/evidence-thread.service'; import { DigestChipComponent } from '../../../../shared/domain/digest-chip/digest-chip.component'; @Component({ @@ -31,75 +32,45 @@ import { DigestChipComponent } from '../../../../shared/domain/digest-chip/diges imports: [ CommonModule, RouterModule, - MatTabsModule, MatButtonModule, + MatCardModule, MatChipsModule, MatProgressSpinnerModule, MatTooltipModule, - MatMenuModule, - MatSnackBarModule, - MatDialogModule, - EvidenceGraphPanelComponent, - EvidenceTimelinePanelComponent, - EvidenceTranscriptPanelComponent, - EvidenceNodeCardComponent, - TranslatePipe, - DigestChipComponent + DigestChipComponent, ], templateUrl: './evidence-thread-view.component.html', styleUrls: ['./evidence-thread-view.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, }) export class EvidenceThreadViewComponent implements OnInit, OnDestroy { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly snackBar = inject(MatSnackBar); - private readonly dialog = inject(MatDialog); readonly evidenceService = inject(EvidenceThreadService); - private readonly sanitizer = inject(DomSanitizer); - - private readonly verdictIconSvgMap: Record = { - check_circle: '', - warning: '', - block: '', - schedule: '', - help_outline: '', - }; - - getVerdictIconSvg(verdict?: EvidenceVerdict): SafeHtml { - const icon = this.getVerdictIcon(verdict); - return this.sanitizer.bypassSecurityTrustHtml(this.verdictIconSvgMap[icon] || this.verdictIconSvgMap['help_outline']); - } - private readonly destroy$ = new Subject(); - readonly artifactDigest = signal(''); - readonly selectedTabIndex = signal(0); - readonly selectedNodeId = signal(null); + readonly canonicalId = signal(''); + readonly returnPurl = signal(null); readonly thread = this.evidenceService.currentThread; readonly loading = this.evidenceService.loading; readonly error = this.evidenceService.error; - readonly nodes = this.evidenceService.currentNodes; - readonly links = this.evidenceService.currentLinks; - readonly nodesByKind = this.evidenceService.nodesByKind; - - readonly verdictClass = computed(() => { - const verdict = this.thread()?.thread?.verdict; - return this.evidenceService.getVerdictColor(verdict); - }); - - readonly nodeCount = computed(() => this.nodes().length); - readonly linkCount = computed(() => this.links().length); + readonly attestationCount = computed(() => this.thread()?.attestations.length ?? 0); ngOnInit(): void { - this.route.params.pipe(takeUntil(this.destroy$)).subscribe(params => { - const digest = params['artifactDigest']; - if (digest) { - this.artifactDigest.set(decodeURIComponent(digest)); - this.loadThread(); + this.route.paramMap.pipe(takeUntil(this.destroy$)).subscribe((params) => { + const canonicalId = params.get('canonicalId'); + if (!canonicalId) { + return; } + + this.canonicalId.set(decodeURIComponent(canonicalId)); + this.loadThread(); + }); + + this.route.queryParamMap.pipe(takeUntil(this.destroy$)).subscribe((params) => { + this.returnPurl.set(params.get('purl')); }); } @@ -109,72 +80,57 @@ export class EvidenceThreadViewComponent implements OnInit, OnDestroy { this.evidenceService.clearCurrentThread(); } - private loadThread(): void { - const digest = this.artifactDigest(); - if (!digest) return; - - this.evidenceService.getThreadByDigest(digest) - .pipe(takeUntil(this.destroy$)) - .subscribe({ - error: () => { - this.snackBar.open('Failed to load evidence thread', 'Dismiss', { - duration: 5000 - }); - } - }); - } - onRefresh(): void { this.loadThread(); } - onTabChange(index: number): void { - this.selectedTabIndex.set(index); - } - - onNodeSelect(nodeId: string): void { - this.selectedNodeId.set(nodeId); - } - - onExport(): void { - const thread = this.thread(); - if (!thread) return; - - const dialogRef = this.dialog.open(EvidenceExportDialogComponent, { - width: '500px', - data: { - artifactDigest: this.artifactDigest(), - thread: thread.thread - } - }); - - dialogRef.afterClosed().subscribe(result => { - if (result?.success) { - this.snackBar.open('Export started successfully', 'Dismiss', { - duration: 3000 - }); - } - }); - } - onBack(): void { - this.router.navigate(['/evidence/threads']); + const purl = this.returnPurl(); + void this.router.navigate(['/evidence/threads'], { + queryParams: purl ? { purl } : {}, + }); } - getVerdictLabel(verdict?: EvidenceVerdict): string { - if (!verdict) return 'Unknown'; - return verdict.charAt(0).toUpperCase() + verdict.slice(1); + formatDate(value: string): string { + if (!value) { + return '-'; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); } - getVerdictIcon(verdict?: EvidenceVerdict): string { - const icons: Record = { - allow: 'check_circle', - warn: 'warning', - block: 'block', - pending: 'schedule', - unknown: 'help_outline' - }; - return icons[verdict ?? 'unknown'] ?? 'help_outline'; + formatTransparencyMode(mode?: string): string { + if (!mode) { + return 'Unknown'; + } + + return mode.charAt(0).toUpperCase() + mode.slice(1); } + trackAttestation(_index: number, attestation: EvidenceThreadAttestation): string { + return `${attestation.dsseDigest}:${attestation.predicateType}`; + } + + private loadThread(): void { + const canonicalId = this.canonicalId(); + if (!canonicalId) { + return; + } + + this.evidenceService + .getThreadByCanonicalId(canonicalId) + .pipe(takeUntil(this.destroy$)) + .subscribe(); + } } diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/services/evidence-thread.service.ts b/src/Web/StellaOps.Web/src/app/features/evidence-thread/services/evidence-thread.service.ts index eda20e659..3f7f2c4bf 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/services/evidence-thread.service.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/services/evidence-thread.service.ts @@ -2,32 +2,74 @@ // Copyright (c) StellaOps. Licensed under the BUSL-1.1. // -import { Injectable, inject, signal, computed } from '@angular/core'; -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Observable, catchError, tap, of, BehaviorSubject, map } from 'rxjs'; +import { Injectable, computed, inject, signal } from '@angular/core'; +import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http'; +import { Observable, catchError, map, of, tap } from 'rxjs'; -// Evidence Thread Models export type EvidenceThreadStatus = 'active' | 'archived' | 'exported'; export type EvidenceVerdict = 'allow' | 'warn' | 'block' | 'pending' | 'unknown'; -export type ReachabilityMode = 'exploitable' | 'likely_exploitable' | 'possibly_exploitable' | 'unreachable' | 'unknown'; -export type EvidenceNodeKind = 'sbom_diff' | 'reachability' | 'vex' | 'attestation' | 'policy_eval' | 'runtime_observation' | 'patch_verification' | 'approval' | 'ai_rationale'; -export type EvidenceLinkRelation = 'supports' | 'contradicts' | 'precedes' | 'triggers' | 'derived_from' | 'references'; +export type ReachabilityMode = + | 'exploitable' + | 'likely_exploitable' + | 'possibly_exploitable' + | 'unreachable' + | 'unknown'; +export type EvidenceNodeKind = + | 'sbom_diff' + | 'reachability' + | 'vex' + | 'attestation' + | 'policy_eval' + | 'runtime_observation' + | 'patch_verification' + | 'approval' + | 'ai_rationale'; +export type EvidenceLinkRelation = + | 'supports' + | 'contradicts' + | 'precedes' + | 'triggers' + | 'derived_from' + | 'references'; export type TranscriptType = 'summary' | 'detailed' | 'audit'; export type ExportFormat = 'dsse' | 'json' | 'pdf' | 'markdown'; +export interface EvidenceThreadAttestation { + predicateType: string; + dsseDigest: string; + signerKeyId?: string; + rekorEntryId?: string; + rekorTile?: string; + signedAt: string; +} + +export interface EvidenceThreadTransparencyStatus { + mode: string; + reason?: string; +} + export interface EvidenceThread { - id: string; - tenantId: string; - artifactDigest: string; - artifactName?: string; - status: EvidenceThreadStatus; - verdict?: EvidenceVerdict; - riskScore?: number; - reachabilityMode?: ReachabilityMode; - knowledgeSnapshotHash?: string; - engineVersion?: string; + canonicalId: string; + format: string; + artifactDigest?: string; + purl?: string; + attestations: EvidenceThreadAttestation[]; + transparencyStatus?: EvidenceThreadTransparencyStatus; createdAt: string; - updatedAt: string; +} + +export interface EvidenceThreadSummary { + canonicalId: string; + format: string; + purl?: string; + attestationCount: number; + createdAt: string; +} + +export interface EvidencePaginationInfo { + total: number; + limit: number; + offset: number; } export interface EvidenceAnchor { @@ -100,10 +142,8 @@ export interface EvidenceThreadGraph { } export interface EvidenceThreadListResponse { - items: EvidenceThread[]; - total: number; - page: number; - pageSize: number; + threads: EvidenceThreadSummary[]; + pagination: EvidencePaginationInfo; } export interface TranscriptRequest { @@ -118,194 +158,217 @@ export interface ExportRequest { } export interface EvidenceThreadFilter { - status?: EvidenceThreadStatus; - verdict?: EvidenceVerdict; - artifactName?: string; - page?: number; - pageSize?: number; + purl?: string; +} + +interface EvidenceThreadListApiResponse { + threads?: EvidenceThreadSummaryApiModel[]; + pagination?: EvidencePaginationApiModel; +} + +interface EvidenceThreadSummaryApiModel { + canonical_id?: string; + format?: string; + purl?: string; + attestation_count?: number; + created_at?: string; +} + +interface EvidencePaginationApiModel { + total?: number; + limit?: number; + offset?: number; +} + +interface EvidenceThreadApiModel { + canonical_id?: string; + format?: string; + artifact_digest?: string; + purl?: string; + attestations?: EvidenceThreadAttestationApiModel[]; + transparency_status?: EvidenceThreadTransparencyStatusApiModel; + created_at?: string; +} + +interface EvidenceThreadAttestationApiModel { + predicate_type?: string; + dsse_digest?: string; + signer_keyid?: string; + rekor_entry_id?: string; + rekor_tile?: string; + signed_at?: string; +} + +interface EvidenceThreadTransparencyStatusApiModel { + mode?: string; + reason?: string; +} + +function emptyListResponse(): EvidenceThreadListResponse { + return { + threads: [], + pagination: { + total: 0, + limit: 0, + offset: 0, + }, + }; } -/** - * Service for managing Evidence Threads. - * Provides API integration and local state management for evidence thread operations. - */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class EvidenceThreadService { private readonly httpClient = inject(HttpClient); - private readonly apiBase = '/api/v1/evidence'; + private readonly apiBase = '/api/v1/evidence/thread'; - // Local state signals - private readonly _currentThread = signal(null); - private readonly _threads = signal([]); - private readonly _loading = signal(false); + private readonly _currentThread = signal(null); + private readonly _threads = signal([]); + private readonly _loading = signal(false); private readonly _error = signal(null); + private readonly _currentNodes = signal([]); + private readonly _currentLinks = signal([]); - // Public computed signals readonly currentThread = this._currentThread.asReadonly(); readonly threads = this._threads.asReadonly(); readonly loading = this._loading.asReadonly(); readonly error = this._error.asReadonly(); - - readonly currentNodes = computed(() => this._currentThread()?.nodes ?? []); - readonly currentLinks = computed(() => this._currentThread()?.links ?? []); + readonly currentNodes = this._currentNodes.asReadonly(); + readonly currentLinks = this._currentLinks.asReadonly(); readonly nodesByKind = computed(() => { - const nodes = this.currentNodes(); + const nodes = this._currentNodes(); return nodes.reduce((acc, node) => { if (!acc[node.kind]) { acc[node.kind] = []; } + acc[node.kind].push(node); return acc; }, {} as Record); }); - /** - * Fetches a list of evidence threads with optional filtering. - */ getThreads(filter?: EvidenceThreadFilter): Observable { + const purl = filter?.purl?.trim() ?? ''; + this._error.set(null); + + if (!purl) { + const empty = emptyListResponse(); + this._threads.set(empty.threads); + this._loading.set(false); + return of(empty); + } + + this._loading.set(true); + const params = new HttpParams().set('purl', purl); + + return this.httpClient + .get(`${this.apiBase}/`, { params }) + .pipe( + map((response) => this.normalizeListResponse(response)), + tap((response) => { + this._threads.set(response.threads); + this._loading.set(false); + }), + catchError((error) => { + this._threads.set([]); + this._error.set( + this.buildErrorMessage(error, `Failed to load evidence threads for ${purl}.`) + ); + this._loading.set(false); + return of(emptyListResponse()); + }) + ); + } + + getThreadByCanonicalId(canonicalId: string): Observable { + const normalizedCanonicalId = canonicalId.trim(); + if (!normalizedCanonicalId) { + this._currentThread.set(null); + this._currentNodes.set([]); + this._currentLinks.set([]); + return of(null); + } + this._loading.set(true); this._error.set(null); - let params = new HttpParams(); - if (filter?.status) { - params = params.set('status', filter.status); - } - if (filter?.verdict) { - params = params.set('verdict', filter.verdict); - } - if (filter?.artifactName) { - params = params.set('artifactName', filter.artifactName); - } - if (filter?.page !== undefined) { - params = params.set('page', filter.page.toString()); - } - if (filter?.pageSize !== undefined) { - params = params.set('pageSize', filter.pageSize.toString()); - } + return this.httpClient + .get(`${this.apiBase}/${encodeURIComponent(normalizedCanonicalId)}`) + .pipe( + map((response) => this.normalizeThreadResponse(response)), + tap((thread) => { + this._currentThread.set(thread); + this._currentNodes.set([]); + this._currentLinks.set([]); + this._loading.set(false); + }), + catchError((error) => { + this._currentThread.set(null); + this._currentNodes.set([]); + this._currentLinks.set([]); + this._error.set( + this.buildErrorMessage( + error, + `Failed to load evidence thread ${normalizedCanonicalId}.` + ) + ); + this._loading.set(false); + return of(null); + }) + ); + } - return this.httpClient.get(this.apiBase, { params }).pipe( - tap(response => { - this._threads.set(response.items); - this._loading.set(false); - }), - catchError(err => { - this._error.set(err.message ?? 'Failed to fetch evidence threads'); - this._loading.set(false); - return of({ items: [], total: 0, page: 1, pageSize: 20 }); - }) + // Compatibility shim for older revived components/tests. + getThreadByDigest(canonicalId: string): Observable { + return this.getThreadByCanonicalId(canonicalId); + } + + getNodes(_canonicalId: string): Observable { + return of([]); + } + + getLinks(_canonicalId: string): Observable { + return of([]); + } + + generateTranscript( + _canonicalId: string, + _request: TranscriptRequest + ): Observable { + this._error.set( + 'Evidence transcripts are not supported by the current EvidenceLocker thread API.' ); + return of(null); } - /** - * Fetches a single evidence thread by artifact digest, including its full graph. - */ - getThreadByDigest(artifactDigest: string): Observable { - this._loading.set(true); - this._error.set(null); - - const encodedDigest = encodeURIComponent(artifactDigest); - return this.httpClient.get(`${this.apiBase}/${encodedDigest}`).pipe( - tap(graph => { - this._currentThread.set(graph); - this._loading.set(false); - }), - catchError(err => { - this._error.set(err.message ?? 'Failed to fetch evidence thread'); - this._loading.set(false); - return of(null); - }) + exportThread( + _canonicalId: string, + _request: ExportRequest + ): Observable { + this._error.set( + 'Evidence exports are not supported by the current EvidenceLocker thread API.' ); + return of(null); } - /** - * Fetches nodes for a specific thread. - */ - getNodes(artifactDigest: string): Observable { - const encodedDigest = encodeURIComponent(artifactDigest); - return this.httpClient.get(`${this.apiBase}/${encodedDigest}/nodes`).pipe( - catchError(err => { - this._error.set(err.message ?? 'Failed to fetch evidence nodes'); - return of([]); - }) + downloadExport(_exportId: string): Observable { + this._error.set( + 'Evidence exports are not supported by the current EvidenceLocker thread API.' ); + return of(new Blob()); } - /** - * Fetches links for a specific thread. - */ - getLinks(artifactDigest: string): Observable { - const encodedDigest = encodeURIComponent(artifactDigest); - return this.httpClient.get(`${this.apiBase}/${encodedDigest}/links`).pipe( - catchError(err => { - this._error.set(err.message ?? 'Failed to fetch evidence links'); - return of([]); - }) - ); - } - - /** - * Generates a transcript for the evidence thread. - */ - generateTranscript(artifactDigest: string, request: TranscriptRequest): Observable { - const encodedDigest = encodeURIComponent(artifactDigest); - - return this.httpClient.post( - `${this.apiBase}/${encodedDigest}/transcript`, - request - ).pipe( - catchError(err => { - this._error.set(err.message ?? 'Failed to generate transcript'); - return of(null); - }) - ); - } - - /** - * Exports the evidence thread in the specified format. - */ - exportThread(artifactDigest: string, request: ExportRequest): Observable { - const encodedDigest = encodeURIComponent(artifactDigest); - - return this.httpClient.post( - `${this.apiBase}/${encodedDigest}/export`, - request - ).pipe( - catchError(err => { - this._error.set(err.message ?? 'Failed to export evidence thread'); - return of(null); - }) - ); - } - - /** - * Downloads an exported evidence bundle. - */ - downloadExport(exportId: string): Observable { - return this.httpClient.get(`${this.apiBase}/exports/${exportId}/download`, { - responseType: 'blob' - }); - } - - /** - * Clears the current thread from local state. - */ clearCurrentThread(): void { this._currentThread.set(null); + this._currentNodes.set([]); + this._currentLinks.set([]); } - /** - * Clears any error state. - */ clearError(): void { this._error.set(null); } - /** - * Gets the display label for a node kind. - */ getNodeKindLabel(kind: EvidenceNodeKind): string { const labels: Record = { sbom_diff: 'SBOM Diff', @@ -316,14 +379,11 @@ export class EvidenceThreadService { runtime_observation: 'Runtime Observation', patch_verification: 'Patch Verification', approval: 'Approval', - ai_rationale: 'AI Rationale' + ai_rationale: 'AI Rationale', }; return labels[kind] ?? kind; } - /** - * Gets the icon name for a node kind. - */ getNodeKindIcon(kind: EvidenceNodeKind): string { const icons: Record = { sbom_diff: 'compare_arrows', @@ -334,29 +394,26 @@ export class EvidenceThreadService { runtime_observation: 'visibility', patch_verification: 'check_circle', approval: 'thumb_up', - ai_rationale: 'psychology' + ai_rationale: 'psychology', }; return icons[kind] ?? 'help_outline'; } - /** - * Gets the color class for a verdict. - */ getVerdictColor(verdict?: EvidenceVerdict): string { - if (!verdict) return 'neutral'; + if (!verdict) { + return 'neutral'; + } + const colors: Record = { allow: 'success', warn: 'warning', block: 'error', pending: 'info', - unknown: 'neutral' + unknown: 'neutral', }; return colors[verdict] ?? 'neutral'; } - /** - * Gets the display label for a link relation. - */ getLinkRelationLabel(relation: EvidenceLinkRelation): string { const labels: Record = { supports: 'Supports', @@ -364,8 +421,80 @@ export class EvidenceThreadService { precedes: 'Precedes', triggers: 'Triggers', derived_from: 'Derived From', - references: 'References' + references: 'References', }; return labels[relation] ?? relation; } + + private normalizeListResponse( + response: EvidenceThreadListApiResponse | null | undefined + ): EvidenceThreadListResponse { + const threads = (response?.threads ?? []).map((thread) => ({ + canonicalId: thread.canonical_id ?? '', + format: thread.format ?? 'unknown', + purl: thread.purl ?? undefined, + attestationCount: thread.attestation_count ?? 0, + createdAt: thread.created_at ?? '', + })); + + return { + threads, + pagination: { + total: response?.pagination?.total ?? threads.length, + limit: response?.pagination?.limit ?? threads.length, + offset: response?.pagination?.offset ?? 0, + }, + }; + } + + private normalizeThreadResponse(response: EvidenceThreadApiModel): EvidenceThread { + return { + canonicalId: response.canonical_id ?? '', + format: response.format ?? 'unknown', + artifactDigest: response.artifact_digest ?? undefined, + purl: response.purl ?? undefined, + attestations: (response.attestations ?? []).map((attestation) => ({ + predicateType: attestation.predicate_type ?? 'unknown', + dsseDigest: attestation.dsse_digest ?? '', + signerKeyId: attestation.signer_keyid ?? undefined, + rekorEntryId: attestation.rekor_entry_id ?? undefined, + rekorTile: attestation.rekor_tile ?? undefined, + signedAt: attestation.signed_at ?? '', + })), + transparencyStatus: response.transparency_status?.mode + ? { + mode: response.transparency_status.mode, + reason: response.transparency_status.reason ?? undefined, + } + : undefined, + createdAt: response.created_at ?? '', + }; + } + + private buildErrorMessage(error: unknown, fallback: string): string { + if (error instanceof HttpErrorResponse) { + const apiError = + typeof error.error === 'object' && error.error && 'error' in error.error + ? String(error.error.error) + : null; + + if (apiError) { + return apiError; + } + + if (typeof error.error === 'string' && error.error.trim()) { + return error.error.trim(); + } + + if (error.status === 404) { + return 'Evidence thread not found.'; + } + } + + if (error instanceof Error && error.message.trim()) { + return error.message; + } + + return fallback; + } } diff --git a/src/Web/StellaOps.Web/src/app/features/timeline/components/timeline-filter/timeline-filter.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/timeline/components/timeline-filter/timeline-filter.component.spec.ts new file mode 100644 index 000000000..f1a7fb3be --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/timeline/components/timeline-filter/timeline-filter.component.spec.ts @@ -0,0 +1,33 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, provideRouter } from '@angular/router'; +import { of } from 'rxjs'; + +import { TimelineFilterComponent } from './timeline-filter.component'; + +describe('TimelineFilterComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TimelineFilterComponent], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + queryParams: of({}), + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TimelineFilterComponent); + }); + + it('renders the filter form without Angular Material control errors', () => { + expect(() => fixture.detectChanges()).not.toThrow(); + expect( + fixture.nativeElement.querySelector('input[formControlName="fromHlc"]') + ).not.toBeNull(); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/timeline/components/timeline-filter/timeline-filter.component.ts b/src/Web/StellaOps.Web/src/app/features/timeline/components/timeline-filter/timeline-filter.component.ts index 6681ba5a3..9f6128470 100644 --- a/src/Web/StellaOps.Web/src/app/features/timeline/components/timeline-filter/timeline-filter.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/timeline/components/timeline-filter/timeline-filter.component.ts @@ -7,6 +7,7 @@ import { Component, Output, EventEmitter, inject, OnInit, OnDestroy } from '@ang import { FormBuilder, ReactiveFormsModule, FormGroup } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatButtonModule } from '@angular/material/button'; import { MatChipsModule } from '@angular/material/chips'; @@ -28,6 +29,7 @@ import { imports: [ ReactiveFormsModule, MatFormFieldModule, + MatInputModule, MatSelectModule, MatButtonModule, MatChipsModule diff --git a/src/Web/StellaOps.Web/src/tests/evidence/evidence-thread-browser.component.spec.ts b/src/Web/StellaOps.Web/src/tests/evidence/evidence-thread-browser.component.spec.ts index 55bc72669..037fc3082 100644 --- a/src/Web/StellaOps.Web/src/tests/evidence/evidence-thread-browser.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/evidence/evidence-thread-browser.component.spec.ts @@ -1,51 +1,52 @@ import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialog } from '@angular/material/dialog'; -import { ActivatedRoute, Router, provideRouter } from '@angular/router'; +import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router'; import { BehaviorSubject, of } from 'rxjs'; import { EvidenceThreadListComponent } from '../../app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component'; import { EvidenceThreadViewComponent } from '../../app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component'; -import { - EvidenceThread, - EvidenceThreadGraph, - EvidenceThreadService, -} from '../../app/features/evidence-thread/services/evidence-thread.service'; +import { EvidenceThreadService } from '../../app/features/evidence-thread/services/evidence-thread.service'; describe('Evidence thread browser', () => { describe('EvidenceThreadListComponent', () => { let fixture: ComponentFixture; let component: EvidenceThreadListComponent; let router: Router; + let queryParamMap$: BehaviorSubject>; - const thread: EvidenceThread = { - id: 'thread-1', - tenantId: 'tenant-1', - artifactDigest: 'sha256:artifact-1', - artifactName: 'artifact-a', - status: 'active', - verdict: 'allow', - riskScore: 2.2, - createdAt: '2026-02-10T00:00:00Z', - updatedAt: '2026-02-10T00:00:00Z', + const thread = { + canonicalId: 'canon-1', + format: 'dsse-envelope', + purl: 'pkg:oci/acme/api@sha256:abc123', + attestationCount: 2, + createdAt: '2026-03-08T09:00:00Z', }; const listServiceStub = { - threads: signal([thread]), + threads: signal([thread]), loading: signal(false), error: signal(null), getThreads: jasmine .createSpy('getThreads') - .and.returnValue(of({ items: [thread], total: 1, page: 1, pageSize: 20 })), - getVerdictColor: jasmine.createSpy('getVerdictColor').and.returnValue('success'), + .and.returnValue(of({ threads: [thread], pagination: { total: 1, limit: 25, offset: 0 } })), }; beforeEach(async () => { + queryParamMap$ = new BehaviorSubject( + convertToParamMap({ purl: 'pkg:oci/acme/api@sha256:abc123' }) + ); + await TestBed.configureTestingModule({ imports: [EvidenceThreadListComponent], providers: [ provideRouter([]), { provide: EvidenceThreadService, useValue: listServiceStub }, + { + provide: ActivatedRoute, + useValue: { + queryParamMap: queryParamMap$.asObservable(), + }, + }, ], }).compileComponents(); @@ -57,18 +58,23 @@ describe('Evidence thread browser', () => { fixture.detectChanges(); }); - it('loads thread list on init and tracks total rows', () => { - expect(listServiceStub.getThreads).toHaveBeenCalled(); - expect(component.totalItems()).toBe(1); + it('loads thread list from the current PURL query parameter', () => { + expect(component.searchQuery).toBe('pkg:oci/acme/api@sha256:abc123'); expect(component.threads().length).toBe(1); + expect(listServiceStub.getThreads).toHaveBeenCalledWith({ + purl: 'pkg:oci/acme/api@sha256:abc123', + }); }); - it('navigates to encoded thread digest when a row is opened', () => { + it('navigates to the canonical-id detail route and preserves the lookup PURL', () => { component.onRowClick(thread); - expect(router.navigate).toHaveBeenCalledWith([ - '/evidence/threads', - encodeURIComponent('sha256:artifact-1'), - ]); + + expect(router.navigate).toHaveBeenCalledWith( + ['/evidence/threads', encodeURIComponent('canon-1')], + { + queryParams: { purl: 'pkg:oci/acme/api@sha256:abc123' }, + } + ); }); }); @@ -76,79 +82,45 @@ describe('Evidence thread browser', () => { let fixture: ComponentFixture; let component: EvidenceThreadViewComponent; let router: Router; - let routeParams$: BehaviorSubject>; - const graph: EvidenceThreadGraph = { - thread: { - id: 'thread-1', - tenantId: 'tenant-1', - artifactDigest: 'sha256:artifact-1', - artifactName: 'artifact-a', - status: 'active', - verdict: 'allow', - riskScore: 2.2, - createdAt: '2026-02-10T00:00:00Z', - updatedAt: '2026-02-10T00:00:00Z', - }, - nodes: [ + const thread = { + canonicalId: 'canon-1', + format: 'dsse-envelope', + artifactDigest: 'sha256:artifact-1', + purl: 'pkg:oci/acme/api@sha256:abc123', + createdAt: '2026-03-08T09:00:00Z', + attestations: [ { - id: 'node-1', - tenantId: 'tenant-1', - threadId: 'thread-1', - kind: 'sbom_diff', - refId: 'ref-1', - title: 'SBOM', - anchors: [], - content: {}, - createdAt: '2026-02-10T00:00:00Z', + predicateType: 'https://slsa.dev/provenance/v1', + dsseDigest: 'sha256:dsse-1', + signerKeyId: 'signer-1', + rekorEntryId: 'entry-1', + signedAt: '2026-03-08T09:05:00Z', }, ], - links: [], + }; + + const viewServiceStub = { + currentThread: signal(thread), + loading: signal(false), + error: signal(null), + getThreadByCanonicalId: jasmine + .createSpy('getThreadByCanonicalId') + .and.returnValue(of(thread)), + clearCurrentThread: jasmine.createSpy('clearCurrentThread'), }; beforeEach(async () => { - Object.defineProperty(globalThis, 'ResizeObserver', { - configurable: true, - writable: true, - value: class { - observe(): void {} - unobserve(): void {} - disconnect(): void {} - }, - }); - - routeParams$ = new BehaviorSubject>({ - artifactDigest: encodeURIComponent('sha256:artifact-1'), - }); - - const dialogStub = { - open: jasmine.createSpy('open').and.returnValue({ - afterClosed: () => of(null), - }), - }; - - const viewServiceStub = { - currentThread: signal(graph), - loading: signal(false), - error: signal(null), - currentNodes: signal(graph.nodes), - currentLinks: signal(graph.links), - nodesByKind: signal({ sbom_diff: graph.nodes }), - getThreadByDigest: jasmine.createSpy('getThreadByDigest').and.returnValue(of(graph)), - clearCurrentThread: jasmine.createSpy('clearCurrentThread'), - getVerdictColor: jasmine.createSpy('getVerdictColor').and.returnValue('success'), - }; - await TestBed.configureTestingModule({ imports: [EvidenceThreadViewComponent], providers: [ provideRouter([]), { provide: EvidenceThreadService, useValue: viewServiceStub }, - { provide: MatDialog, useValue: dialogStub }, { provide: ActivatedRoute, useValue: { - params: routeParams$.asObservable(), + paramMap: of(convertToParamMap({ canonicalId: 'canon-1' })), + queryParamMap: of(convertToParamMap({ purl: 'pkg:oci/acme/api@sha256:abc123' })), }, }, ], @@ -162,15 +134,18 @@ describe('Evidence thread browser', () => { fixture.detectChanges(); }); - it('decodes route digest and loads thread details', () => { - expect(component.artifactDigest()).toBe('sha256:artifact-1'); - expect(component.thread()?.thread.id).toBe('thread-1'); + it('loads thread details from the canonical-id route parameter', () => { + expect(component.canonicalId()).toBe('canon-1'); + expect(component.thread()?.canonicalId).toBe('canon-1'); + expect(viewServiceStub.getThreadByCanonicalId).toHaveBeenCalledWith('canon-1'); }); - it('navigates back to the canonical evidence threads list', () => { + it('navigates back to the PURL-filtered evidence threads list', () => { component.onBack(); - expect(router.navigate).toHaveBeenCalledWith(['/evidence/threads']); + expect(router.navigate).toHaveBeenCalledWith(['/evidence/threads'], { + queryParams: { purl: 'pkg:oci/acme/api@sha256:abc123' }, + }); }); }); }); diff --git a/src/Web/StellaOps.Web/src/tests/evidence/evidence-thread.service.spec.ts b/src/Web/StellaOps.Web/src/tests/evidence/evidence-thread.service.spec.ts index 650cb41e4..119ac44aa 100644 --- a/src/Web/StellaOps.Web/src/tests/evidence/evidence-thread.service.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/evidence/evidence-thread.service.spec.ts @@ -4,7 +4,7 @@ import { TestBed } from '@angular/core/testing'; import { EvidenceThreadService } from '../../app/features/evidence-thread/services/evidence-thread.service'; -describe('EvidenceThreadService loading behavior', () => { +describe('EvidenceThreadService compatibility actions', () => { let service: EvidenceThreadService; let httpMock: HttpTestingController; @@ -21,56 +21,29 @@ describe('EvidenceThreadService loading behavior', () => { httpMock.verify(); }); - it('does not toggle global loading when generating transcript', () => { - expect(service.loading()).toBeFalse(); + it('fails closed without network traffic when transcript generation is requested', () => { + let actual: unknown = 'pending'; - let actual: unknown = null; - service.generateTranscript('sha256:artifact-dev', { transcriptType: 'summary' }).subscribe((value) => { + service.generateTranscript('canon-1', { transcriptType: 'summary' }).subscribe((value) => { actual = value; }); - const request = httpMock.expectOne('/api/v1/evidence/sha256%3Aartifact-dev/transcript'); - expect(request.request.method).toBe('POST'); + expect(actual).toBeNull(); expect(service.loading()).toBeFalse(); - - request.flush({ - id: 'transcript-1', - tenantId: 'tenant-a', - threadId: 'thread-1', - transcriptType: 'summary', - templateVersion: 'v1', - content: 'summary text', - anchors: [], - generatedAt: '2026-02-11T00:00:00Z', - }); - - expect(service.loading()).toBeFalse(); - expect(actual).not.toBeNull(); + expect(service.error()).toContain('not supported'); + expect(httpMock.match(() => true).length).toBe(0); }); - it('does not toggle global loading when exporting thread content', () => { - expect(service.loading()).toBeFalse(); + it('fails closed without network traffic when evidence export is requested', () => { + let actual: unknown = 'pending'; - let actual: unknown = null; - service.exportThread('sha256:artifact-dev', { format: 'json', sign: false }).subscribe((value) => { + service.exportThread('canon-1', { format: 'json', sign: false }).subscribe((value) => { actual = value; }); - const request = httpMock.expectOne('/api/v1/evidence/sha256%3Aartifact-dev/export'); - expect(request.request.method).toBe('POST'); + expect(actual).toBeNull(); expect(service.loading()).toBeFalse(); - - request.flush({ - id: 'export-1', - tenantId: 'tenant-a', - threadId: 'thread-1', - exportFormat: 'json', - contentHash: 'sha256:export-1', - storagePath: '/tmp/export-1.json', - createdAt: '2026-02-11T00:00:00Z', - }); - - expect(service.loading()).toBeFalse(); - expect(actual).not.toBeNull(); + expect(service.error()).toContain('not supported'); + expect(httpMock.match(() => true).length).toBe(0); }); });