Fix live evidence and registry auth contracts

This commit is contained in:
master
2026-03-08 22:54:36 +02:00
parent 6efed23647
commit 4f445ad951
24 changed files with 1404 additions and 1576 deletions

View File

@@ -668,6 +668,11 @@ public static class StellaOpsScopes
/// </summary>
public const string IntegrationOperate = "integration:operate";
/// <summary>
/// Scope granting administrative access to registry plan and audit surfaces.
/// </summary>
public const string RegistryAdmin = "registry.admin";
private static readonly IReadOnlyList<string> AllScopes = BuildAllScopes();
private static readonly HashSet<string> KnownScopes = new(AllScopes, StringComparer.OrdinalIgnoreCase);

View File

@@ -36,14 +36,24 @@ internal sealed class StandardPluginBootstrapper : IHostedService
{
using var scope = scopeFactory.CreateScope();
var optionsMonitor = scope.ServiceProvider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var credentialStore = scope.ServiceProvider.GetRequiredService<StandardUserCredentialStore>();
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<StandardUserCredentialStore>();
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<BootstrapClientOptions> bootstrapClients,
CancellationToken cancellationToken)
{
if (bootstrapClients.Count == 0)
{
return;
}
var clientProvisioningStore = services.GetRequiredService<StandardClientProvisioningStore>();
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;
}

View File

@@ -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<BootstrapClientOptions>();
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<BootstrapClientOptions>();
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<string, string?>(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<string>();
}
return raw
.Split([' ', ',', ';', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.ToArray();
}
private static IReadOnlyCollection<Uri> ParseUris(string? raw, string? pluginName, string clientId, string propertyName)
{
var values = SplitValues(raw);
if (values.Length == 0)
{
return Array.Empty<Uri>();
}
var uris = new List<Uri>(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; }

View File

@@ -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<AuthorityClientCertificateBinding>()
: 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<string, string?> 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)

View File

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

View File

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

View File

@@ -2,160 +2,107 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
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<EvidenceThreadViewComponent>;
let mockEvidenceService: jasmine.SpyObj<EvidenceThreadService>;
let routeParams$: BehaviorSubject<any>;
let router: Router;
let routeParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let queryParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
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<string | null>(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();
});
});

View File

@@ -2,58 +2,19 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
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');
});
});

View File

@@ -1,10 +1,10 @@
<!-- Evidence Thread List Component -->
<div class="evidence-thread-list">
<!-- Header -->
<header class="list-header">
<div class="header-left">
<h1>Evidence Threads</h1>
<p class="subtitle">View and manage evidence chains for your artifacts</p>
<p class="subtitle">
Search canonical evidence records by package URL against the shipped EvidenceLocker API.
</p>
</div>
<div class="header-right">
<button mat-icon-button (click)="onRefresh()" matTooltip="Refresh" [disabled]="loading()">
@@ -13,50 +13,33 @@
</div>
</header>
<!-- Filters -->
<mat-card class="filters-card">
<div class="filters-row">
<mat-form-field appearance="outline" class="search-field">
<mat-label>Search artifacts</mat-label>
<mat-label>Package URL</mat-label>
<svg matPrefix xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
matInput
[(ngModel)]="searchQuery"
(keyup.enter)="onSearch()"
placeholder="Search by artifact name or digest...">
placeholder="pkg:oci/acme/api@sha256:...">
@if (searchQuery) {
<button mat-icon-button matSuffix (click)="searchQuery = ''; onSearch()">
<button mat-icon-button matSuffix (click)="onClearSearch()">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
}
</mat-form-field>
<mat-form-field appearance="outline" class="filter-field">
<mat-label>Status</mat-label>
<mat-select [(value)]="statusFilter" (selectionChange)="onFilterChange()">
@for (option of statusOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="filter-field">
<mat-label>Verdict</mat-label>
<mat-select [(value)]="verdictFilter" (selectionChange)="onFilterChange()">
@for (option of verdictOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<button mat-raised-button color="primary" (click)="onSearch()">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Search
</button>
</div>
<p class="filters-hint">
EvidenceLocker lists threads only for a specific PURL. Enter the exact package URL you want to inspect.
</p>
</mat-card>
<!-- Loading State -->
@if (loading()) {
<div class="loading-container">
<mat-spinner diameter="48"></mat-spinner>
@@ -64,7 +47,6 @@
</div>
}
<!-- Error State -->
@if (error() && !loading()) {
<div class="error-container">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
@@ -75,76 +57,59 @@
</div>
}
<!-- Data Table -->
@if (!loading() && !error()) {
<mat-card class="table-card">
@if (threads().length === 0) {
@if (!searchedPurl()) {
<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
<p>Enter a package URL to search for canonical evidence records.</p>
<p class="hint">Example: <code>pkg:oci/stellaops/api@sha256:...</code></p>
</div>
} @else if (threads().length === 0) {
<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<p>No evidence threads found</p>
<p class="hint">Evidence threads are created when artifacts are scanned and evaluated.</p>
<p>No evidence threads matched this package URL.</p>
<p class="hint">Try the exact PURL stored by EvidenceLocker for the artifact you are investigating.</p>
</div>
} @else {
<table mat-table [dataSource]="threads()" class="threads-table">
<!-- Artifact Name Column -->
<ng-container matColumnDef="artifactName">
<th mat-header-cell *matHeaderCellDef>Artifact</th>
<ng-container matColumnDef="canonicalId">
<th mat-header-cell *matHeaderCellDef>Canonical ID</th>
<td mat-cell *matCellDef="let thread">
<div class="artifact-cell">
<span class="artifact-name">{{ thread.artifactName ?? 'Unnamed' }}</span>
<app-digest-chip
[digest]="thread.artifactDigest"
variant="artifact"
></app-digest-chip>
<span class="artifact-name">{{ thread.canonicalId }}</span>
</div>
</td>
</ng-container>
<!-- Verdict Column -->
<ng-container matColumnDef="verdict">
<th mat-header-cell *matHeaderCellDef>Verdict</th>
<ng-container matColumnDef="format">
<th mat-header-cell *matHeaderCellDef>Format</th>
<td mat-cell *matCellDef="let thread">
<mat-chip [ngClass]="'verdict-' + getVerdictColor(thread.verdict)">
<span matChipAvatar [innerHTML]="getVerdictIconSvg(thread.verdict)"></span>
{{ thread.verdict ?? 'Unknown' | titlecase }}
</mat-chip>
{{ thread.format }}
</td>
</ng-container>
<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<ng-container matColumnDef="purl">
<th mat-header-cell *matHeaderCellDef>PURL</th>
<td mat-cell *matCellDef="let thread">
<span class="status-badge" [class]="'status-' + thread.status">
<span [innerHTML]="getStatusIconSvg(thread.status)"></span>
{{ thread.status | titlecase }}
</span>
<code class="cell-code">{{ thread.purl ?? '-' }}</code>
</td>
</ng-container>
<!-- Risk Score Column -->
<ng-container matColumnDef="riskScore">
<th mat-header-cell *matHeaderCellDef>Risk Score</th>
<ng-container matColumnDef="attestationCount">
<th mat-header-cell *matHeaderCellDef>Attestations</th>
<td mat-cell *matCellDef="let thread">
@if (thread.riskScore !== undefined && thread.riskScore !== null) {
<span class="risk-score" [class]="getRiskClass(thread.riskScore)">
{{ thread.riskScore | number:'1.1-1' }}
</span>
} @else {
<span class="no-score">-</span>
}
{{ formatCount(thread.attestationCount) }}
</td>
</ng-container>
<!-- Updated At Column -->
<ng-container matColumnDef="updatedAt">
<th mat-header-cell *matHeaderCellDef>Last Updated</th>
<ng-container matColumnDef="createdAt">
<th mat-header-cell *matHeaderCellDef>Created</th>
<td mat-cell *matCellDef="let thread">
{{ formatDate(thread.updatedAt) }}
{{ formatDate(thread.createdAt) }}
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let thread">
@@ -162,15 +127,6 @@
class="clickable-row">
</tr>
</table>
<mat-paginator
[length]="totalItems()"
[pageSize]="pageSize()"
[pageIndex]="pageIndex()"
[pageSizeOptions]="[10, 20, 50, 100]"
(page)="onPageChange($event)"
showFirstLastButtons>
</mat-paginator>
}
</mat-card>
}

View File

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

View File

@@ -2,31 +2,30 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
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<string, string> = {
check_circle: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
warning: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
block: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>',
schedule: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
help_outline: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
};
private readonly destroy$ = new Subject<void>();
private readonly statusIconSvgMap: Record<string, string> = {
play_circle: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>',
archive: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>',
cloud_done: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/><polyline points="10 15 12 17 16 13"/></svg>',
};
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<string | null>(null);
// Pagination
readonly totalItems = signal<number>(0);
readonly pageSize = signal<number>(20);
readonly pageIndex = signal<number>(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<EvidenceVerdict, string> = {
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<EvidenceThreadStatus, string> = {
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();
}
}

View File

@@ -1,169 +1,146 @@
<!-- Evidence Thread View Component -->
<div class="evidence-thread-view">
<!-- Header -->
<header class="thread-header">
<div class="header-left">
<button mat-icon-button (click)="onBack()" [matTooltip]="'ui.evidence_thread.back_to_list' | translate">
<button mat-icon-button (click)="onBack()" matTooltip="Back to evidence thread search">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
</button>
<div class="thread-info">
<h1 class="thread-title">
@if (thread()?.thread?.artifactName) {
{{ thread()?.thread?.artifactName }}
} @else {
{{ 'ui.evidence_thread.title_default' | translate }}
}
</h1>
<h1 class="thread-title">{{ thread()?.purl ?? canonicalId() }}</h1>
<div class="thread-digest">
<app-digest-chip
[digest]="artifactDigest()"
variant="artifact"
></app-digest-chip>
<code>{{ canonicalId() }}</code>
</div>
</div>
</div>
<div class="header-right">
@if (thread()?.thread) {
@if (thread()) {
<mat-chip-set>
<mat-chip [ngClass]="'verdict-' + verdictClass()">
<span matChipAvatar [innerHTML]="getVerdictIconSvg(thread()?.thread?.verdict)"></span>
{{ getVerdictLabel(thread()?.thread?.verdict) }}
<mat-chip>
{{ thread()?.format | uppercase }}
</mat-chip>
@if (thread()?.thread?.riskScore !== undefined && thread()?.thread?.riskScore !== null) {
<mat-chip>
{{ attestationCount() }} attestations
</mat-chip>
@if (thread()?.transparencyStatus?.mode) {
<mat-chip>
<svg matChipAvatar xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
{{ 'ui.evidence_thread.risk_label' | translate }} {{ thread()?.thread?.riskScore | number:'1.1-1' }}
{{ formatTransparencyMode(thread()?.transparencyStatus?.mode) }} transparency
</mat-chip>
}
<mat-chip>
<svg matChipAvatar xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
{{ nodeCount() }} {{ 'ui.evidence_thread.nodes' | translate }}
</mat-chip>
</mat-chip-set>
}
<div class="header-actions">
<button mat-icon-button (click)="onRefresh()" [matTooltip]="'ui.actions.refresh' | translate" [disabled]="loading()">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
</button>
<button mat-raised-button color="primary" (click)="onExport()" [disabled]="loading() || !thread()">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
{{ 'ui.actions.export' | translate }}
</button>
</div>
<button mat-icon-button (click)="onRefresh()" matTooltip="Refresh" [disabled]="loading()">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
</button>
</div>
</header>
<!-- Loading State -->
@if (loading()) {
<div class="loading-container">
<mat-spinner diameter="48"></mat-spinner>
<p>{{ 'ui.evidence_thread.loading' | translate }}</p>
<p>Loading evidence thread...</p>
</div>
}
<!-- Error State -->
@if (error() && !loading()) {
<div class="error-container">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<p>{{ error() }}</p>
<button mat-raised-button color="primary" (click)="onRefresh()">
{{ 'ui.error.try_again' | translate }}
Try Again
</button>
<button mat-button (click)="onBack()">Back to Search</button>
</div>
}
<!-- Content -->
@if (thread() && !loading()) {
<div class="thread-content">
<!-- Tab Navigation -->
<mat-tab-group
[selectedIndex]="selectedTabIndex()"
(selectedIndexChange)="onTabChange($event)"
animationDuration="200ms">
<mat-card class="summary-card">
<h2>Record Summary</h2>
<dl class="summary-grid">
<div>
<dt>Canonical ID</dt>
<dd><code>{{ thread()?.canonicalId }}</code></dd>
</div>
<div>
<dt>Format</dt>
<dd>{{ thread()?.format }}</dd>
</div>
<div>
<dt>Created</dt>
<dd>{{ formatDate(thread()?.createdAt ?? '') }}</dd>
</div>
<div>
<dt>Package URL</dt>
<dd><code>{{ thread()?.purl ?? '-' }}</code></dd>
</div>
<div>
<dt>Artifact Digest</dt>
<dd>
@if (thread()?.artifactDigest) {
<app-digest-chip [digest]="thread()!.artifactDigest!" variant="artifact"></app-digest-chip>
} @else {
-
}
</dd>
</div>
<div>
<dt>Transparency</dt>
<dd>
@if (thread()?.transparencyStatus?.mode) {
{{ formatTransparencyMode(thread()?.transparencyStatus?.mode) }}
@if (thread()?.transparencyStatus?.reason) {
<span class="transparency-reason">({{ thread()?.transparencyStatus?.reason }})</span>
}
} @else {
-
}
</dd>
</div>
</dl>
</mat-card>
<!-- Graph Tab -->
<mat-tab>
<ng-template mat-tab-label>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
<span>{{ 'ui.evidence_thread.graph_tab' | translate }}</span>
</ng-template>
<ng-template matTabContent>
<div class="tab-content">
<stella-evidence-graph-panel
[nodes]="nodes()"
[links]="links()"
[selectedNodeId]="selectedNodeId()"
(nodeSelect)="onNodeSelect($event)">
</stella-evidence-graph-panel>
</div>
</ng-template>
</mat-tab>
<!-- Timeline Tab -->
<mat-tab>
<ng-template mat-tab-label>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
<span>{{ 'ui.evidence_thread.timeline_tab' | translate }}</span>
</ng-template>
<ng-template matTabContent>
<div class="tab-content">
<stella-evidence-timeline-panel
[nodes]="nodes()"
[selectedNodeId]="selectedNodeId()"
(nodeSelect)="onNodeSelect($event)">
</stella-evidence-timeline-panel>
</div>
</ng-template>
</mat-tab>
<!-- Transcript Tab -->
<mat-tab>
<ng-template mat-tab-label>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<span>{{ 'ui.evidence_thread.transcript_tab' | translate }}</span>
</ng-template>
<ng-template matTabContent>
<div class="tab-content">
<stella-evidence-transcript-panel
[artifactDigest]="artifactDigest()"
[thread]="thread()?.thread"
[nodes]="nodes()">
</stella-evidence-transcript-panel>
</div>
</ng-template>
</mat-tab>
</mat-tab-group>
<!-- Selected Node Detail Panel (Side Panel) -->
@if (selectedNodeId()) {
<aside class="node-detail-panel">
@for (node of nodes(); track node.id) {
@if (node.id === selectedNodeId()) {
<stella-evidence-node-card
[node]="node"
[expanded]="true"
(close)="selectedNodeId.set(null)">
</stella-evidence-node-card>
}
}
</aside>
}
<mat-card class="summary-card">
<h2>Attestations</h2>
@if (thread()?.attestations?.length === 0) {
<div class="empty-container compact">
<p>No attestations are currently attached to this canonical record.</p>
</div>
} @else {
<table class="attestations-table">
<thead>
<tr>
<th>Predicate Type</th>
<th>Signed</th>
<th>DSSE Digest</th>
<th>Signer</th>
<th>Rekor Entry</th>
</tr>
</thead>
<tbody>
@for (attestation of thread()?.attestations ?? []; track trackAttestation($index, attestation)) {
<tr>
<td>{{ attestation.predicateType }}</td>
<td>{{ formatDate(attestation.signedAt) }}</td>
<td><code>{{ attestation.dsseDigest }}</code></td>
<td><code>{{ attestation.signerKeyId ?? '-' }}</code></td>
<td><code>{{ attestation.rekorEntryId ?? '-' }}</code></td>
</tr>
}
</tbody>
</table>
}
</mat-card>
</div>
}
<!-- Empty State -->
@if (!thread() && !loading() && !error()) {
<div class="empty-container">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
<p>{{ 'ui.evidence_thread.not_found' | translate }}</p>
<p>No evidence thread is selected.</p>
<button mat-raised-button (click)="onBack()">
{{ 'ui.actions.back_to_list' | translate }}
Back to Search
</button>
</div>
}

View File

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

View File

@@ -2,27 +2,28 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
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<string, string> = {
check_circle: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
warning: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
block: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>',
schedule: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
help_outline: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
};
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<void>();
readonly artifactDigest = signal<string>('');
readonly selectedTabIndex = signal<number>(0);
readonly selectedNodeId = signal<string | null>(null);
readonly canonicalId = signal('');
readonly returnPurl = signal<string | null>(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<EvidenceVerdict, string> = {
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();
}
}

View File

@@ -2,32 +2,74 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
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<EvidenceThreadGraph | null>(null);
private readonly _threads = signal<EvidenceThread[]>([]);
private readonly _loading = signal<boolean>(false);
private readonly _currentThread = signal<EvidenceThread | null>(null);
private readonly _threads = signal<EvidenceThreadSummary[]>([]);
private readonly _loading = signal(false);
private readonly _error = signal<string | null>(null);
private readonly _currentNodes = signal<EvidenceNode[]>([]);
private readonly _currentLinks = signal<EvidenceLink[]>([]);
// 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<EvidenceNodeKind, EvidenceNode[]>);
});
/**
* Fetches a list of evidence threads with optional filtering.
*/
getThreads(filter?: EvidenceThreadFilter): Observable<EvidenceThreadListResponse> {
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<EvidenceThreadListApiResponse>(`${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<EvidenceThread | null> {
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<EvidenceThreadApiModel>(`${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<EvidenceThreadListResponse>(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<EvidenceThread | null> {
return this.getThreadByCanonicalId(canonicalId);
}
getNodes(_canonicalId: string): Observable<EvidenceNode[]> {
return of([]);
}
getLinks(_canonicalId: string): Observable<EvidenceLink[]> {
return of([]);
}
generateTranscript(
_canonicalId: string,
_request: TranscriptRequest
): Observable<EvidenceTranscript | null> {
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<EvidenceThreadGraph | null> {
this._loading.set(true);
this._error.set(null);
const encodedDigest = encodeURIComponent(artifactDigest);
return this.httpClient.get<EvidenceThreadGraph>(`${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<EvidenceExport | null> {
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<EvidenceNode[]> {
const encodedDigest = encodeURIComponent(artifactDigest);
return this.httpClient.get<EvidenceNode[]>(`${this.apiBase}/${encodedDigest}/nodes`).pipe(
catchError(err => {
this._error.set(err.message ?? 'Failed to fetch evidence nodes');
return of([]);
})
downloadExport(_exportId: string): Observable<Blob> {
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<EvidenceLink[]> {
const encodedDigest = encodeURIComponent(artifactDigest);
return this.httpClient.get<EvidenceLink[]>(`${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<EvidenceTranscript | null> {
const encodedDigest = encodeURIComponent(artifactDigest);
return this.httpClient.post<EvidenceTranscript>(
`${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<EvidenceExport | null> {
const encodedDigest = encodeURIComponent(artifactDigest);
return this.httpClient.post<EvidenceExport>(
`${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<Blob> {
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<EvidenceNodeKind, string> = {
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<EvidenceNodeKind, string> = {
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<EvidenceVerdict, string> = {
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<EvidenceLinkRelation, string> = {
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;
}
}

View File

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

View File

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

View File

@@ -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<EvidenceThreadListComponent>;
let component: EvidenceThreadListComponent;
let router: Router;
let queryParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
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<EvidenceThread[]>([thread]),
threads: signal([thread]),
loading: signal(false),
error: signal<string | null>(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<EvidenceThreadViewComponent>;
let component: EvidenceThreadViewComponent;
let router: Router;
let routeParams$: BehaviorSubject<Record<string, string>>;
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<string | null>(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<Record<string, string>>({
artifactDigest: encodeURIComponent('sha256:artifact-1'),
});
const dialogStub = {
open: jasmine.createSpy('open').and.returnValue({
afterClosed: () => of(null),
}),
};
const viewServiceStub = {
currentThread: signal<EvidenceThreadGraph | null>(graph),
loading: signal(false),
error: signal<string | null>(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' },
});
});
});
});

View File

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